Skip to content

Improve /resume: interactive session picker + model-generated titles - #224

Merged
gnanam1990 merged 8 commits into
mainfrom
feat/resume-session-picker
Jun 17, 2026
Merged

Improve /resume: interactive session picker + model-generated titles#224
gnanam1990 merged 8 commits into
mainfrom
feat/resume-session-picker

Conversation

@gnanam1990

@gnanam1990 gnanam1990 commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

What & why

/resume listed sessions as a static text blob, and every session was titled by its first user message — so prompts that started the same way (and the many empty/failed runs that produced no output) were indistinguishable in the list. This reworks /resume into an interactive picker and gives sessions real, model-generated titles.

Changes

Interactive session picker

  • Bare /resume now opens a single-select overlay (a new pickerSession kind) just like /model and /provider: arrow/type-to-filter/enter. /resume <id> and /resume latest still resolve directly.
  • Each row leads with a precise timestamp (today → 15:04:05, this year → Jan _2 15:04, else date) so same-titled sessions are still distinguishable; the id shows in the row meta.

Hide empty/failed sessions

  • Runs with no tool calls/results and no real assistant text (e.g. the no-output guardrail stop) are hidden from the picker. They stay on disk — only the picker filters them. A shared agent.IsNoProgressStop recognizes that stop marker.

Model-generated titles

  • New sessions.Store.UpdateTitle rewrites only the title under the per-session lock (re-reading the latest metadata first), leaves UpdatedAt untouched so a retitle never reorders the list, rejects blanks, and no-ops an unchanged title.
  • A bounded digest of the conversation (user/assistant text + tool names, per-message and total caps, skipping the no-output stop) is sent as a one-shot completion via the active provider; the response is cleaned (first content line, quotes/markup/Title: label stripped, word- and rune-capped).
  • Auto-title going forward: after a successful turn, a session still on its default first-message title gets a concise title generated in the background, at most once per session. Failures are non-fatal — the first-message title simply stays.
  • /retitle backfill: titles existing resumable sessions that still have a first-message title, one at a time, skipping empty/failed and already-named sessions, with kickoff and completion status lines.

Resume correctness fixes

  • Resume now prefers the rehydrated (compaction-aware) event view, matching zero exec --resume and the in-TUI /compact reload, so resuming a compacted session honors the prior compaction instead of re-inflating the raw log.
  • The resume summary reports the model/provider the run will actually continue with, noting the session's recorded values when they differ.
  • Removed a dead/unreachable branch in the resume text path.

Notes

  • Generation runs off the Update goroutine; it costs a small extra provider call per title (sequential for /retitle, so no burst).
  • Built on top of the Bubble Tea v2 migration (Migrate TUI to Bubble Tea v2 #222), merged into this branch.

Testing

  • New unit tests: UpdateTitle semantics; digest/cleaning/auto-title one-shot; /retitle candidate selection; picker open/hydrate; hide-empties; resume honors prior compaction.
  • gofmt, go vet, build (host + linux + windows), staticcheck all clean; internal/sessions and internal/tui pass under -race.

Summary by CodeRabbit

Release Notes

  • New Features

    • Sessions now automatically generate concise titles after successful conversation turns.
    • Added /retitle to generate/update concise session titles.
    • /resume now opens an interactive session picker when used without an ID.
  • Improvements

    • Resuming prefers compaction-aware session history with safe fallbacks.
    • Title updates persist without changing session ordering, and resumable-session detection more reliably ignores no-progress guardrail output.
  • Bug Fixes

    • Improved resilience when session history is unavailable (no crashes).
  • Tests

    • Expanded coverage for title generation/retitling and resume picker behavior.

- /resume (no arg) now opens an interactive picker like /model and /provider:
  one row per resumable session showing the title and session id (+ relative
  age); arrow to highlight, type to filter, enter to load. /resume <id> and
  /resume latest still resolve directly.
- Resume now loads the rehydrated (compaction-aware) event view instead of the
  raw log, so a resumed session honors a prior /compact — matching the CLI's
  `zero exec --resume` and the in-TUI /compact reload. Falls back to raw on a
  rehydrate error.
- The resume summary reports the model/provider the continuation will actually
  use (the active ones), noting the session's recorded model/provider when it
  differs, instead of implying a switch that doesn't happen.
- Drop the dead, self-contradictory resumeText(arg) branch (it was unreachable
  and its hint was circular); resumeText is now the no-session/error fallback.

Tests: session picker opens with title+id rows and hydrates on selection;
resume honors prior compaction (rehydrated, not raw events).
Same-titled sessions (e.g. the same first prompt run several times) looked like
duplicates because the row showed only the identical title plus a long id that
truncated the age away. Lead each row's label with a precise local timestamp
(HH:MM:SS today, "Jan _2 15:04" earlier this year, else the date) so distinct
sessions are obviously distinct; the full id stays as the right-aligned meta and
is what selection resolves. sessionWhen parses the RFC3339 UpdatedAt.
Most of a heavily-retried prompt's sessions are empty failed runs — just the
prompt plus the no-output guardrail stop ("Agent stopped … with no output …"),
with no assistant text and no tool calls. They have nothing to resume and flood
the picker with identical-looking rows. Skip any session with no resumable
content (no tool call/result and no real non-user message) from the picker; they
stay on disk and are never deleted. Adds agent.IsNoProgressStop to recognize the
stop message from a recorded event, and a test that the picker hides an empty
run while keeping a real one.
Sessions were titled from their first user message, so prompts that
started the same way (or empty/failed runs) looked identical in the
/resume picker. Generate a concise, specific title with the active
provider instead.

- sessions.Store.UpdateTitle(id, title): rewrites only Title under the
  per-session lock, re-reading the latest metadata first; leaves
  UpdatedAt untouched (a retitle is not activity and must not reorder
  the resumable list), rejects a blank title, and no-ops an unchanged
  one.
- Title generator: a bounded digest of the conversation (user/assistant
  text + tool names, per-message and total caps, skipping the no-output
  guardrail stop) is sent as a one-shot completion; the response is
  cleaned (first content line, quotes/markup/"Title:" label stripped,
  word- and rune-capped) before storing.
- Auto-title going forward: after a successful turn, a session still
  carrying its default first-message title gets a title in the
  background, at most once per session.
- /retitle: backfills existing resumable sessions that still have a
  first-message title, one at a time, skipping empty/failed runs and
  already-named sessions, with kickoff and completion status lines.

Generation runs off the Update goroutine; failures are non-fatal (the
first-message title simply stays). Adds store + TUI unit tests
(UpdateTitle semantics, digest/cleaning, auto-title one-shot, backfill
candidate selection). gofmt/vet/build(host+linux+windows)/test -race/
staticcheck all green.
…ature

Adapts the /resume picker + model-generated titles to Bubble Tea v2:
- session_title.go now imports charm.land/bubbletea/v2.
- Tests drive keys via the v2 testKey() helper instead of
  tea.KeyMsg{Type: ...}, and read the picker overlay via
  viewString(View()) (View() returns tea.View under v2).
- Resolves the TestResumeCommandListsRecentSessions conflict in favor of
  the picker assertions (bare /resume opens the session picker).

gofmt/vet/build(host+linux+windows)/staticcheck clean; sessions + tui
pass under -race.
@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: b581019e1da6
Changed files (14): internal/agent/guardrails.go, internal/agent/no_progress_stop_test.go, internal/sessions/session_title_test.go, internal/sessions/store.go, internal/tui/command_center.go, internal/tui/commands.go, internal/tui/model.go, internal/tui/model_test.go, internal/tui/picker.go, internal/tui/resume_nil_store_test.go, internal/tui/session.go, internal/tui/session_test.go, and 2 more

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds automatic background session title generation after each successful agent turn, a /retitle command to backfill titles across existing resumable sessions, an interactive picker for bare /resume, compaction-aware event rehydration on resume, and an exported IsNoProgressStop predicate to identify no-progress stop guardrail outcomes. The session store gains an UpdateTitle method.

Changes

Session Auto-Titling, /retitle Command, and Interactive /resume Picker

Layer / File(s) Summary
No-progress stop marker extraction and IsNoProgressStop predicate
internal/agent/guardrails.go, internal/agent/no_progress_stop_test.go
Extracts noOutputStopPrefix, noOutputStopMarker, noOutputStopSuffix as reusable constants, refactors noOutputStopAnswer to use them, and adds exported IsNoProgressStop predicate to recognize the full structured no-output guardrail stop. Predicate trims input, validates prefix, parses turn count, and requires marker/suffix to match exactly—not partial substring matches—ensuring the session picker and title digest can identify and exclude guardrail-stopped sessions reliably.
Store.UpdateTitle method and tests
internal/sessions/store.go, internal/sessions/session_title_test.go
Adds UpdateTitle method with per-session locking, input validation (blank titles rejected), no-op on unchanged title, and intentional UpdatedAt preservation so retitling does not affect session ordering in resumable listings. Tests validate trimming, persistence via second Get, blank rejection without data loss, no-op idempotence, and invalid ID rejection.
Title digest, cleaning, and generation logic
internal/tui/session_title.go (constants & digest, cleaning, generation)
Implements sessionTitleDigest (bounded event transcript summary skipping no-progress stops), cleanGeneratedTitle (normalizes LLM output to 3–6 words by extracting first meaningful line, removing labels/quotes/markup/punctuation and code fences), generateSessionTitle (provider streaming call with 30s timeout), firstUserMessageTitle (derives default from first user message), and sessionTitleIsAuto (classification predicate for default/auto-titled sessions).
Auto-title generation and /retitle backfill orchestration
internal/tui/session_title.go (commands, background logic)
Adds generateSessionTitleCmd (background Bubble Tea command), maybeAutoTitleActiveSession (one-shot per-session post-turn scheduling with in-process queue gate to prevent duplicate work), startSessionRetitle (scans resumable sessions, filters auto-titled candidates with titlable content, initializes sequential queue), and handleSessionTitleGenerated (applies results, releases gate on failure for retryability, drains backfill queue sequentially, appends transcript status row on completion).
/retitle command registration and model state fields
internal/tui/commands.go, internal/tui/model.go
Registers commandRetitle kind and /retitle command definition (session group, "generating concise titles for resumable sessions"); extends model struct with titledSessions (per-session queue gate), retitleQueue, retitleActive, retitleTotal, retitleDone, retitleOK fields for sequential backfill tracking.
Interactive /resume picker, rehydrated event loading, and resume summary
internal/tui/picker.go, internal/tui/session.go
Adds pickerSession picker kind; introduces resumeEvents helper (prefers rehydrated/compaction-aware ReadRehydratedEvents, falls back to raw); implements sessionWhen timestamp formatter, newSessionPicker builder (lists only resumable sessions with content), sessionHasResumableContent/eventsHaveResumableContent filters (tool events or non-user content, excluding no-progress stops), openSessionPicker wiring; converts formatResumeSummary to method with model/provider "recorded" lines showing recorded-vs-current differences.
resumeText no-argument fallback and nil store defense
internal/tui/command_center.go, internal/tui/resume_nil_store_test.go
Refactors resumeText from argument-based renderer to no-argument fallback, removes per-argument "requested session" path, adds explicit nil sessionStore check returning "session store unavailable" message to prevent panics in defensive/test paths.
Model command routing: /resume picker, /retitle dispatch, auto-title trigger
internal/tui/model.go, internal/tui/session.go, internal/tui/session_test.go
handleResumeCommand returns early via m.resumeText() on empty args, otherwise routes through resumeEvents; handleSubmit opens picker for bare /resume and adds commandRetitle branch; agentResponseMsg schedules maybeAutoTitleActiveSession then queued prompt, returning both commands batched; choosePicker adds pickerSession case calling handleResumeCommand and appending non-empty system text; test updated for no-arg resumeText().
Comprehensive test coverage
internal/tui/session_title_test.go, internal/tui/model_test.go
Title generation tests: TestCleanGeneratedTitle, TestSessionTitleDigest* (digest composition and budget), TestSessionTitleIsAuto, TestAutoTitleGeneratesTitleForActiveSession, TestAutoTitleSkipsAlreadyNamedSession, TestAutoTitleFailureReleasesRetryGate, TestRetitleBackfillTitlesOnlyAutoTitledSessions. Resume tests: TestResumeCommandListsRecentSessions (picker with titles/ids), TestResumePickerSelectionHydratesSession, TestResumePickerHidesEmptyFailedSessions, TestResumeHonorsPriorCompaction (rehydrated events deep equality via reflect.DeepEqual). Test scaffolding: mock title provider, append/factory helpers.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant TUI as TUI model.handleSubmit
  participant AgentLoop as agentResponseMsg handler
  participant AutoTitle as maybeAutoTitleActiveSession
  participant GenCmd as generateSessionTitleCmd
  participant Provider as Provider Stream
  participant Store as Store.UpdateTitle
  participant SessionPicker as newSessionPicker

  User->>TUI: /retitle
  TUI->>TUI: startSessionRetitle (scan, filter auto-titled)
  TUI-->>User: "Retitling N sessions..."

  User->>TUI: /resume (bare)
  TUI->>SessionPicker: openSessionPicker
  SessionPicker->>SessionPicker: filter by resumableContent (exclude no-progress-stop)
  SessionPicker-->>TUI: picker overlay
  TUI-->>User: "Resume a session" picker

  User->>TUI: select session
  TUI->>TUI: handleResumeCommand(sessionID)
  TUI->>TUI: resumeEvents (rehydrated-first)
  TUI-->>User: "Resumed <ID>" summary

  AgentLoop->>AutoTitle: maybeAutoTitleActiveSession (post-turn)
  AutoTitle->>GenCmd: generateSessionTitleCmd(sessionID, digest)
  GenCmd->>Provider: streaming completion (30s)
  Provider-->>GenCmd: raw output
  GenCmd->>GenCmd: cleanGeneratedTitle
  GenCmd->>Store: UpdateTitle
  Store-->>GenCmd: persisted
  GenCmd-->>AgentLoop: sessionTitleGeneratedMsg
  AgentLoop->>AgentLoop: update active session title
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~65 minutes

Possibly related PRs

  • Gitlawb/zero#125: Both PRs modify internal/agent/guardrails.go no-output stop logic; this PR refactors with stable marker constants and IsNoProgressStop predicate that depend on guardrail behavior.
  • Gitlawb/zero#69: This PR's /resume picker and resumeEvents rehydration depend on persisted-session hydration APIs and session state handling in internal/tui/session.go.
  • Gitlawb/zero#98: Both PRs modify internal/tui/command_center.go resumeText signature and rendering; this PR removes argument-based behavior while that PR rewrites the renderer for richer sessions view.

Suggested reviewers

  • Vasanthdev2004
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and clearly summarizes the main enhancement: adding an interactive session picker to /resume and implementing model-generated titles for sessions.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/resume-session-picker

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/tui/command_center.go (1)

235-239: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard resumeText against a nil sessionStore to avoid panic.

Line 238 dereferences m.sessionStore unconditionally. In fallback paths, this can crash instead of rendering a safe “store unavailable” response.

Proposed fix
 func (m model) resumeText() string {
+	if m.sessionStore == nil {
+		return renderCommandOutput(commandOutput{
+			Title:  "Sessions",
+			Status: commandStatusBlocked,
+			Sections: []commandSection{{
+				Title: "Store",
+				Lines: []string{"error: session store is unavailable"},
+			}},
+		})
+	}
 	// Only standalone conversations — not child/spec sub-runs, which an agent
 	// spawns by the dozen and would otherwise flood the picker (the "… N more").
 	sessions, err := m.sessionStore.ListResumable()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/command_center.go` around lines 235 - 239, Add a nil check for
m.sessionStore in the resumeText method before calling ListResumable() on it. If
m.sessionStore is nil, return an appropriate fallback message indicating the
store is unavailable instead of allowing the unconditional dereference that
would cause a panic. This guards against cases where sessionStore might not be
initialized in fallback paths.
🧹 Nitpick comments (1)
internal/tui/model_test.go (1)

984-986: ⚡ Quick win

Assert full rehydrated event equality, not only count.

This can false-pass if resume loads the wrong event slice with the same length. Compare next.sessionEvents and rehydrated directly to lock the compaction contract.

Suggested test hardening
 import (
 	"bytes"
 	"context"
 	"errors"
 	"path/filepath"
+	"reflect"
 	"strings"
 	"testing"
 	"time"
@@
 	if len(next.sessionEvents) != len(rehydrated) {
 		t.Fatalf("resumed sessionEvents = %d, want rehydrated %d (not raw %d): resume must honor prior compaction", len(next.sessionEvents), len(rehydrated), len(raw))
 	}
+	if !reflect.DeepEqual(next.sessionEvents, rehydrated) {
+		t.Fatalf("resumed sessionEvents do not match rehydrated events")
+	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/model_test.go` around lines 984 - 986, The test assertion in the
rehydration check only validates that next.sessionEvents and rehydrated have the
same length, which can produce false positives if the wrong events are loaded.
Replace the length comparison with a direct equality check that compares
next.sessionEvents and rehydrated slices fully to ensure the compaction contract
is properly honored and the correct events are restored during resume.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/agent/guardrails.go`:
- Around line 105-109: The IsNoProgressStop function currently uses
strings.Contains to match the noOutputStopMarker anywhere in the content, which
is too permissive and can cause false positives when the marker appears as part
of legitimate assistant or user text. Tighten the detection logic by replacing
the substring check with a more precise validation—such as checking for an exact
content match (where the entire content equals the marker) or validating the
marker appears only at a specific position or in a specific format that reliably
identifies actual guardrail stops rather than coincidental substring matches.

In `@internal/tui/session.go`:
- Around line 201-204: In the fallback path where
m.sessionStore.ReadEvents(sessionID) is called in the resumeEvents function, the
error handling block returns the wrong error variable. When rawErr is not nil,
change the return statement to return rawErr instead of err, so that the actual
failure from ReadEvents is propagated rather than masking it with the earlier
rehydration error.

---

Outside diff comments:
In `@internal/tui/command_center.go`:
- Around line 235-239: Add a nil check for m.sessionStore in the resumeText
method before calling ListResumable() on it. If m.sessionStore is nil, return an
appropriate fallback message indicating the store is unavailable instead of
allowing the unconditional dereference that would cause a panic. This guards
against cases where sessionStore might not be initialized in fallback paths.

---

Nitpick comments:
In `@internal/tui/model_test.go`:
- Around line 984-986: The test assertion in the rehydration check only
validates that next.sessionEvents and rehydrated have the same length, which can
produce false positives if the wrong events are loaded. Replace the length
comparison with a direct equality check that compares next.sessionEvents and
rehydrated slices fully to ensure the compaction contract is properly honored
and the correct events are restored during resume.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a78dbe17-b226-4625-a236-f0b507757e62

📥 Commits

Reviewing files that changed from the base of the PR and between 9c1cc69 and 63d8b11.

📒 Files selected for processing (12)
  • internal/agent/guardrails.go
  • internal/sessions/session_title_test.go
  • internal/sessions/store.go
  • internal/tui/command_center.go
  • internal/tui/commands.go
  • internal/tui/model.go
  • internal/tui/model_test.go
  • internal/tui/picker.go
  • internal/tui/session.go
  • internal/tui/session_test.go
  • internal/tui/session_title.go
  • internal/tui/session_title_test.go

Comment thread internal/agent/guardrails.go Outdated
Comment thread internal/tui/session.go
@gnanam1990 gnanam1990 mentioned this pull request Jun 16, 2026

@anandh8x anandh8x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes based on the current diff.

Findings:

  • internal/agent/guardrails.go:108: IsNoProgressStop uses strings.Contains, so any legitimate message that quotes the no-output marker can be treated as a failed empty run. That can hide real sessions from /resume and skip title generation. Please tighten this to an exact or otherwise structured guardrail-stop match.
  • internal/tui/session.go:201-204: resumeEvents returns the earlier rehydration err when raw fallback ReadEvents fails. It should return rawErr so callers see the actual fallback failure.
  • internal/tui/command_center.go:235-238: resumeText dereferences m.sessionStore without a nil guard. newModel normally initializes it, but this fallback method should still avoid a zero-value/model-test panic.
  • internal/tui/model_test.go:984-986: the rehydration test only compares lengths. Please compare next.sessionEvents with the rehydrated event slice directly so the test proves /resume loaded the compacted context, not another same-length slice.

CI is green and the feature direction looks fine, but the first two are correctness issues that should be fixed before merge.

…nil guard

Three review findings on the /resume work:

- IsNoProgressStop matched any content CONTAINING the no-output marker
  substring (strings.Contains), so a legitimate assistant/tool message that
  quoted the marker could be misread as a failed empty run — wrongly hiding a
  real session from the /resume picker and skipping its title generation. Now
  it requires the full answer structure (prefix + marker + suffix); a quote no
  longer matches.
- resumeEvents returned the earlier rehydration error when the raw ReadEvents
  fallback itself failed, masking the actual fallback failure. Returns rawErr.
- resumeText dereferenced m.sessionStore unconditionally; a nil store (fallback/
  test paths) would panic. Now renders a safe "store unavailable" message.
  (newSessionPicker already guarded nil.)

Tests: IsNoProgressStop accepts the real answer and rejects a quoting message /
bare marker / prefix- or suffix-only; resumeText with a nil store returns the
safe message instead of panicking. Full gate green.
@gnanam1990

Copy link
Copy Markdown
Collaborator Author

Thanks for the review — all three addressed in 12474aa:

  • IsNoProgressStop false positives — it now requires the full stop-answer structure (Agent stopped after … prefix + the marker + the …without making progress. suffix) instead of a bare strings.Contains on the marker. A legitimate message that merely quotes the marker no longer counts, so real sessions aren't hidden from /resume and don't lose title generation. Added a test covering the real answer (any turn count) vs. a quoting message / bare marker / prefix- or suffix-only.
  • resumeEvents fallback error — now returns rawErr (the actual ReadEvents failure) instead of the earlier rehydration error.
  • resumeText nil sessionStore — guarded; it renders a safe "store unavailable" message instead of dereferencing nil. (newSessionPicker already guarded nil.) Added a nil-store test.

gofmt / vet / build (host+linux+windows) / -race / staticcheck all green.

@gnanam1990
gnanam1990 requested a review from anandh8x June 16, 2026 16:43

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: Request changes — three substantive issues + one minor test-strength finding

This is a strong, well-architected feature — the file split (separate session_title.go from session.go), the bounded-digest approach, the per-session lock on UpdateTitle, the strict no-progress detection, and the one-shot auto-title gating are all solid choices. The test coverage is good. The issues below are correctness/UX gaps that are worth fixing before merge; the architecture stays as-is.

Findings

1. titledSessions is set optimistically — auto-title failures are silent and permanent (UX correctness)

internal/tui/session_title.go:250 (maybeAutoTitleActiveSession) and :289 (startSessionRetitle) set m.titledSessions[sessionID] = true before the title cmd fires, so a failed title generation (provider error, model returned no usable title, timeout) leaves the session with its first-message title forever for this process. The user has no idea anything happened — no log line, no status row, no retry path, and the only escape is process restart. The PR comment says "on failure the first-message title simply stays" but it stays permanently, not just for this turn.

Suggested fix: move the titledSessions[id] = true into the success path of the returned msg (handleSessionTitleGenerated), not the cmd-scheduling site. The session id is gated only after the title actually persists. Failure leaves the map untouched, so a future turn (or /retitle restart) can retry.

2. IsNoProgressStop is structurally loose — a real message that quotes the marker in passing is misclassified

internal/agent/guardrails.go:117 — the current check is HasPrefix(Prefix) && Contains(Marker) && HasSuffix(Suffix), which only requires the three components in order with arbitrary text in between. A hostile real message like

Agent stopped after 3 turns. The marker is "with no output (no visible text and no tool calls)" so here it is: to avoid consuming tokens without making progress.

is currently classified as a no-progress stop and would hide a real session from /resume and skip its title generation. (I confirmed this in a quick repro outside the repo.) The PR's own test guards against the "marker only" case but not the "marker as a quote in the middle" case.

Suggested fix: after HasPrefix succeeds, find the marker's index, then require the suffix to come immediately after the marker (whitespace allowed but no arbitrary text). Something like:

idx := strings.Index(trimmed, noOutputStopMarker)
if idx < 0 {
    return false
}
return strings.HasSuffix(trimmed[idx+len(noOutputStopMarker):], noOutputStopSuffix)

Add a test case to no_progress_stop_test.go for the hostile-input sentence above.

3. maybeAutoTitleActiveSession has no sessionStore == nil guard

internal/tui/session_title.go:243 — checks m.provider == nil but not m.sessionStore == nil. The auto-title path is reachable for any model that ran a successful turn, including the fallback model constructed in some tests. If m.sessionStore is nil, the closure inside generateSessionTitleCmd would nil-dereference on the auto-title path (the backfill path happens to guard it because the closure re-reads events). The other nil-store fix in this PR (resumeText, newSessionPicker) set the precedent — this one was missed.

Suggested fix: add if m.sessionStore == nil { return m, nil } after the provider check. Add a small test (TestMaybeAutoTitleSkipsWhenStoreNil) parallel to the existing TestResumeCommandReportsMissingSession style.

4. TestResumeHonorsPriorCompaction only compares slice lengths, not contents

internal/tui/model_test.go:946 (and :984 for the assertion) — already flagged by @anandh8x. The setup checks len(rehydrated) >= len(raw) and the assertion checks len(next.sessionEvents) != len(rehydrated). A regression that produced a same-length but different-content slice (e.g. rehydration returns the raw events in a different order, or replaces the compaction summary event with one of the dropped originals) would pass this test.

Suggested fix: replace the length check with if !reflect.DeepEqual(next.sessionEvents, rehydrated) { t.Fatalf(...) } (or element-by-element comparison for a more readable failure message). Cheap, catches the actual bug class.

Non-blocking notes (skip if you want)

  • generateSessionTitleCmd closure captures m.provider and m.sessionStore at cmd-build time. If the user runs /provider between the successful turn and the title cmd landing, the title is generated by the old provider. Probably fine — same provider, different model — but worth a one-line comment if you want to be explicit.
  • UpdateTitle (internal/sessions/store.go:580) trims the input but doesn't normalize internal whitespace, so a model return of "Some Title" (two spaces) won't no-op against an existing "Some Title" (one space). Minor; would just rewrite the file with the new whitespace.

What's good (no action needed)

  • The picker structure mirrors /model and /provider cleanly.
  • sessionTitleDigest correctly skips the no-output stop AND respects the digest char budget.
  • cleanGeneratedTitle covers the realistic model failure modes (quotes, code fences, "Title:" labels, multi-line responses).
  • resumeEvents rehydration-aware-with-fallback is the right call for the compaction correctness story.
  • sessionWhen timestamp formatting (today → 15:04:05, this year → Jan _2 15:04, else date) is exactly the right granularity.
  • Per-session titledSessions gating at the type level (a map, not a counter) is the right shape for "at most once per session."
  • All tests pass on -count=1 and on CI. The "Zero Review" failure is the pre-existing TestLoadOrCreateSecretConcurrentConverges flake in internal/oauth, not this PR.

Happy to pair on any of these — they're all 5-15 line changes.

Comment thread internal/agent/guardrails.go Outdated
Comment thread internal/tui/model_test.go Outdated
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator

Plan guide — revisions for feat/resume-session-picker

Posting a structured plan alongside the REQUEST_CHANGES review so the work can be scoped as one or two PRs without losing the durability story. The plan has 4 work-streams, each independently shippable — Streams 1+2 (~70 LoC) can land in this PR; Stream 3 (~80 LoC) is the durable replacement for the in-memory titledSessions map and is recommended as a follow-up PR so this one stays focused.


Stream 1 — Fix review findings (~40 LoC, ship in this PR)

1.1 [high] Tighten IsNoProgressStopinternal/agent/guardrails.go:114-119

Current check (HasPrefix + Contains + HasSuffix) allows arbitrary text between the three components. A real message that quotes the marker in passing gets misclassified. Replace the body with:

func IsNoProgressStop(content string) bool {
    trimmed := strings.TrimSpace(content)
    if !strings.HasPrefix(trimmed, noOutputStopPrefix) {
        return false
    }
    idx := strings.Index(trimmed, noOutputStopMarker)
    if idx < 0 {
        return false
    }
    return strings.HasSuffix(trimmed[idx+len(noOutputStopMarker):], noOutputStopSuffix)
}

Add a case to internal/agent/no_progress_stop_test.go for the hostile-input sentence:

Agent stopped after 3 turns. The marker is "with no output (no visible text and no tool calls)" so here it is: to avoid consuming tokens without making progress.


1.2 [high] Move titledSessions[id] = true to the success pathinternal/tui/session_title.go:255 and :294

The map is set before the title cmd fires, so a failed title generation leaves the session with its first-message title forever for this process. No log line, no retry, no escape other than restart. Drop the optimistic set. Move it to handleSessionTitleGenerated (line 307), gated on msg.err == nil && msg.title != "":

if msg.err == nil && msg.title != "" {
    if m.titledSessions == nil {
        m.titledSessions = map[string]bool{}
    }
    m.titledSessions[msg.sessionID] = true
}

maybeAutoTitleActiveSession becomes a pure "is it worth firing?" check. startSessionRetitle still pre-marks its first candidate only (not the whole queue — see Stream 3.2 for the full fix). Add a test to session_title_test.go that returns a sessionTitleGeneratedMsg{err: someError} and asserts the cmd fires again on a second call.


1.3 [medium] Add m.sessionStore == nil guardinternal/tui/session_title.go:237-259

Checks m.provider == nil but not m.sessionStore == nil. The closure inside generateSessionTitleCmd calls store.UpdateTitle and would nil-deref. Add the guard after the provider check:

if m.sessionStore == nil {
    return m, nil
}

Add TestMaybeAutoTitleSkipsWhenStoreNil to session_title_test.go.


1.4 [low] Strengthen TestResumeHonorsPriorCompactioninternal/tui/model_test.go:946, assertion at :984

Replace the length-only check with a deep equality check. Add reflect to the imports:

if !reflect.DeepEqual(next.sessionEvents, rehydrated) {
    t.Fatalf("resumed sessionEvents do not match rehydrated; resume must honor prior compaction\nresumed:  %+v\nrehydrated: %+v\nraw:       %+v",
        next.sessionEvents, rehydrated, raw)
}

Stream 2 — Add the isSessionTitleManuallySet flag (~30 LoC, ship in this PR)

Currently no way for the user to say "this title is mine, leave it alone." If the user runs /retitle on a session, the next turn's auto-title will fire and overwrite it.

2.1internal/sessions/store.go

Add to Metadata (line 65-95):

TitleIsManuallySet bool `json:"titleIsManuallySet,omitempty"`

omitempty keeps existing session files backward compatible. Change UpdateTitle (line 579-606) to take an options struct and reject auto writes when the manual flag is set:

type UpdateTitleInput struct {
    Title  string
    Manual bool
}

func (store *Store) UpdateTitle(sessionID string, input UpdateTitleInput) (Metadata, error) {
    // ... existing validation + lock + read ...
    if !input.Manual && session.TitleIsManuallySet {
        return session, nil // no-op
    }
    if session.Title == trimmed {
        return session, nil
    }
    session.Title = trimmed
    if input.Manual {
        session.TitleIsManuallySet = true
    }
    // ... existing writeMetadata ...
    return session, nil
}

Update the two call sites in session_title.go:185 (auto-title, no manual flag) and the backfill path (also auto, no manual flag). The /rename command (separate PR) is what sets Manual: true.

2.2 (deferred to a follow-up PR): add a /rename <id> <new title> slash command that calls UpdateTitle{Manual: true} and mark manually-titled sessions with a small or · locked prefix in the picker. Not in scope for this PR.


Stream 3 — Replace the in-memory titledSessions map with a persisted stage (~80 LoC, follow-up PR)

The current titledSessions map[string]bool is in-memory and process-local. It dies on restart; the auto-title path can re-fire on the same session across restarts. The trade-off:

  • In-memory (today): simple, fast, dies on restart
  • Persisted stage (proposed): durable, survives restart, matches the project's SpecStatus / LastEventType pattern

3.1internal/sessions/store.go

Add a TitleAutoStage enum (matches the SpecStatus pattern at line 57-62):

type TitleAutoStage string

const (
    TitleAutoStageNone         TitleAutoStage = ""
    TitleAutoStageFirstMessage TitleAutoStage = "first_message"
)

type Metadata struct {
    // ... existing fields ...
    TitleAutoStage TitleAutoStage `json:"titleAutoStage,omitempty"`
    // ...
}

3.2internal/sessions/store.go (UpdateTitle)

Add server-side stage gate. The store enforces "auto writes only succeed if the session is still at the relevant stage":

if !input.Manual {
    switch session.TitleAutoStage {
    case TitleAutoStageFirstMessage:
        return session, nil // already titled by the auto path
    }
}
// ... existing write ...
if !input.Manual && session.TitleAutoStage < TitleAutoStageFirstMessage {
    session.TitleAutoStage = TitleAutoStageFirstMessage
}

3.3internal/tui/model.go (line 80) and internal/tui/session_title.go (lines 237, 262, 290, 307)

Remove the titledSessions field from model. maybeAutoTitleActiveSession becomes a thin "should we fire?" check, no side effect. startSessionRetitle no longer pre-marks the map. handleSessionTitleGenerated no longer writes the map — the store handles the stage advance server-side.

Bonus behavior change: failed backfill steps don't lose their turn. If the provider errors on session 2 of 5, the next /retitle run picks it up because the server-side stage is still "" and sessionTitleIsAuto is still true.

3.4 — Tests

Add to internal/sessions/session_title_test.go:

  • TestUpdateTitleRejectsAutoWhenManual
  • TestUpdateTitleAdvancesAutoStage
  • TestUpdateTitleManualIgnoresStage

Add to internal/tui/session_title_test.go:

  • TestAutoTitleRetriesAfterFailure — return sessionTitleGeneratedMsg{err: ...}, assert cmd fires again on a second call
  • TestBackfillRetriesAfterFailure — same shape for the backfill path

Stream 4 — Prompt + cleaner cleanup (optional, 5 LoC)

4.1internal/tui/session_title.go:19-21

The 30-line cleanGeneratedTitle has a "drop a leading Title: / Title - label" branch because the system prompt tells the model to write Title Case — the model often interprets that as "write a line that starts with Title:". Either tighten the prompt or keep both. Recommendation: keep the cleaner (10 lines, good test coverage, future-proof) and tighten the prompt as defense-in-depth:

const sessionTitleSystemPrompt = "You write a short, specific title for a coding-assistant conversation so a user can tell it apart from others in a list. " +
    "Reply with ONLY the title in Title Case, starting directly with the title text — no label, no preamble, no explanation. " +
    "3 to 6 words, naming the concrete task or topic."

Suggested merge order

  1. Stream 1.1 (hotfix, 4 LoC)
  2. Stream 1.2 + 1.3 + 1.4 (semantic changes, 30 LoC + tests)
  3. Stream 2.1 (manual flag, signature change, 30 LoC)
  4. Stream 3.1 + 3.2 + 3.3 + 3.4 (durable stage, separate PR, 80 LoC + tests)
  5. Stream 4.1 (prompt tweak, independent, 5 LoC)

Total: ~150 LoC across two PRs.


What NOT to do

  • Don't keep the in-memory titledSessions map and add the server-side stage. Two state sources for the same invariant drift over time. Pick one — the proposal is server-side only.
  • Don't add the Title: label handling to the prompt and remove the cleaner in the same commit. Fix one or the other, not both.
  • Don't introduce a sentinel value in the title field itself (e.g. Title = "@auto"). The project uses dedicated fields for this kind of state (SpecStatus, LastEventType).
  • Don't add a /rename slash command in this PR. It's the natural next step after the manual flag is persisted, but the picker UX work is its own scope.

Verification checklist (before merge)

  • go build ./... clean (stash .claude/wip-stash/ first per the build script)
  • go vet ./internal/... clean
  • gofmt -l ./... clean
  • go test -count=1 -race ./internal/sessions/... ./internal/tui/... green (CGO required for -race)
  • All 6 smoke checks (ubuntu / macos / windows) green on CI
  • Pre-existing flake TestLoadOrCreateSecretConcurrentConverges (internal/oauth) may surface on Zero Review; re-run to clear
  • Pre-existing env-fragile test from PR TUI polish and fixes #220 (TestModelCommandPersistsSelectedModelToUserConfig) is unrelated to this PR; verify failure (if any) is from the ANTHROPIC_* env vars, not from these changes

Full plan guide with all the code blocks in context: see the discussion comment on this PR.

…o-title, nil-store guard, deep resume assertion

- guardrails: IsNoProgressStop now matches the exact structure noOutputStopAnswer
  emits — prefix + "<int> turns " + marker + " " + suffix — instead of
  prefix && contains(marker) && suffix. A genuine message that merely quotes the
  marker amid other prose is no longer misclassified as a failed empty run (which
  would wrongly hide a real session from /resume and skip its title generation).
  Adds the hostile-input regression cases (quoted marker, text between
  marker/suffix, non-integer turn count).
- session_title: release the optimistic titledSessions gate when a generation
  FAILS (provider error, empty title, store write error) so a later turn or
  /retitle can retry; success keeps the one-shot gate. Kept the optimistic mark
  (rather than mark-on-success) to preserve the no-double-fire guarantee while a
  generation is in flight.
- session_title: guard maybeAutoTitleActiveSession against a nil sessionStore
  (the title cmd calls store.UpdateTitle) — mirrors the resume nil-store guards.
- model_test: assert resumed sessionEvents with reflect.DeepEqual against the
  rehydrated context, not just slice length, so a reordered/substituted-but-
  same-length regression is caught.
@gnanam1990

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough review @Vasanthdev2004 — all four addressed in 2d4d127.

1. titledSessions set optimistically (silent, permanent failure). Fixed — but I kept the optimistic mark and instead release it on failure in handleSessionTitleGenerated (provider error / empty title / store-write error → delete(titledSessions, id)), rather than moving the mark to the success path. Reason: marking only on success reopens a double-fire window — maybeAutoTitleActiveSession runs after every turn, so a second turn finishing while the first title cmd is still in flight (title not applied yet, so the session still reads as "auto") would kick off a second generation for the same session. Unmark-on-failure keeps your retryability goal and the no-double-fire guarantee; success still keeps the one-shot gate. Covered by TestAutoTitleFailureReleasesRetryGate.

2. IsNoProgressStop structurally loose. Fixed — it now matches the exact structure noOutputStopAnswer emits: prefix + "<int> turns " + marker + " " + suffix, requiring the count to be a bare integer and the marker to be immediately followed by the suffix. Your hostile sentence (plus "text between marker and suffix" and "non-integer count") are now in no_progress_stop_test.go and rejected.

3. maybeAutoTitleActiveSession missing nil-store guard. Fixed — if m.provider == nil || m.sessionStore == nil { return m, nil }, with TestMaybeAutoTitleSkipsWhenStoreNil.

4. Length-only compaction assertion. Fixed — TestResumeHonorsPriorCompaction now uses reflect.DeepEqual(next.sessionEvents, rehydrated) and dumps raw/rehydrated/resumed on failure. (This also covers @anandh8x's flag of the same test.)

Non-blocking notes left as-is: the generateSessionTitleCmd provider capture is benign (same provider — only the model could differ between the turn and the title cmd), and the UpdateTitle internal-whitespace normalization is cosmetic. Happy to fold in either if you'd prefer.

Gate: gofmt, go vet, go test ./internal/agent ./internal/tui -race, and staticcheck all green.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/tui/session_title.go (1)

265-275: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Missing nil guard for sessionStore before use.

maybeAutoTitleActiveSession correctly checks m.sessionStore == nil at line 241, but startSessionRetitle only checks m.provider before calling m.sessionStore.ListResumable() at line 272. If /retitle is invoked with a nil store (e.g., in a test or fallback context), this panics.

🛡️ Proposed fix to add nil-store guard
 func (m model) startSessionRetitle() (model, tea.Cmd, string) {
 	if m.provider == nil {
 		return m, nil, "Cannot retitle sessions: no active provider is configured."
 	}
+	if m.sessionStore == nil {
+		return m, nil, "Cannot retitle sessions: session store is unavailable."
+	}
 	if m.retitleActive {
 		return m, nil, fmt.Sprintf("Already generating titles (%d/%d). Let it finish first.", m.retitleDone, m.retitleTotal)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/session_title.go` around lines 265 - 275, The
startSessionRetitle function checks if m.provider is nil but does not check if
m.sessionStore is nil before calling m.sessionStore.ListResumable() at line 272.
Add a nil guard for m.sessionStore early in the startSessionRetitle function,
similar to the pattern already used in maybeAutoTitleActiveSession (which checks
m.sessionStore == nil at line 241), to prevent a panic if the session store is
not initialized. Return an appropriate error message when the store is nil.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/tui/session_title.go`:
- Around line 265-275: The startSessionRetitle function checks if m.provider is
nil but does not check if m.sessionStore is nil before calling
m.sessionStore.ListResumable() at line 272. Add a nil guard for m.sessionStore
early in the startSessionRetitle function, similar to the pattern already used
in maybeAutoTitleActiveSession (which checks m.sessionStore == nil at line 241),
to prevent a panic if the session store is not initialized. Return an
appropriate error message when the store is nil.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: dc655539-65d5-448b-8d1c-a3988232e674

📥 Commits

Reviewing files that changed from the base of the PR and between 12474aa and 2d4d127.

📒 Files selected for processing (5)
  • internal/agent/guardrails.go
  • internal/agent/no_progress_stop_test.go
  • internal/tui/model_test.go
  • internal/tui/session_title.go
  • internal/tui/session_title_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/agent/guardrails.go
  • internal/tui/model_test.go

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM ? no issues found. UpdateTitle correctly serializes under the per-session lock, re-reads latest metadata before writing, leaves UpdatedAt untouched, rejects blank/unchanged titles, and the auto-title path fires at most once per session. The resume picker hydrates via handleResumeCommand and correctly prefers the compaction-aware event view.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM ? no issues found. UpdateTitle correctly serializes under the per-session lock, re-reads latest metadata before writing, leaves UpdatedAt untouched, rejects blank/unchanged titles, and the auto-title path fires at most once per session. The resume picker hydrates via handleResumeCommand and correctly prefers the compaction-aware event view.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looked this over carefully ??? it's a solid PR. The interaction with choosePicker for the new pickerSession kind is wired in cleanly, and openSessionPicker does the right thing by short-circuiting to the text fallback when there's nothing resumable. handleResumeCommand -> resumeEvents preferring the rehydrated (compaction-aware) view with a raw-read fallback is the right move, and the summary line showing the active vs recorded model/provider is a small but very useful touch ??? it'll save people a lot of "wait, why did it switch?" moments.

The title work is where I was most skeptical, and it's well-handled:

  • Store.UpdateTitle is locked under the same per-session mutex as AppendEvent and re-reads metadata under that lock, so it can't race with a concurrent append or vice-versa. Leaving UpdatedAt alone on a retitle is the right call ??? a title isn't activity, and a retitle should never reorder the picker.
  • The titledSessions gate is set optimistically when the cmd is scheduled and released on failure, so a hung/failed provider doesn't permanently lock a session out of ever getting a model-generated title. Auto-title is silent on failure (first-message title stays), which is the right UX.
  • cleanGeneratedTitle is defensive in the right ways: first-non-empty-line after markup strip, word cap, rune cap, and sessionTitleTimeout keeps a stuck provider from wedging the background cmd.
  • The IsNoProgressStop structural match (prefix + <int> turns + marker + suffix) is correct ??? a model quoting the marker amid other prose won't be misclassified as an empty run and wrongly hidden from the picker.

session_title_test.go covers digest, cleaning, auto-title one-shot, and /retitle candidate selection; the test in the sessions package covers the lock/no-blank/no-op semantics. resume_nil_store_test.go covers the nil-store paths.

I also confirmed gofmt/go vet clean and the three affected packages (internal/sessions, internal/agent, internal/tui) pass under go test -count=1.

LGTM. Two small suggestions for follow-up, not blockers:

  1. The title generation runs serially in /retitle (one at a time via m.retitleQueue). That's the right call to avoid burst-charging, but the per-session sessionTitleTimeout is 30s ??? a run with N sessions could take N*30s in the worst case. Worth surfacing a cancel command in /retitle so a user can bail if it's taking too long.
  2. m.titledSessions lives on the model. If the TUI restarts (process restart) the gate resets, so a session that was mid-title at exit can get re-titled on the next turn. Probably fine in practice (the model will just re-generate the same title), but worth noting in case you want a persistent titledAt marker on the metadata.

Nice work on this one ??? the picker UX is much better than the old text blob, and the model-generated titles make /resume actually usable for finding past work.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looked this over carefully ??? it's a solid PR. The interaction with choosePicker for the new pickerSession kind is wired in cleanly, and openSessionPicker does the right thing by short-circuiting to the text fallback when there's nothing resumable. handleResumeCommand -> resumeEvents preferring the rehydrated (compaction-aware) view with a raw-read fallback is the right move, and the summary line showing the active vs recorded model/provider is a small but very useful touch ??? it'll save people a lot of "wait, why did it switch?" moments.

The title work is where I was most skeptical, and it's well-handled:

  • Store.UpdateTitle is locked under the same per-session mutex as AppendEvent and re-reads metadata under that lock, so it can't race with a concurrent append or vice-versa. Leaving UpdatedAt alone on a retitle is the right call ??? a title isn't activity, and a retitle should never reorder the picker.
  • The titledSessions gate is set optimistically when the cmd is scheduled and released on failure, so a hung/failed provider doesn't permanently lock a session out of ever getting a model-generated title. Auto-title is silent on failure (first-message title stays), which is the right UX.
  • cleanGeneratedTitle is defensive in the right ways: first-non-empty-line after markup strip, word cap, rune cap, and sessionTitleTimeout keeps a stuck provider from wedging the background cmd.
  • The IsNoProgressStop structural match (prefix + <int> turns + marker + suffix) is correct ??? a model quoting the marker amid other prose won't be misclassified as an empty run and wrongly hidden from the picker.

session_title_test.go covers digest, cleaning, auto-title one-shot, and /retitle candidate selection; the test in the sessions package covers the lock/no-blank/no-op semantics. resume_nil_store_test.go covers the nil-store paths.

I also confirmed gofmt/go vet clean and the three affected packages (internal/sessions, internal/agent, internal/tui) pass under go test -count=1.

LGTM. Two small suggestions for follow-up, not blockers:

  1. The title generation runs serially in /retitle (one at a time via m.retitleQueue). That's the right call to avoid burst-charging, but the per-session sessionTitleTimeout is 30s ??? a run with N sessions could take N*30s in the worst case. Worth surfacing a cancel command in /retitle so a user can bail if it's taking too long.
  2. m.titledSessions lives on the model. If the TUI restarts (process restart) the gate resets, so a session that was mid-title at exit can get re-titled on the next turn. Probably fine in practice (the model will just re-generate the same title), but worth noting in case you want a persistent titledAt marker on the metadata.

Nice work on this one ??? the picker UX is much better than the old text blob, and the model-generated titles make /resume actually usable for finding past work.

@gnanam1990
gnanam1990 dismissed anandh8x’s stale review June 17, 2026 07:09

already got two approvals

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
internal/tui/model.go (1)

1489-1499: 💤 Low value

Dead code: scrollableTranscriptView and scrollableTranscriptLayoutView are unused.

Static analysis confirms scrollableTranscriptView is never called. It delegates to scrollableTranscriptLayoutView, which is also only called from here. Both can be removed—the active code path now goes through scrollableTranscriptItemsView.

🧹 Remove dead code
-func (m model) scrollableTranscriptView(header string, body string, footer string, width int, overlay string) string {
-	return m.scrollableTranscriptLayoutView(header, transcriptBodyLayout{lines: viewLines(body)}, footer, width, overlay)
-}
-
-func (m model) scrollableTranscriptLayoutView(header string, body transcriptBodyLayout, footer string, width int, overlay string) string {
-	frame := m.scrollableTranscriptFrame(header, footer)
-	window := transcriptViewportForLayout(body, frame, m.chatScrollOffset).window()
-
-	bodyWindow := body.visibleLines(window)
-	return m.renderScrollableTranscriptWindow(frame, bodyWindow, window, width, overlay)
-}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/model.go` around lines 1489 - 1499, The functions
scrollableTranscriptView and scrollableTranscriptLayoutView are dead code that
are no longer used in the codebase. Remove both of these methods entirely from
the model.go file. The scrollableTranscriptView method delegates to
scrollableTranscriptLayoutView which is only called from
scrollableTranscriptView, and static analysis confirms neither function is
called elsewhere. The active code path now uses scrollableTranscriptItemsView
instead.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/tui/model.go`:
- Around line 1489-1499: The functions scrollableTranscriptView and
scrollableTranscriptLayoutView are dead code that are no longer used in the
codebase. Remove both of these methods entirely from the model.go file. The
scrollableTranscriptView method delegates to scrollableTranscriptLayoutView
which is only called from scrollableTranscriptView, and static analysis confirms
neither function is called elsewhere. The active code path now uses
scrollableTranscriptItemsView instead.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: da2d3e91-272e-40ea-8afb-ce67a4b0e560

📥 Commits

Reviewing files that changed from the base of the PR and between 2d4d127 and b581019.

📒 Files selected for processing (3)
  • internal/tui/command_center.go
  • internal/tui/model.go
  • internal/tui/model_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/tui/command_center.go
  • internal/tui/model_test.go

@gnanam1990
gnanam1990 merged commit 6df5c2b into main Jun 17, 2026
7 checks passed
@Vasanthdev2004
Vasanthdev2004 deleted the feat/resume-session-picker branch June 28, 2026 08:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants