diff --git a/CHANGELOG.md b/CHANGELOG.md index a465b51c2..b6e208ea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Branch, changes and pull request on the cards and the footer catch up within a second of a commit, checkout or edit made outside a turn, where they could lag up to three seconds, and selecting a card reads its checkout at once instead of showing what the last read found. - Kiro CLI sessions follow `KIRO_HOME`. With it set, lich read Kiro's conversations, cost and context from `~/.kiro` while Kiro kept them under `KIRO_HOME`, so they showed nothing and History listed none; the agent that carries lich's hooks was installed where Kiro never looked, and a sandboxed session was not given the directory it writes into. - Opening or closing the dock no longer leaves the session's terminal cut off at its edge for a beat once the dock stops. The terminal takes its new width as the dock starts moving, so the agent redraws while the dock slides in, where the terminal waited until a tenth of a second after the dock stopped and then jumped. A split layout's terminals fit the moment the dock stops. +- Expanding the sidebar no longer leaves the session's terminal at its old width for a beat once the sidebar is open. The terminal takes its new width as the sidebar starts moving, as with the dock, where it waited until a tenth of a second after the sidebar stopped and then jumped; collapsing it refits at once too. A split layout's terminals fit the moment the sidebar stops. ## [0.78.0] - 2026-10-22 diff --git a/native/cmd/lich-native/main.go b/native/cmd/lich-native/main.go index 0934f5ed2..2c67de508 100644 --- a/native/cmd/lich-native/main.go +++ b/native/cmd/lich-native/main.go @@ -622,8 +622,8 @@ func (u *window) body(gtx C, v stageView) (int, sidebarAction, error) { // island is the stage's rounded box: Home, the empty notice, the diff or // the terminal, in the color the active tab merges with. Its top-left corner // is square while that tab sits on it. A lone session's terminal is laid out -// termWidth wide, which a dock slide sets to where the box is going: wider, -// the box clips it; narrower, its fill shows past it. +// termWidth wide, which a dock slide or a sidebar morph sets to where the box +// is going: wider, the box clips it; narrower, its fill shows past it. func (u *window) island(gtx C, v stageView, termWidth int) error { size := gtx.Constraints.Max gtx.Constraints.Min = size @@ -729,15 +729,17 @@ func (u *window) stageRow(gtx C, v stageView) error { d.slide.snap(open) } share := d.slide.at(gtx, open) - u.terms.refitNow = d.slide.began || d.slide.landed + u.terms.refitNow = d.slide.began || d.slide.landed || u.sidebar.morph.holdsStage() defer func() { u.terms.refitNow = false }() size := gtx.Constraints.Max - // The session's terminal takes the width the slide ends at from its first - // frame, so the agent redraws for it while the dock is still moving. - termWidth := size.X + // The session's terminal takes the width the dock's slide and the + // sidebar's morph end at from their first frame, so the agent redraws for + // it while they are still moving. + termWidth := size.X - u.sidebar.stageOverhang(gtx) if open && !d.fullscreen { - termWidth = max(size.X-d.width(gtx)-gtx.Dp(gutter), 0) + termWidth -= d.width(gtx) + gtx.Dp(gutter) } + termWidth = max(termWidth, 0) if share == 0 { d.islands = nil return u.island(gtx, v, termWidth) diff --git a/native/cmd/lich-native/sidebar_morph.go b/native/cmd/lich-native/sidebar_morph.go index 818c39cd5..b34e42d3f 100644 --- a/native/cmd/lich-native/sidebar_morph.go +++ b/native/cmd/lich-native/sidebar_morph.go @@ -63,6 +63,9 @@ type sidebarMorph struct { p float32 // this frame's eased progress side morphSide boxes [2]map[string]image.Rectangle // by side, in window coordinates + + // landed is whether this frame ended the morph. + landed bool } // begin starts a morph to the shape the sidebar was just switched to. @@ -73,10 +76,15 @@ func (m *sidebarMorph) begin(gtx C) { func (m *sidebarMorph) active() bool { return m.phase != morphIdle } +// holdsStage is whether the stage's size this frame holds until the morph +// lands, or is the size it landed on. +func (m *sidebarMorph) holdsStage() bool { return m.active() || m.landed } + // advance sets the frame's progress. It runs once, before the frame is laid // out, so every part of the frame reads the same one. func (m *sidebarMorph) advance(gtx C) { m.capturing = m.phase == morphCapture + m.landed = false switch m.phase { case morphIdle: return @@ -87,7 +95,7 @@ func (m *sidebarMorph) advance(gtx C) { m.start = gtx.Now } if m.p = morphProgress(gtx.Now.Sub(m.start)); m.p >= 1 { - m.phase = morphIdle + m.phase, m.landed = morphIdle, true return } } @@ -183,6 +191,12 @@ func (s *sidebar) stageReserve(gtx C) int { return min(s.boxWidth(gtx, true), s.boxWidth(gtx, false)) } +// stageOverhang is how much wider the stage is this frame than where the +// morph leaves it: the shapes' difference through an expand, else 0. +func (s *sidebar) stageOverhang(gtx C) int { + return s.boxWidth(gtx, s.open) - s.stageReserve(gtx) +} + // morphing is the sidebar mid-morph: its box eases between the two shapes' // widths, each shape drawn at its own size from the box's leading corner and // clipped by it, the old one fading out as the new one fades in. diff --git a/native/cmd/lich-native/sidebar_morph_test.go b/native/cmd/lich-native/sidebar_morph_test.go index 268601622..5944995f9 100644 --- a/native/cmd/lich-native/sidebar_morph_test.go +++ b/native/cmd/lich-native/sidebar_morph_test.go @@ -152,3 +152,47 @@ func TestSidebarMorphResizesTheStageOnce(t *testing.T) { } } } + +// The terminal fits the width the morph ends at from its first frame, so the +// agent redraws while the sidebar is still moving: either way the terminal's +// width changes once, as the morph starts, to the final one, and the frames +// from there to the one the morph lands in refit it at once. +func TestSidebarMorphFitsTheTerminalToWhereItEndsAsItStarts(t *testing.T) { + const window = 1400 + for _, opening := range []bool{true, false} { + s := newSidebar(new(ui.Root)) + s.open = !opening + gtx := layout.Context{Ops: new(op.Ops), Metric: unit.Metric{PxPerDp: 1}, Now: time.Unix(0, 0)} + var widths []int + landings := 0 + frame := func() { + s.morph.advance(gtx) + widths = append(widths, window-s.stageReserve(gtx)-s.stageOverhang(gtx)) + if s.morph.landed { + landings++ + } + if s.morph.active() && !s.morph.holdsStage() { + t.Fatalf("opening=%v: a morph frame does not hold the stage", opening) + } + s.morph.capture(gtx.Ops) + gtx.Now = gtx.Now.Add(testFrame) + } + frame() + s.setOpen(gtx, opening, new(sidebarAction)) + for range 2 + int(sidebarMorphDuration/testFrame) + 2 { + frame() + } + after := window - s.boxWidth(gtx, opening) + for i, w := range widths[1:] { + if w != after { + t.Fatalf("opening=%v: frame %d terminal width %d, want %d from the morph's first frame", opening, i+1, w, after) + } + } + if landings != 1 { + t.Fatalf("opening=%v: landed in %d frames, want 1", opening, landings) + } + if s.morph.holdsStage() { + t.Fatalf("opening=%v: still holds the stage a frame after landing", opening) + } + } +} diff --git a/native/cmd/lich-native/stale_chrome_test.go b/native/cmd/lich-native/stale_chrome_test.go index e4295ccfc..17ba99a57 100644 --- a/native/cmd/lich-native/stale_chrome_test.go +++ b/native/cmd/lich-native/stale_chrome_test.go @@ -14,6 +14,7 @@ func stageFrames(t *testing.T, tab dockTab) (*window, *frameRig) { r := newFrameRig(rigWindow, nil) u := &window{model: newModel(nil, r.invalidate), terms: &terminals{byID: map[string]*termView{}, invalidate: r.invalidate}} u.topbar = newTopbar(&u.root) + u.sidebar = newSidebar(&u.root) _, client := newSidebarBackend(t) comments := new(commentStore) code, err := newCodeTab(client, r.invalidate, "monospace", 12, comments, func(ui.Toast) {}) diff --git a/native/cmd/lich-native/terminals.go b/native/cmd/lich-native/terminals.go index f079e63a7..91885871b 100644 --- a/native/cmd/lich-native/terminals.go +++ b/native/cmd/lich-native/terminals.go @@ -99,9 +99,9 @@ type terminals struct { // unzoomed is the window's own scale, which terminals draw at whatever // the interface zoom: a zoom step never changes a grid's cols×rows. unzoomed unit.Metric - // refitNow is a frame a dock slide began or ended in: the area just took - // the size it will hold, so the grid follows it at once rather than after - // refitSettle more of a clipped grid. + // refitNow is a frame whose area holds its size until a dock slide or a + // sidebar morph lands, or the frame one landed in, so the grid follows it + // at once rather than after refitSettle more of a clipped grid. refitNow bool mu sync.Mutex