From ceb489611c9a8807645472440b8b5cf5958ff4c1 Mon Sep 17 00:00:00 2001 From: omartelo Date: Sat, 17 Oct 2026 12:58:00 -0300 Subject: [PATCH 1/4] fix(store): one card per conversation on reopen, one history row per conversation Reopening a parked row whose conversation an open session already runs now hands back that open session instead of a second card on the same transcript; both windows already focus an id that is on screen. History lists each conversation once, by its newest park, and hides one a card has open; Forget removes every parked copy of it. --- CHANGELOG.md | 6 ++ internal/store/history.go | 16 +++++- internal/store/history_test.go | 56 ++++++++++++++++++ internal/store/mutations.go | 85 +++++++++++++++++++--------- internal/store/reopen_test.go | 41 ++++++++++++++ native/cmd/lich-native/close_undo.go | 3 +- 6 files changed, 178 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ded167d..495c242e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 apiece for a CJK or emoji font: six open sessions now take about half the memory they did. It also starts quicker on a machine with CJK fonts installed. +- **Reopening a session whose conversation is already open focuses that + card.** A closed session from History, an undone close or an existing + worktree could come back as a second card on a conversation another card + was still running, and the two wrote into the same transcript. History + lists such a conversation once, by its latest close, and Forget removes + every copy of it. ## [0.69.0] - 2026-10-16 diff --git a/internal/store/history.go b/internal/store/history.go index b88fdf8e..b8bbedc1 100644 --- a/internal/store/history.go +++ b/internal/store/history.go @@ -105,6 +105,10 @@ func escapeLike(term string) string { // not reached yet, which is the other way this answer can be short; asking for // one with a term is what starts that backfill. // +// A conversation is listed once, by its newest park, and not at all while a +// card has it open (newestOfItsConversation): its copies, left by a `/resume` +// typed into another card, all reopen the same transcript. +// // rowid is the tiebreak, not the order, for RecentProjects' reason twice over: // it dates the insert, and a resumed session is reinserted — so rows parked // before closed_at existed all carry 0 and fall back to it together. @@ -182,6 +186,16 @@ type sessionMatch struct { args []any } +// newestOfItsConversation keeps a parked row s that runs no conversation, or +// whose conversation no other row holds open or parked after it, in the same +// order ClosedSessions sorts by. +// provider_session_id has no index, so this scans the table once per parked +// row: 18 ms over a 900-row workspace (measured 2026-10-17). +const newestOfItsConversation = `(s.provider_session_id = '' OR NOT EXISTS ( + SELECT 1 FROM sessions o + WHERE o.provider_session_id = s.provider_session_id AND o.id <> s.id + AND (o.is_open = 1 OR o.closed_at > s.closed_at OR (o.closed_at = s.closed_at AND o.rowid > s.rowid))))` + // closedSessionsMatch is the OR the search is built around, as SQL: every word // in the name half, or the conversation in the index. An empty term is no // condition at all beyond the row being parked. @@ -191,7 +205,7 @@ type sessionMatch struct { // which on a workspace of two hundred sessions costs tens of seconds instead of // tens of milliseconds. func closedSessionsMatch(term string) sessionMatch { - m := sessionMatch{where: "WHERE s.is_open = 0", said: "0", args: []any{}} + m := sessionMatch{where: "WHERE s.is_open = 0 AND " + newestOfItsConversation, said: "0", args: []any{}} if fts := ftsQuery(term); fts != "" { m.with = `WITH said AS MATERIALIZED ( SELECT t.session_id FROM session_texts t diff --git a/internal/store/history_test.go b/internal/store/history_test.go index e9ae3266..ba25d3b4 100644 --- a/internal/store/history_test.go +++ b/internal/store/history_test.go @@ -620,3 +620,59 @@ func TestClosedSessionsMatch(t *testing.T) { }) } } + +// TestClosedSessionsListsAConversationOnce: copies of one conversation (a +// `/resume` typed into another card, then both closed) are one entry to +// resume, the newest park, and a conversation still running in a card is not +// history at all: reopening it only focuses that card. A row with no +// conversation is listed on its own, as it always was. +func TestClosedSessionsListsAConversationOnce(t *testing.T) { + svc := newTestStore(t) + _ = svc.AddProject("p1", "alpha", "/tmp/alpha") + _ = svc.AddSession("p1", "keep", "Session 1", "claude", "", 2, "") + park(t, svc, "older", "older copy", 100) + park(t, svc, "newer", "newer copy", 200) + park(t, svc, "shell", "a shell", 300) + park(t, svc, "copy", "copy of the open one", 400) + _ = svc.SetProviderSession("older", "conv-parked") + _ = svc.SetProviderSession("newer", "conv-parked") + _ = svc.SetProviderSession("copy", "conv-open") + _ = svc.SetProviderSession("keep", "conv-open") + + var ids []string + for _, c := range mustClosed(t, svc, "") { + ids = append(ids, c.ID) + } + if !slices.Equal(ids, []string{"shell", "newer"}) { + t.Errorf("history = %v, want [shell newer]", ids) + } +} + +// TestForgetSessionForgetsEveryCopyOfItsConversation: the history shows one +// row per conversation, so forgetting that row forgets the conversation; a +// copy left behind would take its place in the list the moment it was gone. +func TestForgetSessionForgetsEveryCopyOfItsConversation(t *testing.T) { + svc := newTestStore(t) + var gone []string + svc.SetSessionGone(func(id string) { gone = append(gone, id) }) + _ = svc.AddProject("p1", "alpha", "/tmp/alpha") + _ = svc.AddSession("p1", "keep", "Session 1", "claude", "", 2, "") + park(t, svc, "older", "older copy", 100) + park(t, svc, "newer", "newer copy", 200) + park(t, svc, "other", "another conversation", 300) + _ = svc.SetProviderSession("older", "conv-parked") + _ = svc.SetProviderSession("newer", "conv-parked") + _ = svc.SetProviderSession("other", "conv-other") + + if err := svc.ForgetSession("newer"); err != nil { + t.Fatalf("ForgetSession: %v", err) + } + closed := mustClosed(t, svc, "") + if len(closed) != 1 || closed[0].ID != "other" { + t.Errorf("closed = %+v, want only the other conversation left", closed) + } + slices.Sort(gone) + if !slices.Equal(gone, []string{"newer", "older"}) { + t.Errorf("reported gone = %v, want both copies", gone) + } +} diff --git a/internal/store/mutations.go b/internal/store/mutations.go index 0a0e37e3..e605b3d4 100644 --- a/internal/store/mutations.go +++ b/internal/store/mutations.go @@ -264,28 +264,26 @@ func (s *Service) SessionBranch(sessionID string) string { return s.branchOf(path) } -// ForgetSession deletes one parked session for good. It is the way out for the -// row whose checkout was removed behind lich's back: nothing can resume it, and -// PurgeWorktreeSessions never ran for it because the removal never went through -// the app. +// ForgetSession deletes one parked session for good, with every other parked +// copy of its conversation: the history lists a conversation once +// (ClosedSessions), so a copy left behind would take the forgotten row's place. +// It is the way out for the row whose checkout was removed behind lich's back: +// nothing can resume it, and PurgeWorktreeSessions never ran for it because the +// removal never went through the app. // // is_open = 0 is the whole guard: a session on screen is closed, never // forgotten, so an id belonging to a live card matches nothing here. A row that -// was already gone is not an error, and only a delete that actually removed one -// reports the session gone. +// was already gone is not an error, and only the rows a delete actually removed +// are reported gone. func (s *Service) ForgetSession(sessionID string) error { - s.noteForfeitedSchedules(`id = ? AND is_open = 0`, sessionID) - res, err := s.db.Exec(`DELETE FROM sessions WHERE id = ? AND is_open = 0`, sessionID) - if err != nil { + const parkedCopies = `is_open = 0 AND (id = ? OR (provider_session_id <> '' AND provider_session_id = + (SELECT provider_session_id FROM sessions WHERE id = ? AND is_open = 0)))` + s.noteForfeitedSchedules(parkedCopies, sessionID, sessionID) + gone := s.sessionIDsWhere(parkedCopies, sessionID, sessionID) + if _, err := s.db.Exec(`DELETE FROM sessions WHERE `+parkedCopies, sessionID, sessionID); err != nil { return fmt.Errorf("forget session %q: %w", sessionID, err) } - n, err := res.RowsAffected() - if err != nil { - return fmt.Errorf("forget session %q rows: %w", sessionID, err) - } - if n > 0 { - s.sessionIsGone(sessionID) - } + s.sessionIsGone(gone...) return nil } @@ -325,8 +323,14 @@ func (s *Service) ReopenSession(sessionID, newSessionID string) (*Session, error // The fresh id is deliberate: it makes the frontend treat the card as // never-spawned, so its resume prompt fires and the provider conversation // continues instead of starting cold. +// +// A parked row whose conversation an open session already runs is left parked, +// and that open session is returned instead: the caller finds the id already +// on screen and focuses it, where a second card would write into the same +// transcript as the first. func (s *Service) reopen(newSessionID, where string, args ...any) (*Session, error) { var restored *Session + var holderProject, holderID string err := s.tx(func(tx *sql.Tx) error { var old Session // label_auto rides along so a user rename survives the park/resume @@ -375,6 +379,18 @@ func (s *Service) reopen(newSessionID, where string, args ...any) (*Session, err } return fmt.Errorf("find parked session: %w", err) } + if old.ProviderSessionID != "" { + err := tx.QueryRow( + `SELECT project_id, id FROM sessions WHERE provider_session_id = ? AND is_open = 1 LIMIT 1`, + old.ProviderSessionID, + ).Scan(&holderProject, &holderID) + if err == nil { + return nil + } + if !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("find the session holding %q: %w", old.ProviderSessionID, err) + } + } // The cost ledgers are read out before the row goes, because deleting it // cascades them away — and a resumed session that forgot what it spent // would restate its total from zero over the same conversation. @@ -445,9 +461,26 @@ func (s *Service) reopen(newSessionID, where string, args ...any) (*Session, err if err != nil { return nil, err } + if holderID != "" { + return s.openSession(holderProject, holderID) + } return restored, nil } +// openSession is the open session id of projectID, as LoadState reads it. +func (s *Service) openSession(projectID, id string) (*Session, error) { + sessions, err := s.sessionsOf(projectID) + if err != nil { + return nil, err + } + for i := range sessions { + if sessions[i].ID == id { + return &sessions[i], nil + } + } + return nil, fmt.Errorf("session %q is no longer open", id) +} + // PurgeWorktreeSessions deletes every session row for the worktree at path in a // project — the live one and any parked leftovers alike — so removing a worktree // never strands a hidden row that a later resume could resurrect against a @@ -461,7 +494,7 @@ func (s *Service) PurgeWorktreeSessions(projectID, path string) error { s.noteForfeitedSchedules(`project_id = ? AND path = ?`, projectID, path) // Read before the delete, because what hangs off a session outside the // database is keyed by id and the rows are about to be gone. - gone := s.sessionIDsAt(projectID, path) + gone := s.sessionIDsWhere(`project_id = ? AND path = ?`, projectID, path) if _, err := s.db.Exec( `DELETE FROM sessions WHERE project_id = ? AND path = ?`, projectID, path, ); err != nil { @@ -471,19 +504,17 @@ func (s *Service) PurgeWorktreeSessions(projectID, path string) error { return nil } -// sessionIDsAt lists the sessions of a project living at path — the live one -// and any parked leftovers alike. Only asked when something is listening for -// deleted sessions, so a store without that wiring runs the query it always -// ran. A failed read costs the cleanup, never the delete. -func (s *Service) sessionIDsAt(projectID, path string) []string { +// sessionIDsWhere lists the sessions a delete with the same WHERE is about to +// remove. Only asked when something is listening for deleted sessions, so a +// store without that wiring runs the query it always ran. A failed read costs +// the cleanup, never the delete. +func (s *Service) sessionIDsWhere(where string, args ...any) []string { if s.sessionGone == nil { return nil } - rows, err := s.db.Query( - `SELECT id FROM sessions WHERE project_id = ? AND path = ?`, projectID, path, - ) + rows, err := s.db.Query(`SELECT id FROM sessions WHERE `+where, args...) if err != nil { - slog.Warn("list worktree sessions", "path", path, "err", err) + slog.Warn("list sessions to delete", "err", err) return nil } defer rows.Close() @@ -491,7 +522,7 @@ func (s *Service) sessionIDsAt(projectID, path string) []string { for rows.Next() { var id string if err := rows.Scan(&id); err != nil { - slog.Warn("scan worktree session id", "path", path, "err", err) + slog.Warn("scan session id to delete", "err", err) return ids } ids = append(ids, id) diff --git a/internal/store/reopen_test.go b/internal/store/reopen_test.go index 4bafe843..6ab0d892 100644 --- a/internal/store/reopen_test.go +++ b/internal/store/reopen_test.go @@ -42,3 +42,44 @@ func TestReopenWorktreeSessionTakesTheNewestParkedRow(t *testing.T) { t.Errorf("parked rows after resume = %d, want the older one alone", parked) } } + +// TestReopenHandsBackTheCardThatHoldsTheConversation: a parked row whose +// conversation an open session already runs is a stale copy of that card (a +// `/resume` typed in another card, then closed), and reopening it would put two +// cards on one transcript. Both doors answer with the open card instead, so the +// window focuses it, and leave the parked copy where it was. +func TestReopenHandsBackTheCardThatHoldsTheConversation(t *testing.T) { + doors := map[string]func(svc *Service) (*Session, error){ + "by id": func(svc *Service) (*Session, error) { return svc.ReopenSession("copy", "fresh") }, + "by worktree": func(svc *Service) (*Session, error) { return svc.ReopenWorktreeSession("p1", "/wt/foo", "fresh") }, + } + for name, reopen := range doors { + t.Run(name, func(t *testing.T) { + svc := newTestStore(t) + _ = svc.AddProject("p1", "alpha", "/tmp/alpha") + _ = svc.AddSession("p1", "holder", "auth-refactor", "claude", "/wt/foo", 2, "") + _ = svc.SetProviderSession("holder", "conv-shared") + _ = svc.AddSession("p1", "copy", "Session 3", "claude", "/wt/foo", 3, "") + _ = svc.SetProviderSession("copy", "conv-shared") + _ = svc.CloseSession("p1", "copy", "holder") + + restored, err := reopen(svc) + if err != nil { + t.Fatalf("reopen: %v", err) + } + if restored == nil || restored.ID != "holder" { + t.Fatalf("reopen = %+v, want the open card holding the conversation", restored) + } + var open, parked int + if err := svc.db.QueryRow( + `SELECT COUNT(*) FILTER (WHERE is_open = 1), COUNT(*) FILTER (WHERE is_open = 0) + FROM sessions WHERE provider_session_id = ?`, "conv-shared", + ).Scan(&open, &parked); err != nil { + t.Fatalf("count rows: %v", err) + } + if open != 1 || parked != 1 { + t.Errorf("rows on the conversation = %d open, %d parked; want the holder open and the copy still parked", open, parked) + } + }) + } +} diff --git a/native/cmd/lich-native/close_undo.go b/native/cmd/lich-native/close_undo.go index ca8ae491..0a4a1e73 100644 --- a/native/cmd/lich-native/close_undo.go +++ b/native/cmd/lich-native/close_undo.go @@ -27,7 +27,8 @@ func (u *window) closedToast(projectID string, s lichclient.Session, index int, // store can sit on a lock for seconds, and a reopen that overtook it would // find the row still open and do nothing. The row comes back under a fresh // id, unspawned, so the spawn gate offers the conversation's resume as for -// any restored card. +// any restored card; when another open card already runs that conversation, +// the store hands that card back instead and the undo only focuses it. func (u *window) undoClose(projectID string, s lichclient.Session, index int, parked <-chan error) { if err := <-parked; err != nil { return // closeSession logged it; there is no parked row to bring back From 89d191b05c6f92ceb64849326511b4a827397875 Mon Sep 17 00:00:00 2001 From: omartelo Date: Sat, 17 Oct 2026 13:49:56 -0300 Subject: [PATCH 2/4] feat(native/ui): toaster position, any of the four corners ToasterStyle.Position is sonner's position prop: top-right (the zero value and the default), top-left, bottom-right or bottom-left. A bottom stack grows up and enters from below, and a swipe leaves toward the corner's own side and edge. The gallery takes -toasts-at to shoot each corner. --- native/cmd/ui-gallery/main.go | 4 ++ native/cmd/ui-gallery/toasts.go | 6 +++ native/ui/toast_body.go | 20 ++++---- native/ui/toast_test.go | 89 +++++++++++++++++++++++++++++++-- native/ui/toast_view.go | 67 ++++++++++++++++++++----- 5 files changed, 160 insertions(+), 26 deletions(-) diff --git a/native/cmd/ui-gallery/main.go b/native/cmd/ui-gallery/main.go index 96f32c02..2fd1c028 100644 --- a/native/cmd/ui-gallery/main.go +++ b/native/cmd/ui-gallery/main.go @@ -40,6 +40,9 @@ const pagePad unit.Dp = 24 func main() { flag.Parse() + if _, ok := toastPositions[*flagToastsAt]; !ok { + log.Fatalf("-toasts-at %q: want top-right, top-left, bottom-right or bottom-left", *flagToastsAt) + } go func() { w := new(app.Window) w.Option(app.Title("lich ui gallery"), app.Size(unit.Dp(1400), unit.Dp(900))) @@ -183,6 +186,7 @@ func (g *gallery) layout(gtx C) D { g.layoutDialog(gtx) toaster := g.th.Toaster(&g.toaster, false) toaster.Expand = *flagToastsExpanded + toaster.Position = toastPositions[*flagToastsAt] toaster.Layout(gtx) return g.th.ScrollArea(&g.page).Layout(gtx, 1, func(gtx C, _ int) D { return layout.UniformInset(pagePad).Layout(gtx, g.content) diff --git a/native/cmd/ui-gallery/toasts.go b/native/cmd/ui-gallery/toasts.go index 69be68fb..8b94bc52 100644 --- a/native/cmd/ui-gallery/toasts.go +++ b/native/cmd/ui-gallery/toasts.go @@ -13,8 +13,14 @@ import ( var ( flagToasts = flag.Bool("toasts", false, "raise the demo toasts at start, for -shot") flagToastsExpanded = flag.Bool("toasts-expanded", false, "draw the toast stack expanded, as a hover would, for -shot") + flagToastsAt = flag.String("toasts-at", "top-right", "the corner the toasts stack in: top-right, top-left, bottom-right or bottom-left") ) +var toastPositions = map[string]ui.ToastPosition{ + "top-right": ui.ToastTopRight, "top-left": ui.ToastTopLeft, + "bottom-right": ui.ToastBottomRight, "bottom-left": ui.ToastBottomLeft, +} + // toastDemoCount is len(toastDemos), which an array length cannot read. const toastDemoCount = 9 diff --git a/native/ui/toast_body.go b/native/ui/toast_body.go index edfa46e0..94abe398 100644 --- a/native/ui/toast_body.go +++ b/native/ui/toast_body.go @@ -139,14 +139,17 @@ func (s ToasterStyle) place(gtx C) { before += slot.own live++ } - e.view.animate(gtx, targetOf(e, slot), width, inset, e.Description != "" || e.Progress != nil) + center, scale, y, h := e.view.animate(gtx, targetOf(e, slot), width, inset, e.Description != "" || e.Progress != nil) + e.view.place = s.Position.placeOf(center, scale, y, h) e.view.canHit = visible(i) && !e.removed() } } -// animate runs the toast's transitions toward tg and works out its box. -// A toast drawn for the first time enters from translateY(-100%). -func (v *toastView) animate(gtx C, tg toastTarget, width, inset int, wrapped bool) { +// animate runs the toast's transitions toward tg and works out its box, and +// returns what placing it takes: its center, scale, distance from the stack's +// edge and height. A toast drawn for the first time enters from +// translateY(-100%). +func (v *toastView) animate(gtx C, tg toastTarget, width, inset int, wrapped bool) (center f32.Point, scale, y, h float32) { if !v.seeded { v.seeded = true v.y.seed(-tg.height) @@ -155,9 +158,9 @@ func (v *toastView) animate(gtx C, tg toastTarget, width, inset int, wrapped boo v.height.seed(tg.height) v.content.seed(tg.inside) } - y := v.y.at(gtx, tg.y, tg.move, ease) - scale := v.scale.at(gtx, tg.scale, tg.move, ease) - h := v.height.at(gtx, tg.height, toastTransition, ease) + y = v.y.at(gtx, tg.y, tg.move, ease) + scale = v.scale.at(gtx, tg.scale, tg.move, ease) + h = v.height.at(gtx, tg.height, toastTransition, ease) v.alpha = v.opacity.at(gtx, tg.alpha, tg.fade, ease) v.inside = v.content.at(gtx, tg.inside, toastTransition, ease) if v.drag.active { @@ -173,8 +176,7 @@ func (v *toastView) animate(gtx C, tg toastTarget, width, inset int, wrapped boo if !wrapped { v.bodyAt.Y = (v.boxSize.Y - v.bodySize.Y) / 2 } - center := f32.Pt(float32(width)/2, h/2) - v.place = f32.Affine2D{}.Scale(center, f32.Pt(scale, scale)).Offset(f32.Pt(0, y)) + return f32.Pt(float32(width)/2, h/2), scale, y, h } // measure records the toast's content at the inner width. diff --git a/native/ui/toast_test.go b/native/ui/toast_test.go index e8696278..13f45f74 100644 --- a/native/ui/toast_test.go +++ b/native/ui/toast_test.go @@ -184,6 +184,7 @@ type toasterRig struct { router input.Router now time.Time size image.Point + pos ToastPosition } func newToasterRig(t *testing.T) *toasterRig { @@ -197,7 +198,9 @@ func (r *toasterRig) frame() { Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1}, Constraints: layout.Exact(r.size), } - testTheme().Toaster(&r.state, false).Layout(gtx) + toaster := testTheme().Toaster(&r.state, false) + toaster.Position = r.pos + toaster.Layout(gtx) r.router.Frame(ops) } @@ -212,9 +215,85 @@ func (r *toasterRig) pointer(kind pointer.Kind, at f32.Point) { r.router.Queue(pointer.Event{Kind: kind, Source: pointer.Mouse, Position: at, Buttons: buttons}) } -// onFront is a point in the front toast's box, top-right corner. +// onFront is a point in the front toast's box, 20dp in from the window side +// and 10dp in from the window edge of the rig's corner. func (r *toasterRig) onFront() f32.Point { - return f32.Pt(float32(r.size.X-int(toastOffsetRight)-20), float32(toastOffsetTop)+10) + at := f32.Pt(float32(r.size.X-int(toastOffsetSide)-20), float32(toastOffsetTop)+10) + if r.pos.left() { + at.X = float32(toastOffsetSide) + 20 + } + if r.pos.bottom() { + at.Y = float32(r.size.Y-int(toastOffsetBottom)) - 10 + } + return at +} + +// toward is a d dp step toward the rig's own side of the window. +func (r *toasterRig) toward(d float32) f32.Point { + if r.pos.left() { + return f32.Pt(-d, 0) + } + return f32.Pt(d, 0) +} + +var everyToastPosition = map[string]ToastPosition{ + "top-right": ToastTopRight, "top-left": ToastTopLeft, + "bottom-right": ToastBottomRight, "bottom-left": ToastBottomLeft, +} + +func TestToasterSitsInItsCorner(t *testing.T) { + for name, pos := range everyToastPosition { + t.Run(name, func(t *testing.T) { + r := newToasterRig(t) + r.pos = pos + r.state.Show(Toast{ID: "x", Title: "hello", Duration: ToastForever}) + r.settle() + r.pointer(pointer.Move, r.onFront()) + r.frame() + if !r.state.hovered { + t.Fatalf("a point in the %s corner is not on the toast", name) + } + r.pointer(pointer.Move, f32.Pt(float32(r.size.X)/2, float32(r.size.Y)/2)) + r.frame() + if r.state.hovered { + t.Fatal("the middle of the window is on the toast") + } + }) + } +} + +func TestToasterSwipesOutTowardItsOwnSide(t *testing.T) { + for name, pos := range everyToastPosition { + t.Run(name, func(t *testing.T) { + r := newToasterRig(t) + r.pos = pos + r.state.Show(Toast{ID: "x", Title: "hello", Duration: ToastForever}) + r.settle() + at := r.onFront().Sub(r.toward(100)) + r.pointer(pointer.Press, at) + r.frame() + r.now = r.now.Add(time.Second) + r.pointer(pointer.Drag, at.Add(r.toward(60))) + r.frame() + r.pointer(pointer.Release, at.Add(r.toward(60))) + r.frame() + if r.live("x") { + t.Fatalf("a slow 60dp swipe toward the %s side left it up", name) + } + }) + } +} + +func TestToastPullAwayFromItsEdgeIsDampened(t *testing.T) { + gtx := layout.Context{Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1}} + pulls := map[ToastPosition]float32{ToastTopRight: 40, ToastBottomRight: -40} + for pos, delta := range pulls { + var d toastDrag + d.follow(gtx, f32.Pt(0, delta), pos) + if d.amount.Y != delta/3.5 { + t.Errorf("pull of %v at %v = %v, want it dampened to %v", delta, pos, d.amount.Y, delta/3.5) + } + } } // settle lays out a frame once the enter animation is over, so the areas @@ -295,7 +374,7 @@ func TestToasterActionRunsItsHandlerAndDismisses(t *testing.T) { button := e.view.actionAt.Add(e.view.bodyAt) origin := e.view.place.Transform(f32.Point{}) r.state.mu.Unlock() - base := f32.Pt(float32(r.size.X-int(toastOffsetRight)-int(toastWidth)), float32(toastOffsetTop)).Add(origin) + base := f32.Pt(float32(r.size.X-int(toastOffsetSide)-int(toastWidth)), float32(toastOffsetTop)).Add(origin) center := base.Add(f32.Pt(float32(button.Min.X+button.Dx()/2), float32(button.Min.Y+button.Dy()/2))) r.pointer(pointer.Press, center) r.pointer(pointer.Release, center) @@ -323,7 +402,7 @@ func TestToasterSecondaryRunsItsOwnHandlerBetweenCancelAndAction(t *testing.T) { if !(cancel.Max.X <= secondary.Min.X && secondary.Max.X <= action.Min.X) { t.Fatalf("cancel %v, secondary %v, action %v: want them left to right", cancel, secondary, action) } - base := f32.Pt(float32(r.size.X-int(toastOffsetRight)-int(toastWidth)), float32(toastOffsetTop)).Add(origin) + base := f32.Pt(float32(r.size.X-int(toastOffsetSide)-int(toastWidth)), float32(toastOffsetTop)).Add(origin) center := base.Add(f32.Pt(float32(at.Min.X+at.Dx()/2), float32(at.Min.Y+at.Dy()/2))) r.pointer(pointer.Press, center) r.pointer(pointer.Release, center) diff --git a/native/ui/toast_view.go b/native/ui/toast_view.go index 724e75aa..c67e1b26 100644 --- a/native/ui/toast_view.go +++ b/native/ui/toast_view.go @@ -37,10 +37,52 @@ const ( ) var ( - toastOffsetTop = Space(12) // sonner.tsx offset top 3rem, below the topbar - toastOffsetRight = Space(4) // right 1rem + toastOffsetTop = Space(12) // sonner.tsx offset top 3rem, below the topbar + toastOffsetSide = Space(4) // right 1rem, and left by the same + // toastOffsetBottom mirrors the top: clear of the footer bar as the top + // one is of the topbar. + toastOffsetBottom = Space(12) ) +// ToastPosition is sonner's position prop: the corner a Toaster stacks in. +// The zero value is top-right, sonner's own default. +type ToastPosition int + +const ( + ToastTopRight ToastPosition = iota + ToastTopLeft + ToastBottomRight + ToastBottomLeft +) + +func (p ToastPosition) bottom() bool { return p == ToastBottomRight || p == ToastBottomLeft } +func (p ToastPosition) left() bool { return p == ToastTopLeft || p == ToastBottomLeft } + +// anchor is the top-left of the column the stack is laid out in, in a window +// of size max: a bottom stack's column starts at its bottom edge and grows up +// (placeOf). +func (p ToastPosition) anchor(gtx C, max image.Point) image.Point { + at := image.Pt(max.X-gtx.Dp(toastOffsetSide)-gtx.Dp(toastWidth), gtx.Dp(toastOffsetTop)) + if p.left() { + at.X = gtx.Dp(toastOffsetSide) + } + if p.bottom() { + at.Y = max.Y - gtx.Dp(toastOffsetBottom) + } + return at +} + +// placeOf is where a toast of height h sits y away from the stack's edge, +// scaled about its center: below the edge for a top stack, above it for a +// bottom one, so entering from y = -h comes in from outside the window on +// either side. +func (p ToastPosition) placeOf(center f32.Point, scale, y, h float32) f32.Affine2D { + if p.bottom() { + y = -y - h + } + return f32.Affine2D{}.Scale(center, f32.Pt(scale, scale)).Offset(f32.Pt(0, y)) +} + const ( toastTransition = 400 * time.Millisecond // transform, opacity, height .4s toastSwipeOut = 200 * time.Millisecond // swipe-out-* animation @@ -87,9 +129,11 @@ type toastDrag struct { amount f32.Point } -// ToasterStyle draws a Toaster in the top-right corner, above everything. +// ToasterStyle draws a Toaster in the corner Position names, above +// everything. type ToasterStyle struct { - State *Toaster + State *Toaster + Position ToastPosition // Light is the scheme sonner's theme prop follows: the description and // cancel colors are sonner's own, not the popover tokens. Light bool @@ -131,8 +175,7 @@ func (s ToasterStyle) frame(gtx C) []func() { } s.place(gtx) macro := op.Record(gtx.Ops) - at := image.Pt(gtx.Constraints.Max.X-gtx.Dp(toastOffsetRight)-gtx.Dp(toastWidth), gtx.Dp(toastOffsetTop)) - off := op.Offset(at).Push(gtx.Ops) + off := op.Offset(s.Position.anchor(gtx, gtx.Constraints.Max)).Push(gtx.Ops) entries := t.stack.entries for i := len(entries) - 1; i >= 0; i-- { s.paint(gtx, entries[i]) @@ -213,7 +256,7 @@ func (s ToasterStyle) readDrag(gtx C, e *toastEntry) { } case pointer.Drag: if d.active { - d.follow(gtx, p.Position.Sub(d.start)) + d.follow(gtx, p.Position.Sub(d.start), s.Position) } case pointer.Release: if d.active { @@ -240,9 +283,9 @@ func (v *toastView) onButton(p f32.Point) bool { } // follow is sonner's onPointerMove: the first move past a pixel locks the -// axis, and a pull the way top-right toasts do not leave (left, down) is -// dampened. -func (d *toastDrag) follow(gtx C, delta f32.Point) { +// axis, and a pull the way toasts at pos do not leave is dampened. They leave +// toward their own side and their own edge (sonner's default swipeDirections). +func (d *toastDrag) follow(gtx C, delta f32.Point, pos ToastPosition) { if d.axis == 0 && (abs32(delta.X) > 1 || abs32(delta.Y) > 1) { d.axis = 'y' if abs32(delta.X) > abs32(delta.Y) { @@ -253,9 +296,9 @@ func (d *toastDrag) follow(gtx C, delta f32.Point) { d.amount = f32.Point{} switch d.axis { case 'x': - d.amount.X = swipeAmount(delta.X/perDp, delta.X > 0) * perDp + d.amount.X = swipeAmount(delta.X/perDp, (delta.X > 0) != pos.left()) * perDp case 'y': - d.amount.Y = swipeAmount(delta.Y/perDp, delta.Y < 0) * perDp + d.amount.Y = swipeAmount(delta.Y/perDp, (delta.Y < 0) != pos.bottom()) * perDp } } From 42ed55069d65200983f6ae5d058e395d9fbea45e Mon Sep 17 00:00:00 2001 From: omartelo Date: Sat, 17 Oct 2026 13:49:56 -0300 Subject: [PATCH 3/4] feat: warn when two cards run one conversation A session-start report naming a conversation another open card already runs, which a resume typed in the agent's own app does, raises session-duplicate {id, heldBy}. The native window shows it in its own bottom-right toaster with Keep both and Switch; Switch closes the card that resumed without leaving a parked copy and brings up the one that had the conversation. Which providers report such a switch, and when, was measured on 2026-10-17 and is written down in docs/ceilings.md and the README provider tables. --- CHANGELOG.md | 4 ++ README.es.md | 1 + README.ja.md | 1 + README.ko.md | 1 + README.md | 1 + README.pt-BR.md | 1 + README.ru.md | 1 + README.zh-CN.md | 1 + docs/ceilings.md | 12 ++++ docs/hooks/session-start.md | 7 +++ internal/store/mutations.go | 19 ++++++ internal/store/reopen_test.go | 33 +++++++++++ internal/terminal/duplicate_test.go | 39 ++++++++++++ internal/terminal/hookstate_test.go | 2 + internal/terminal/terminal.go | 27 +++++++++ internal/terminal/terminal_test.go | 3 + native/cmd/lich-native/duplicate.go | 75 ++++++++++++++++++++++++ native/cmd/lich-native/duplicate_test.go | 40 +++++++++++++ native/cmd/lich-native/main.go | 8 +++ native/cmd/lich-native/model.go | 8 +++ native/i18n/locales/en/terminal.json | 5 ++ native/i18n/locales/es/terminal.json | 5 ++ native/i18n/locales/ja/terminal.json | 5 ++ native/i18n/locales/ko/terminal.json | 5 ++ native/i18n/locales/pt-br/terminal.json | 5 ++ native/i18n/locales/ru/terminal.json | 5 ++ native/i18n/locales/zh-cn/terminal.json | 5 ++ 27 files changed, 319 insertions(+) create mode 100644 internal/terminal/duplicate_test.go create mode 100644 native/cmd/lich-native/duplicate.go create mode 100644 native/cmd/lich-native/duplicate_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 495c242e..e37c24af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **The native window warns when two cards run one conversation.** Resuming, + from inside an agent's own TUI, a conversation another card already has + open raises a warning in the bottom-right corner; Switch closes the card + that resumed and brings up the one that had it. - **The native window shows images.** A PNG, JPEG, GIF, WebP or BMP opens in the Files tab's preview over a checkerboard, with its size and dimensions, and a changed image in the Review shows before and after side by side. A diff --git a/README.es.md b/README.es.md index 0197b2f2..0fd10618 100644 --- a/README.es.md +++ b/README.es.md @@ -129,6 +129,7 @@ porque lee lo que cada CLI deja escrito y no hay dos que anoten las mismas cosas | Reanudar una conversación iniciada fuera de lich, desde la pestaña Historial de la paleta | sí | sí | sí | sí | sí | Linux; macOS y Windows sin probar | sí | sí | | Un pegado largo puede llegar como texto editable en vez de un marcador plegado (Ajustes › Proveedores, desactivado por defecto) | sí | no | no | sí | no medido | no, se convierte en un adjunto `paste_N.txt` | no | sí | | Bifurcar una conversación en un nuevo worktree | sí | sí | no | sí | no | no | no | no | +| Aviso en la ventana nativa cuando retomas, desde la propia app del agente, una conversación que otra tarjeta tiene abierta | sí | en tu próximo prompt | en tu próximo prompt, sin probar | no | no | en su próxima llamada a una tool | no | sí | | Abrir una sesión con un esfuerzo de razonamiento elegido | sí | sí | sí | no | sí | no | en el nombre del modelo | sí | | Abrir una sesión con ultracode activado | sí | no | no | no | no | no | no | no | | Varias cuentas lado a lado como perfiles, cada una con su propio inicio de sesión completo, y una sesión movida a otra cuenta que sigue en la misma tarjeta (en la ventana nativa, experimental: Ajustes › Proveedores) | sí | sí, con sus propios plugins en cada perfil y los ajustes de Personal compartidos por todos | no | sí, con sesión iniciada desde una tarjeta de terminal; una sesión no puede pasar a otro perfil, porque sus conversaciones están en la base de datos que guarda el inicio de sesión | sí, con sesión iniciada desde una tarjeta de terminal, con sus ajustes compartidos por todos los perfiles | no medido | Linux y Windows (en macOS su inicio de sesión está en el Llavero), con sesión iniciada desde una tarjeta de terminal, con `mcp.json`, hooks y skills compartidos por todos los perfiles | solo Linux (macOS guarda su inicio de sesión en el Llavero y en Windows nada lo mueve), con sesión iniciada desde una tarjeta de terminal, con sus conversaciones compartidas por todos los perfiles | diff --git a/README.ja.md b/README.ja.md index 54d22338..9aaa8296 100644 --- a/README.ja.md +++ b/README.ja.md @@ -120,6 +120,7 @@ lich は各 CLI が書き残したものを読むので、書き残す内容は | lich の外で始めた会話を、パレットの History タブから再開 | あり | あり | あり | あり | あり | Linux のみ。macOS と Windows は未検証 | あり | あり | | 長いペーストを、折りたたまれたプレースホルダーではなく編集可能なテキストとして貼り付けられる(Settings › Providers、既定ではオフ) | あり | なし | なし | あり | 未計測 | なし(`paste_N.txt` の添付になる) | なし | あり | | 会話を新しいワークツリーにフォーク | あり | あり | なし | あり | なし | なし | なし | なし | +| 別のカードで開いている会話をエージェント自身のアプリで再開すると、ネイティブウィンドウに警告 | あり | 次のプロンプトで | 次のプロンプトで(未検証) | なし | なし | 次のツール呼び出しで | なし | あり | | 推論エフォートを指定してセッションを開く | あり | あり | あり | なし | あり | なし | モデル名で指定 | あり | | ultracode をオンにしてセッションを開く | あり | なし | なし | なし | なし | なし | なし | なし | | 複数のアカウントをプロファイルとして並べて使え、それぞれが独自の完全なログインを持ち、別のアカウントへ移したセッションが同じカードで続行される(ネイティブウィンドウ、実験的機能:Settings › Providers) | あり | あり。各プロファイルが独自のプラグインを持ち、Personal の設定はすべてで共有される | なし | あり。ターミナルカードからログインする。会話がログインを保持するデータベースにあるため、セッションを別のプロファイルへ移すことはできない | あり。ターミナルカードからログインし、設定はすべてのプロファイルで共有される | 未計測 | Linux と Windows(macOS ではログインがキーチェーンにある)。ターミナルカードからログインし、`mcp.json`、フック、スキルはすべてのプロファイルで共有される | Linux のみ(macOS ではログインがキーチェーンにあり、Windows ではそれを移す手段がない)。ターミナルカードからログインし、会話はすべてのプロファイルで共有される | diff --git a/README.ko.md b/README.ko.md index 3d402980..73816e68 100644 --- a/README.ko.md +++ b/README.ko.md @@ -117,6 +117,7 @@ lich로 할 수 있는 일: | lich 밖에서 시작한 대화를 팔레트의 History 탭에서 재개 | 예 | 예 | 예 | 예 | 예 | Linux; macOS와 Windows는 미검증 | 예 | 예 | | 긴 붙여 넣기를 접힌 자리 표시자 대신 편집 가능한 텍스트로 넣기(Settings › Providers, 기본값 꺼짐) | 예 | 아니요 | 아니요 | 예 | 측정 안 됨 | 아니요, `paste_N.txt` 첨부 파일이 됨 | 아니요 | 예 | | 대화를 새 worktree로 포크 | 예 | 예 | 아니요 | 예 | 아니요 | 아니요 | 아니요 | 아니요 | +| 다른 카드에 열려 있는 대화를 에이전트 자체 앱에서 재개하면 네이티브 창에 경고 | 예 | 다음 프롬프트에서 | 다음 프롬프트에서, 미검증 | 아니요 | 아니요 | 다음 도구 호출에서 | 아니요 | 예 | | 원하는 reasoning effort로 세션 열기 | 예 | 예 | 예 | 아니요 | 예 | 아니요 | 모델 이름으로 | 예 | | ultracode를 켠 채로 세션 열기 | 예 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 | | 여러 계정을 프로필로 나란히 두고 각자 완전한 로그인을 가지며, 다른 계정으로 옮긴 세션이 같은 카드에서 계속됨(네이티브 창, 실험적 기능: Settings › Providers) | 예 | 예, 프로필마다 자체 플러그인을 두고 Personal의 설정은 모든 프로필이 공유 | 아니요 | 예, 터미널 카드에서 로그인; 대화가 로그인 정보와 같은 데이터베이스에 있어서 세션을 다른 프로필로 옮길 수 없음 | 예, 터미널 카드에서 로그인하며 설정은 모든 프로필이 공유 | 측정 안 됨 | Linux와 Windows(macOS에서는 로그인이 키체인에 있음), 터미널 카드에서 로그인하며 `mcp.json`, hooks, skills는 모든 프로필이 공유 | Linux만(macOS는 로그인을 키체인에 두고, Windows에서는 그 위치를 옮길 방법이 없음), 터미널 카드에서 로그인하며 대화는 모든 프로필이 공유 | diff --git a/README.md b/README.md index 29fc5b47..0460c943 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ reads what each CLI writes down and no two of them write down the same things. | Resume a conversation started outside lich, from the palette's History tab | yes | yes | yes | yes | yes | Linux; macOS and Windows untested | yes | yes | | A long paste can land as editable text instead of a folded placeholder (Settings › Providers, off by default) | yes | no | no | yes | not measured | no, it becomes a `paste_N.txt` attachment | no | yes | | Fork a conversation into a new worktree | yes | yes | no | yes | no | no | no | no | +| Warned in the native window when you resume, from the agent's own app, a conversation another card has open | yes | on your next prompt | on your next prompt, untested | no | no | at its next tool call | no | yes | | Open a session at a chosen reasoning effort | yes | yes | yes | no | yes | no | in the model name | yes | | Open a session with ultracode on | yes | no | no | no | no | no | no | no | | Several accounts side by side as profiles, each with its own full login, and a session moved to another account carrying on in the same card (in the native window, experimental: Settings › Providers) | yes | yes, with its own plugins in each profile and Personal's settings shared by all of them | no | yes, logged in from a terminal card; a session cannot move to another profile, since its conversations sit in the database that holds the login | yes, logged in from a terminal card, with its settings shared by every profile | not measured | Linux and Windows (on macOS its login is in the Keychain), logged in from a terminal card, with `mcp.json`, hooks and skills shared by every profile | Linux only (macOS keeps its login in the Keychain, and on Windows nothing moves it), logged in from a terminal card, with its conversations shared by every profile | diff --git a/README.pt-BR.md b/README.pt-BR.md index 5caf18ce..5bf01224 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -132,6 +132,7 @@ mesmas coisas. | Retomar pela aba Histórico da paleta uma conversa iniciada fora do lich | sim | sim | sim | sim | sim | Linux; macOS e Windows não testados | sim | sim | | Um paste longo pode chegar como texto editável em vez de um marcador dobrado (Configurações › Provedores, desligado por padrão) | sim | não | não | sim | não medido | não, vira um anexo `paste_N.txt` | não | sim | | Fazer fork de uma conversa para uma nova worktree | sim | sim | não | sim | não | não | não | não | +| Aviso na janela nativa quando você retoma, pelo próprio app do agente, uma conversa que outro card tem aberta | sim | no seu próximo prompt | no seu próximo prompt, não testado | não | não | na próxima chamada de tool | não | sim | | Abrir uma sessão com um esforço de raciocínio escolhido | sim | sim | sim | não | sim | não | no nome do modelo | sim | | Abrir uma sessão com o ultracode ligado | sim | não | não | não | não | não | não | não | | Várias contas lado a lado como perfis, cada uma com seu login completo, e uma sessão movida para outra conta continuando no mesmo card (na janela nativa, experimental: Configurações › Provedores) | sim | sim, com plugins próprios em cada perfil e as configurações do Pessoal compartilhadas por todos | não | sim, com login feito em um card de terminal; uma sessão não pode mudar de perfil, já que as conversas dela ficam no banco de dados que guarda o login | sim, com login feito em um card de terminal e as configurações compartilhadas por todos os perfis | não medido | no Linux e no Windows (no macOS o login fica no Keychain), com login feito em um card de terminal, e `mcp.json`, hooks e skills compartilhados por todos os perfis | só no Linux (o macOS guarda o login no Keychain, e no Windows nada muda o lugar dele), com login feito em um card de terminal e as conversas compartilhadas por todos os perfis | diff --git a/README.ru.md b/README.ru.md index b0451df5..256d5603 100644 --- a/README.ru.md +++ b/README.ru.md @@ -133,6 +133,7 @@ lich вообще не запускается. | Возобновление разговора, начатого вне lich, со вкладки «История» палитры | да | да | да | да | да | Linux; macOS и Windows не проверялись | да | да | | Длинная вставка может попасть как редактируемый текст вместо свёрнутой заглушки (Настройки › Провайдеры, по умолчанию выключено) | да | нет | нет | да | не измерялось | нет, она становится вложением `paste_N.txt` | нет | да | | Форк разговора в новый worktree | да | да | нет | да | нет | нет | нет | нет | +| Предупреждение в нативном окне, когда вы возобновляете в самом приложении агента разговор, открытый в другой карточке | да | при следующем промпте | при следующем промпте, не проверено | нет | нет | при следующем вызове инструмента | нет | да | | Открытие сессии с выбранным уровнем рассуждений | да | да | да | нет | да | нет | в названии модели | да | | Открытие сессии с включённым ultracode | да | нет | нет | нет | нет | нет | нет | нет | | Несколько аккаунтов рядом в виде профилей, у каждого свой полный вход, а сессия, перенесённая на другой аккаунт, продолжается в той же карточке (в нативном окне, экспериментально: Настройки › Провайдеры) | да | да, со своими плагинами в каждом профиле и общими для всех настройками Personal | нет | да, вход из терминальной карточки; сессию нельзя перенести в другой профиль, потому что её разговоры лежат в базе данных, где хранится и вход | да, вход из терминальной карточки, настройки общие для всех профилей | не измерялось | Linux и Windows (в macOS вход хранится в связке ключей), вход из терминальной карточки, `mcp.json`, хуки и навыки общие для всех профилей | только Linux (macOS хранит вход в связке ключей, а в Windows его расположение ничем не переносится), вход из терминальной карточки, разговоры общие для всех профилей | diff --git a/README.zh-CN.md b/README.zh-CN.md index de201aa4..b19915a5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -109,6 +109,7 @@ | 在命令面板的 History 页恢复在 lich 之外开始的对话 | 有 | 有 | 有 | 有 | 有 | Linux;macOS 和 Windows 未测 | 有 | 有 | | 长文本粘贴可以以可编辑的完整文本出现,而不是折叠成占位符(设置 › Providers,默认关闭) | 有 | 无 | 无 | 有 | 未测 | 无,会变成 `paste_N.txt` 附件 | 无 | 有 | | 把一段对话分叉到新的 worktree | 有 | 有 | 无 | 有 | 无 | 无 | 无 | 无 | +| 在智能体自己的应用里恢复另一张卡片已打开的对话时,原生窗口发出警告 | 有 | 在你的下一个提示时 | 在你的下一个提示时,未测试 | 无 | 无 | 在它的下一次工具调用时 | 无 | 有 | | 按指定的推理强度打开会话 | 有 | 有 | 有 | 无 | 有 | 无 | 写在模型名里 | 有 | | 以 ultracode 模式打开会话 | 有 | 无 | 无 | 无 | 无 | 无 | 无 | 无 | | 多个账号以 profile 并存,各自完整登录;会话可以移到另一个账号,在同一张卡片里接着做(在原生窗口中,实验性:设置 › Providers) | 有 | 有;每个 profile 有自己的插件,Personal 的设置所有 profile 共用 | 无 | 有;在终端卡片里登录。会话不能移到另一个 profile:对话和登录信息存在同一个数据库里 | 有;在终端卡片里登录,各 profile 共用设置 | 未测量 | Linux 和 Windows(macOS 上登录信息在钥匙串里);在终端卡片里登录,`mcp.json`、hooks 和 skills 各 profile 共用 | 仅 Linux(macOS 把登录信息放在钥匙串里,Windows 上没有办法移动它);在终端卡片里登录,对话由所有 profile 共用 | diff --git a/docs/ceilings.md b/docs/ceilings.md index be757593..58a52983 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -1194,6 +1194,18 @@ work when nobody knows it and that the call site never shows. The mechanism and passed on every spawn, and a fork inherits it from the session whose conversation it branches. The cost is that an ultracode turned off inside the session comes back on the next restart, and nothing turns it off for one session while the provider-wide switch is on. +- **The two-cards-one-conversation warning reaches only the providers that report an in-app switch** + (`internal/terminal/terminal.go` `reportDuplicate`, `native/cmd/lich-native/duplicate.go`): lich learns + which conversation a card runs from its session-start report, so a resume typed inside the agent's own app + is warned about only once that report names the new id. Measured on 2026-10-17: Claude Code (`/resume`) and + Kiro CLI 2.21.0 (`/chat`, `agentSpawn` fires per switch) report at the switch; Codex 0.162.1 reports it with + its next prompt and Crush v0.98.1 with its next tool call, so a turn may already have run in both cards; + Antigravity 1.4.0 carries the id on every `PreInvocation`, read in the binary and not run. oh-my-pi 18.8.9 + emits `session_switch`, which the plugin does not hear; Cursor CLI skips `sessionStart` on any resume + (2026.10.16); opencode 1.18.35 raises nothing on `/sessions`. Cursor CLI and opencode report nothing at + all, so their row keeps the old id and a restart resumes the conversation the card opened with. Reopening + from lich itself is not this gap: every door through `store.reopen` focuses the card that holds the + conversation. - **A model or effort is a birth value, and two providers forget it on resume** (`internal/terminal/command.go`, `modelArgs`, `effortArgs`): lich passes neither when it resumes or forks a conversation, because a `/model` or `/effort` typed inside the session is the user's decision and re-sending the birth value would undo it. What a diff --git a/docs/hooks/session-start.md b/docs/hooks/session-start.md index 1f9e7406..a350fe17 100644 --- a/docs/hooks/session-start.md +++ b/docs/hooks/session-start.md @@ -60,6 +60,13 @@ is a start that lands while the card's turn is open: that is an agent CLI the agent ran as a tool, not the card's own conversation, and it is dropped (session-state.md, Nested agent CLIs). +A report naming a conversation another open card already runs (a resume typed +in the provider's own TUI) is stored like any other, and lich also pushes +`session-duplicate` `{id, heldBy}` to the window, which offers to drop this +card and switch to `heldBy`. Nothing changes on the client side: the warning +reaches only the providers whose hook reports the switch, listed in +`docs/ceilings.md`. + The others report the same id from the earliest place each one offers it. **Antigravity has no session event at all**: its lifecycle is `PreInvocation`, `PreToolUse`, `PostToolUse`, `PostInvocation` and `Stop`, so the report rides the diff --git a/internal/store/mutations.go b/internal/store/mutations.go index e605b3d4..4d9a24f4 100644 --- a/internal/store/mutations.go +++ b/internal/store/mutations.go @@ -955,6 +955,25 @@ func (s *Service) SetProviderSession(sessionID, providerSessionID string) error return nil } +// ConversationHolder is the open session other than sessionID that runs +// providerSessionID, or "" when no other card has it open. Parked rows are not +// asked: they run nothing until reopened, and reopen hands back the open card +// instead (reopen). +func (s *Service) ConversationHolder(providerSessionID, sessionID string) (string, error) { + var holder string + err := s.db.QueryRow( + `SELECT id FROM sessions WHERE provider_session_id = ? AND is_open = 1 AND id <> ? LIMIT 1`, + providerSessionID, sessionID, + ).Scan(&holder) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("find the session holding %q: %w", providerSessionID, err) + } + return holder, nil +} + // ProviderSession returns the provider conversation id recorded for a lich // session, or "" when none has been reported yet (or the session is gone — a // missing row is not an error). Pairs with SetProviderSession: the context-usage diff --git a/internal/store/reopen_test.go b/internal/store/reopen_test.go index 6ab0d892..e4321cd0 100644 --- a/internal/store/reopen_test.go +++ b/internal/store/reopen_test.go @@ -83,3 +83,36 @@ func TestReopenHandsBackTheCardThatHoldsTheConversation(t *testing.T) { }) } } + +// TestConversationHolderNamesAnotherOpenCard: a session reporting a +// conversation is told which other card already runs it, never itself, and +// never a parked row, which runs nothing. +func TestConversationHolderNamesAnotherOpenCard(t *testing.T) { + svc := newTestStore(t) + _ = svc.AddProject("p1", "alpha", "/tmp/alpha") + _ = svc.AddSession("p1", "holder", "auth-refactor", "claude", "", 2, "") + _ = svc.AddSession("p1", "resumer", "Claude 4", "claude", "", 3, "") + _ = svc.AddSession("p1", "parked", "old", "claude", "", 4, "") + _ = svc.SetProviderSession("holder", "conv-shared") + _ = svc.SetProviderSession("resumer", "conv-shared") + _ = svc.SetProviderSession("parked", "conv-parked") + _ = svc.CloseSession("p1", "parked", "holder") + _ = svc.AddSession("p1", "fresh", "Claude 5", "claude", "", 5, "") + _ = svc.SetProviderSession("fresh", "conv-parked") + + cases := []struct{ conversation, asking, want string }{ + {"conv-shared", "resumer", "holder"}, + {"conv-shared", "holder", "resumer"}, + {"conv-parked", "fresh", ""}, + {"conv-nobody", "fresh", ""}, + } + for _, c := range cases { + got, err := svc.ConversationHolder(c.conversation, c.asking) + if err != nil { + t.Fatalf("ConversationHolder(%q, %q): %v", c.conversation, c.asking, err) + } + if got != c.want { + t.Errorf("ConversationHolder(%q, %q) = %q, want %q", c.conversation, c.asking, got, c.want) + } + } +} diff --git a/internal/terminal/duplicate_test.go b/internal/terminal/duplicate_test.go new file mode 100644 index 00000000..46effe58 --- /dev/null +++ b/internal/terminal/duplicate_test.go @@ -0,0 +1,39 @@ +package terminal + +import ( + "testing" + + "github.com/omartelo/lich/internal/providers" +) + +// A card that reports a conversation another open card runs (a `/resume` +// typed in its TUI) is the one the window warns about, naming the holder. +func TestASessionStartOnAHeldConversationTellsTheWindow(t *testing.T) { + hub, rec := newProbeHub(t) + svc := New(stubBins{holder: "holder"}, nil, hub) + + if err := svc.onSessionStart("resumer", "conv-shared", providers.Claude); err != nil { + t.Fatalf("onSessionStart = %v, want nil", err) + } + waitFor(t, func() bool { _, ok := rec.payloadOf(duplicateEventName); return ok }, "the duplicate event") + payload, _ := rec.payloadOf(duplicateEventName) + got, _ := payload.(map[string]any) + if got["id"] != "resumer" || got["heldBy"] != "holder" { + t.Errorf("duplicate event = %v, want resumer held by holder", payload) + } +} + +func TestASessionStartOnItsOwnConversationRaisesNoWarning(t *testing.T) { + hub, rec := newProbeHub(t) + svc := New(stubBins{}, nil, hub) + + if err := svc.onSessionStart("s1", "conv-own", providers.Claude); err != nil { + t.Fatalf("onSessionStart = %v, want nil", err) + } + // The agent event is pushed after the check, on the same socket, so once + // it is in a warning would have been too. + waitFor(t, func() bool { _, ok := rec.payloadOf(agentEventName); return ok }, "the agent event") + if payload, ok := rec.payloadOf(duplicateEventName); ok { + t.Errorf("duplicate event = %v, want none for a conversation nobody else runs", payload) + } +} diff --git a/internal/terminal/hookstate_test.go b/internal/terminal/hookstate_test.go index 10c24320..64098a6e 100644 --- a/internal/terminal/hookstate_test.go +++ b/internal/terminal/hookstate_test.go @@ -268,6 +268,8 @@ type hookStore struct{ Store } func (hookStore) ProviderSession(string) (string, error) { return "", nil } func (hookStore) SetSessionUnread(string, bool) error { return nil } +func (hookStore) ConversationHolder(string, string) (string, error) { return "", nil } + // postHook reports one state the way the companion plugin does, and fails the // test unless lich accepted it (docs/hooks/session-state.md). func postHook(t *testing.T, svc *Service, id, state string) { diff --git a/internal/terminal/terminal.go b/internal/terminal/terminal.go index 4ed215a4..3b94a70f 100644 --- a/internal/terminal/terminal.go +++ b/internal/terminal/terminal.go @@ -81,8 +81,19 @@ const ( // snapshot runs on a worker, so a panel refreshed off the state report reads // the record before it exists (see turnSnaps). turnEventName = "session-turn" + // duplicateEventName carries a session that just reported a conversation + // another open card already runs ({id, heldBy}): a resume typed in the + // provider's own TUI, which lich cannot stop, only point out. Emitted once + // the report is stored, so the window can offer to switch to heldBy. + duplicateEventName = "session-duplicate" ) +// duplicateEvent is the payload of duplicateEventName. +type duplicateEvent struct { + ID string `json:"id"` + HeldBy string `json:"heldBy"` +} + // statusEvent is the payload of statusEventName: the session whose processing // state changed, the new state, and — on a pre-tool report alone — the tool it // is about to run and what that tool acts on, or — on a waiting one alone — what @@ -229,6 +240,7 @@ type Store interface { WorktreePorts() map[string]int SetWorktreePort(path string, port int) error SetProviderSession(sessionID, providerSessionID string) error + ConversationHolder(providerSessionID, sessionID string) (string, error) ProviderSession(sessionID string) (string, error) SessionModel(sessionID string) string SessionEffort(sessionID string) string @@ -596,6 +608,7 @@ func (s *Service) onSessionStart(sessionID, providerSessionID, provider string) if err := s.store.SetProviderSession(sessionID, providerSessionID); err != nil { return err } + s.reportDuplicate(sessionID, providerSessionID) // A SessionStart report is proof that *a* provider's CLI is running // in this PTY, and for a shell session that report is the only thing // that knows which — so its icon wears the reported mark. @@ -612,6 +625,20 @@ func (s *Service) onSessionStart(sessionID, providerSessionID, provider string) return nil } +// reportDuplicate tells the window when the conversation sessionID just +// reported is already open in another card. A failed lookup costs the warning, +// never the report it rides on. +func (s *Service) reportDuplicate(sessionID, providerSessionID string) { + holder, err := s.store.ConversationHolder(providerSessionID, sessionID) + if err != nil { + slog.Warn("terminal: find the conversation's other card", "session", sessionID, "err", err) + return + } + if holder != "" { + s.hub.Emit(duplicateEventName, duplicateEvent{ID: sessionID, HeldBy: holder}) + } +} + // onTitle is the transport's ai-title callback. func (s *Service) onTitle(id, title string) error { s.beatHandsOn(id, 0) diff --git a/internal/terminal/terminal_test.go b/internal/terminal/terminal_test.go index f9b14143..ff307435 100644 --- a/internal/terminal/terminal_test.go +++ b/internal/terminal/terminal_test.go @@ -41,6 +41,7 @@ type stubBins struct { projectPath string providerSession string providerErr error + holder string // the other open card ConversationHolder names model string effort string entrypoint string @@ -190,6 +191,8 @@ func (s stubBins) SandboxGHToken(_ string) bool { return s.ghToken } func (s stubBins) GHAccountForPath(_ string) string { return s.ghAccount } func (s stubBins) SetProviderSession(_, _ string) error { return nil } +func (s stubBins) ConversationHolder(_, _ string) (string, error) { return s.holder, nil } + func (s stubBins) WorktreePorts() map[string]int { if s.ports == nil { return map[string]int{} diff --git a/native/cmd/lich-native/duplicate.go b/native/cmd/lich-native/duplicate.go new file mode 100644 index 00000000..0e49a2bc --- /dev/null +++ b/native/cmd/lich-native/duplicate.go @@ -0,0 +1,75 @@ +package main + +import ( + "github.com/omartelo/lich/native/i18n" + "github.com/omartelo/lich/native/ui" +) + +// duplicateEventName is internal/terminal's duplicateEventName. +const duplicateEventName = "session-duplicate" + +// sessionDuplicate is internal/terminal's duplicateEvent: session ID just +// reported a conversation the open session HeldBy already runs, which a +// resume typed in the provider's own TUI does. +type sessionDuplicate struct { + ID string `json:"id"` + HeldBy string `json:"heldBy"` +} + +// duplicateWarning is what the warning names and where switching lands. +type duplicateWarning struct { + id, label, holder, holderLabel, holderProject string +} + +// duplicateOf reads the warning off the open sessions, false once either +// card is gone: a warning about a card no longer on screen has nothing left +// to switch from. +func (m *model) duplicateOf(d sessionDuplicate) (duplicateWarning, bool) { + m.mu.Lock() + defer m.mu.Unlock() + p, i := m.sessionAt(d.ID) + hp, hi := m.sessionAt(d.HeldBy) + if p == nil || hp == nil { + return duplicateWarning{}, false + } + return duplicateWarning{ + id: d.ID, label: p.Sessions[i].Label, + holder: d.HeldBy, holderLabel: hp.Sessions[hi].Label, holderProject: hp.ID, + }, true +} + +// warnDuplicate raises the warning in the bottom-right toaster, where it +// stays until answered: the conversation goes on in two cards until then. +func (u *window) warnDuplicate(d sessionDuplicate) { + w, ok := u.model.duplicateOf(d) + if !ok { + return + } + u.duplicates.Show(ui.Toast{ + ID: "duplicate:" + w.id, + Variant: ui.ToastWarning, + Title: i18n.T("terminal.duplicate.title", i18n.Args{"label": w.label, "holder": w.holderLabel}), + Duration: ui.ToastForever, + Cancel: &ui.ToastButton{Label: i18n.T("terminal.duplicate.keepBoth")}, + Action: &ui.ToastButton{Label: i18n.T("terminal.duplicate.switch"), OnClick: func() { u.switchToHolder(w) }}, + }) +} + +// switchToHolder drops the card that resumed and puts the holder on screen. +// The row is parked and then forgotten rather than left parked: a parked copy +// of a conversation another card runs is all the history hides and a reopen +// refuses, so keeping it would keep nothing. +func (u *window) switchToHolder(w duplicateWarning) { + projectID, active, ok := u.model.closeSession(w.id) + if !ok { + return + } + u.terms.drop(w.id) + u.gate.forget(w.id) + u.openSession(w.holderProject, w.holder) + go func() { + u.logErr("close session", u.client.CloseSession(u.ctx, projectID, w.id, active)) + u.logErr("forget session", u.client.ForgetSession(u.ctx, w.id)) + u.logErr("close terminal", u.client.CloseTerminal(u.ctx, w.id)) + }() +} diff --git a/native/cmd/lich-native/duplicate_test.go b/native/cmd/lich-native/duplicate_test.go new file mode 100644 index 00000000..b5c87b0d --- /dev/null +++ b/native/cmd/lich-native/duplicate_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "testing" + + "github.com/omartelo/lich/native/lichclient" +) + +func duplicateModel() *model { + m := newModel(nil, func() {}) + m.projects = []lichclient.Project{ + {ID: "p", Sessions: []lichclient.Session{{ID: "resumer", Label: "Claude 4"}}}, + {ID: "q", Sessions: []lichclient.Session{{ID: "holder", Label: "auth-refactor"}}}, + } + return m +} + +func TestDuplicateEventRaisesTheWarningNamingBothCards(t *testing.T) { + m := duplicateModel() + var heard []sessionDuplicate + m.duplicate = func(d sessionDuplicate) { heard = append(heard, d) } + send(m, duplicateEventName, sessionDuplicate{ID: "resumer", HeldBy: "holder"}) + if len(heard) != 1 { + t.Fatalf("heard %v, want the one duplicate report", heard) + } + w, ok := m.duplicateOf(heard[0]) + want := duplicateWarning{id: "resumer", label: "Claude 4", holder: "holder", holderLabel: "auth-refactor", holderProject: "q"} + if !ok || w != want { + t.Fatalf("warning = %+v, %v; want %+v", w, ok, want) + } +} + +func TestDuplicateWarningIsDroppedOnceACardIsGone(t *testing.T) { + m := duplicateModel() + for _, d := range []sessionDuplicate{{ID: "closed", HeldBy: "holder"}, {ID: "resumer", HeldBy: "closed"}} { + if w, ok := m.duplicateOf(d); ok { + t.Errorf("duplicateOf(%+v) = %+v, want no warning about a card not on screen", d, w) + } + } +} diff --git a/native/cmd/lich-native/main.go b/native/cmd/lich-native/main.go index e87af04f..3ef1c38d 100644 --- a/native/cmd/lich-native/main.go +++ b/native/cmd/lich-native/main.go @@ -100,6 +100,9 @@ type window struct { theme *ui.Theme root ui.Root toaster ui.Toaster + // duplicates holds the warnings about a conversation resumed in two + // cards, in the bottom-right corner, apart from every other toast. + duplicates ui.Toaster // shortcuts is the keyboard shortcuts overlay. shortcuts shortcutsOverlay // palette is the command palette. @@ -183,6 +186,7 @@ func run(w *app.Window, rt lichclient.Runtime) error { } u.appearance = &appearance{ctx: ctx, client: client, invalidate: w.Invalidate} u.toaster.Invalidate = w.Invalidate + u.duplicates.Invalidate = w.Invalidate u.terms.toaster = &u.toaster u.terms.profileGone = u.profileGone u.sidebar = newSidebar(&u.root) @@ -196,6 +200,7 @@ func run(w *app.Window, rt lichclient.Runtime) error { u.model.prChanged = u.prChanged u.model.themeSelection = u.appearance.applySelection u.model.exited = u.terms.exited + u.model.duplicate = u.warnDuplicate u.model.pluginIncompatible = func() { go u.checkPlugin() } u.model.appUpdateProgress = u.updateProgressed u.model.dropped = u.backendDropped @@ -473,6 +478,9 @@ func (u *window) layout(gtx C) error { toasts := u.theme.Toaster(&u.toaster, u.light) toasts.Expand = *flagToastsExpanded toasts.Layout(gtx) + duplicates := u.theme.Toaster(&u.duplicates, u.light) + duplicates.Position = ui.ToastBottomRight + duplicates.Layout(gtx) return D{Size: gtx.Constraints.Max} }) u.apply(act) diff --git a/native/cmd/lich-native/model.go b/native/cmd/lich-native/model.go index b641888b..da204344 100644 --- a/native/cmd/lich-native/model.go +++ b/native/cmd/lich-native/model.go @@ -95,6 +95,9 @@ type model struct { themeSelection func(lichclient.ThemeSelection) // exited hears a session's process end. exited func(id string, e sessionExit) + // duplicate hears a session report a conversation another open card + // runs (duplicateEventName). + duplicate func(sessionDuplicate) // pluginIncompatible hears a session report from a lich plugin outside // the range this lich speaks (PluginIncompatibleEvent). pluginIncompatible func() @@ -318,6 +321,11 @@ func (m *model) follow(ctx context.Context, events <-chan lichclient.Event) { if json.Unmarshal(ev.Data, &p) == nil && m.appUpdateProgress != nil { m.appUpdateProgress(p) } + case duplicateEventName: + var d sessionDuplicate + if json.Unmarshal(ev.Data, &d) == nil && m.duplicate != nil { + m.duplicate(d) + } case lichclient.PluginIncompatibleEvent: if m.pluginIncompatible != nil { m.pluginIncompatible() diff --git a/native/i18n/locales/en/terminal.json b/native/i18n/locales/en/terminal.json index 9580779b..76eb896c 100644 --- a/native/i18n/locales/en/terminal.json +++ b/native/i18n/locales/en/terminal.json @@ -35,5 +35,10 @@ "terminalDrop": { "skipped": "{name} ({reason})", "tooLarge": "{name} (over {size}MB)" + }, + "duplicate": { + "title": "{label} resumed a conversation already open in {holder}.", + "keepBoth": "Keep both", + "switch": "Switch" } } diff --git a/native/i18n/locales/es/terminal.json b/native/i18n/locales/es/terminal.json index d332945f..8ae85514 100644 --- a/native/i18n/locales/es/terminal.json +++ b/native/i18n/locales/es/terminal.json @@ -35,5 +35,10 @@ "terminalDrop": { "skipped": "{name} ({reason})", "tooLarge": "{name} (más de {size} MB)" + }, + "duplicate": { + "title": "{label} retomó una conversación que ya está abierta en {holder}.", + "keepBoth": "Mantener ambas", + "switch": "Cambiar" } } diff --git a/native/i18n/locales/ja/terminal.json b/native/i18n/locales/ja/terminal.json index 6ba19ac2..956fa8d2 100644 --- a/native/i18n/locales/ja/terminal.json +++ b/native/i18n/locales/ja/terminal.json @@ -35,5 +35,10 @@ "terminalDrop": { "skipped": "{name}({reason})", "tooLarge": "{name}({size}MB 超)" + }, + "duplicate": { + "title": "{label} は {holder} で開いている会話を再開しました。", + "keepBoth": "両方残す", + "switch": "切り替える" } } diff --git a/native/i18n/locales/ko/terminal.json b/native/i18n/locales/ko/terminal.json index 13077f11..5df49bd7 100644 --- a/native/i18n/locales/ko/terminal.json +++ b/native/i18n/locales/ko/terminal.json @@ -34,5 +34,10 @@ "terminalDrop": { "skipped": "{name}({reason})", "tooLarge": "{name}({size}MB 초과)" + }, + "duplicate": { + "title": "{label}에서 {holder}에 이미 열려 있는 대화를 재개했습니다.", + "keepBoth": "둘 다 유지", + "switch": "전환" } } diff --git a/native/i18n/locales/pt-br/terminal.json b/native/i18n/locales/pt-br/terminal.json index 8d9cfa0d..107a6149 100644 --- a/native/i18n/locales/pt-br/terminal.json +++ b/native/i18n/locales/pt-br/terminal.json @@ -35,5 +35,10 @@ "terminalDrop": { "skipped": "{name} ({reason})", "tooLarge": "{name} (acima de {size} MB)" + }, + "duplicate": { + "title": "{label} retomou uma conversa que já está aberta em {holder}.", + "keepBoth": "Manter as duas", + "switch": "Trocar" } } diff --git a/native/i18n/locales/ru/terminal.json b/native/i18n/locales/ru/terminal.json index e375c9cf..4807a17f 100644 --- a/native/i18n/locales/ru/terminal.json +++ b/native/i18n/locales/ru/terminal.json @@ -37,5 +37,10 @@ "terminalDrop": { "skipped": "{name} ({reason})", "tooLarge": "{name} (больше {size} МБ)" + }, + "duplicate": { + "title": "{label} продолжил разговор, который уже открыт в {holder}.", + "keepBoth": "Оставить оба", + "switch": "Переключиться" } } diff --git a/native/i18n/locales/zh-cn/terminal.json b/native/i18n/locales/zh-cn/terminal.json index 266a2d7e..1e8ff34c 100644 --- a/native/i18n/locales/zh-cn/terminal.json +++ b/native/i18n/locales/zh-cn/terminal.json @@ -35,5 +35,10 @@ "terminalDrop": { "skipped": "{name}({reason})", "tooLarge": "{name}(超过 {size} MB)" + }, + "duplicate": { + "title": "{label} 恢复了一个已在 {holder} 中打开的对话。", + "keepBoth": "保留两者", + "switch": "切换" } } From 9004a2be57e3da0e6e5a842cb50895914b053fa6 Mon Sep 17 00:00:00 2001 From: omartelo Date: Sat, 17 Oct 2026 14:15:20 -0300 Subject: [PATCH 4/4] test(terminal): build the duplicate tests only where stubBins builds --- internal/terminal/duplicate_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/terminal/duplicate_test.go b/internal/terminal/duplicate_test.go index 46effe58..089a721d 100644 --- a/internal/terminal/duplicate_test.go +++ b/internal/terminal/duplicate_test.go @@ -1,3 +1,8 @@ +// The suite reuses stubBins from terminal_test.go, which is Unix-tagged for its +// PTY spawns; this file carries the same tag so the package still builds on +// Windows. Nothing here is platform-specific. +//go:build !windows + package terminal import (