Skip to content

refactor(ui): move session discovery and file I/O to app layer - #105

Merged
ezynda3 merged 3 commits into
masterfrom
feat/101-session-store
Jul 30, 2026
Merged

refactor(ui): move session discovery and file I/O to app layer#105
ezynda3 merged 3 commits into
masterfrom
feat/101-session-store

Conversation

@ezynda3

@ezynda3 ezynda3 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Final step of the #101 refactor. Builds on #102 (terminal capabilities), #103 (sealed event union) and #104 (session snapshots).

Problem

After #104 the UI stopped reaching into the session tree, but three things still tied it to the persistence layer:

  • The session picker listed sessions with session.ListSessions / ListAllSessions, deleted files with session.DeleteSession, and type-asserted on session.SessionInfo in five places. It was the last internal/session import left in internal/ui.
  • /export derived its file name, read the source JSONL and wrote the copy itself.
  • /share read the session file, marshalled a system-prompt entry and spliced it in after the header — i.e. the UI encoded the shared-file layout.

Change

internal/app/session_store.go takes ownership of session discovery and the two file-producing commands:

New app API Replaces
SessionSummary session.SessionInfo in the UI
ListSessions / ListAllSessions / DeleteSession direct internal/session calls
ExportSession(dstPath) (string, int, error) name derivation + os.ReadFile + os.WriteFile in handleExportCommand
WriteShareableSession(systemPrompt, fallbackModelID) os.ReadFile + SessionSystemPromptEntry + buildShareFile in handleShareCommand

ErrSessionNotPersisted joins ErrNoSession as a sentinel, so the UI keeps its distinct "no session" vs "in-memory" wording without inspecting a snapshot field to infer it.

sanitizeFileName and shortSessionID moved with the code that used them — nothing in the UI needs them now.

SessionSystemPromptEntry is folded into WriteShareableSession. It was added in #104 purely as a stepping stone; now that the whole share path lives in the app layer, the UI no longer needs to know that shared files embed a system-prompt entry. It survives as the unexported systemPromptEntry.

Result

$ grep -rn "internal/session" internal/ui/
(no matches)

internal/ui no longer imports internal/session in any file, test or otherwise. AppController grew 5 methods and lost 1; net, the UI's view of the session layer is now entirely value types and sentinel errors.

Design notes

The picker takes a port, not a list. tree_selector in #104 was converted to pure values (NewTreeSelector(roots, ...)), and consistency argued for the same here. I went with a narrow SessionStore interface instead, because unlike the tree selector this component acts: it reloads across scope toggles and deletes files mid-flow. Threading that through the parent model would have meant a request/response message round-trip for delete, which is more coupling than it removes. The port is three methods and *app.App satisfies it incidentally.

Message ordering in /share is preserved deliberately. The snapshot guards stay in the UI ahead of the gh probes, so an in-memory session still reports "not persisted" rather than "gh is not installed". WriteShareableSession re-checks independently.

/export error text changed slightly. Previously "Failed to read session file" / "Failed to write export file"; now one "Failed to export session: …" carrying the wrapped cause, which names the offending path:

Failed to export session: write export file "/tmp/nope/deep/x.jsonl": open /tmp/nope/deep/x.jsonl: no such file or directory

Tests

internal/app/session_store_test.go (28 cases) covers summary projection, listing across working directories, delete guards, export sentinels/name derivation/byte-for-byte copy/unwritable destination, and share splicing — asserting the header stays line 0, the system prompt is line 1, model resolution prefers the session over the fallback, and every original entry survives unchanged. newPersistedApp sandboxes HOME so nothing touches the real session store.

internal/ui/session_selector_test.go (11 cases) exercises the picker against a stub store: load/scope selection, listing-error tolerance, scope toggle, named filter, selection, and the full delete flow including decline and the failure path (entry must survive a failed delete).

Validation

gofmt · go build · go vet · go test -race ./... · golangci-lint — all clean.

Smoke-tested against a seeded session: /resume (both scopes, named filter, delete arm + decline), history replay, /session, /export (derived name, explicit path, unwritable path), /name with a / in it → session_renamed_session_two.jsonl, /tree, /new. Exported file verified byte-identical to source. stderr empty throughout.

Fixes #101

Summary by CodeRabbit

  • New Features
    • Added session browsing by current directory or across all directories.
    • Added session deletion with confirmation.
    • Added session export with automatic filename generation.
    • Added shareable session files that include the applicable system prompt, with reliable temporary-file cleanup.
    • Improved session selection, filtering, and display details.
  • Tests
    • Expanded coverage for session listing, deletion, export/share behaviors, system-prompt splicing, write-failure cleanup, and UI selector scope/filtering error handling.

The session picker was the last part of the TUI that reached into
internal/session directly: it listed sessions, deleted their files and
type-asserted on session.SessionInfo. /export and /share likewise read
and wrote session JSONL themselves, so knowledge of the on-disk format
was spread across the presentation layer.

Session discovery now returns app.SessionSummary values, and the two
file-producing commands are expressed as app-layer operations that
return sentinel errors the UI maps to its own messages. The picker
takes a narrow SessionStore port rather than calling the package
directly, which also makes its scope, filter and delete flows testable
without touching the filesystem.

With this, internal/ui no longer imports internal/session at all and
the session file format is owned entirely by the app layer.

SessionSystemPromptEntry is folded into WriteShareableSession; it was
introduced only as a step towards moving the share path, and the UI no
longer needs to know that shared files embed a system-prompt entry.

Fixes #101
@mark-iii-labs-huly

Copy link
Copy Markdown

Connected to Huly®: KIT-106

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@ezynda3, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 6 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 697400a9-35ce-4791-a1c2-5aa7febefb48

📥 Commits

Reviewing files that changed from the base of the PR and between 07f0c84 and 9adf02a.

📒 Files selected for processing (1)
  • internal/app/session_store_test.go
📝 Walkthrough

Walkthrough

The app layer now owns session listing, deletion, export, and shareable-file creation. The UI delegates these operations through AppController, while the session selector consumes injected summaries and store methods. Tests cover persistence, JSONL transformation, UI selection, filtering, and deletion.

Changes

App session queries and deletion

Layer / File(s) Summary
App session queries and deletion
internal/app/session_store.go, internal/app/session_store_test.go
Adds SessionSummary, app-owned listing and deletion methods, and coverage for metadata, empty results, and deletion errors.

App export and share generation

Layer / File(s) Summary
App export and share generation
internal/app/session_store.go, internal/app/session_store_test.go, internal/app/session_view.go, internal/app/session_view_test.go
Moves export and shareable JSONL assembly into App, including filename handling, system-prompt insertion, fallback model selection, cleanup, and error validation.

UI integration

Layer / File(s) Summary
Controller export and share integration
internal/ui/model.go, internal/ui/model_test.go
Expands AppController, delegates /export and /share to app methods, updates resume wiring, and updates controller stubs.
Injected session selector store
internal/ui/session_selector.go, internal/ui/session_selector_test.go
Injects session listing and deletion through SessionStore, uses app.SessionSummary, and tests scope selection, filtering, selection, and deletion behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • mark3labs/kit#104: Removes the prior SessionSystemPromptEntry dependency and shifts UI session export/share behavior to app-layer session APIs.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant TUI
  participant App
  participant SessionFile
  User->>TUI: Run /export or /share
  TUI->>App: ExportSession or WriteShareableSession
  App->>SessionFile: Read persisted session JSONL
  App->>App: Copy or splice system-prompt entry
  App-->>TUI: Return generated path
  TUI-->>User: Display session file path
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title concisely describes the main refactor: moving session discovery and file I/O from UI into the app layer.
Linked Issues check ✅ Passed The PR implements the linked issue's file-I/O and session-discovery decoupling by moving listing, deletion, export, and share logic into app.
Out of Scope Changes check ✅ Passed The changes stay focused on session ownership, UI decoupling, and related tests; no clear unrelated functionality was introduced.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ 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/101-session-store

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@ezynda3

ezynda3 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@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: 1

🧹 Nitpick comments (2)
internal/app/session_store.go (1)

110-130: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider atomic write for ExportSession to avoid partial files on failure.

os.WriteFile(dstPath, data, 0o644) can leave a truncated/partial file at dstPath if the write fails partway (e.g. disk full). Writing to a temp file in the same directory and renaming into place would make the export atomic.

🤖 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/app/session_store.go` around lines 110 - 130, Update ExportSession
to write the session data to a temporary file in the destination directory,
close and validate the temporary file, then atomically rename it to dstPath;
remove the temporary file on failures and preserve the existing error wrapping
and return values.
internal/ui/session_selector.go (1)

106-135: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider logging swallowed session-listing errors.

store.ListSessions/store.ListAllSessions errors are discarded silently, so a permissions/disk failure looks identical to "no sessions" with zero operator visibility. As per coding guidelines, **/*.go should use github.com/charmbracelet/log for structured logging; a one-line log.Warn when err != nil would preserve today's graceful-degradation UX while giving debuggability.

♻️ Proposed logging addition
 	if cwd != "" {
-		ss.cwdSessions, _ = store.ListSessions(cwd)
+		var err error
+		ss.cwdSessions, err = store.ListSessions(cwd)
+		if err != nil {
+			log.Warn("list sessions for cwd failed", "cwd", cwd, "err", err)
+		}
 		ss.scope = SessionScopeCwd
 	}
-	ss.allSessions, _ = store.ListAllSessions()
+	if all, err := store.ListAllSessions(); err != nil {
+		log.Warn("list all sessions failed", "err", err)
+	} else {
+		ss.allSessions = all
+	}
🤖 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/ui/session_selector.go` around lines 106 - 135, Update
NewSessionSelector to capture errors from both store.ListSessions and
store.ListAllSessions, logging each non-nil error with a one-line structured
charmbracelet/log Warn call while preserving the existing empty-list fallback
and scope behavior.

Source: Coding guidelines

🤖 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/app/session_store.go`:
- Around line 197-240: Preserve the created temp path on all write failures in
writeShareFile so the deferred cleanup can remove it. Replace error returns that
explicitly return an empty path with returns that retain the named tmpPath while
still propagating the wrapped error; keep the successful return and existing
cleanup behavior unchanged. Add a regression test only if the surrounding test
structure supports forcing a write failure and verifying the temporary file is
removed.

---

Nitpick comments:
In `@internal/app/session_store.go`:
- Around line 110-130: Update ExportSession to write the session data to a
temporary file in the destination directory, close and validate the temporary
file, then atomically rename it to dstPath; remove the temporary file on
failures and preserve the existing error wrapping and return values.

In `@internal/ui/session_selector.go`:
- Around line 106-135: Update NewSessionSelector to capture errors from both
store.ListSessions and store.ListAllSessions, logging each non-nil error with a
one-line structured charmbracelet/log Warn call while preserving the existing
empty-list fallback and scope behavior.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2986b841-d88a-4b16-a5fd-93220d83399e

📥 Commits

Reviewing files that changed from the base of the PR and between 37ed878 and 9d721cd.

📒 Files selected for processing (8)
  • internal/app/session_store.go
  • internal/app/session_store_test.go
  • internal/app/session_view.go
  • internal/app/session_view_test.go
  • internal/ui/model.go
  • internal/ui/model_test.go
  • internal/ui/session_selector.go
  • internal/ui/session_selector_test.go
💤 Files with no reviewable changes (2)
  • internal/app/session_view.go
  • internal/app/session_view_test.go

Comment thread internal/app/session_store.go Outdated
- writeShareFile leaked its temp file on every write failure. tmpPath was
  a named result, so `return "", err` zeroed it before the deferred
  cleanup ran and os.Remove("") removed nothing. Split the function so
  the open file is passed to finishShareFile and the path is held in a
  local: the named-result/defer interaction that caused the leak is now
  structurally absent rather than worked around. Close is checked too, so
  a write that only fails on flush is no longer reported as success.

- Log session-listing failures in the picker instead of discarding them;
  a permissions or disk error was indistinguishable from "no sessions".

Skipped: atomic temp-and-rename for ExportSession. os.WriteFile writes
through a symlinked destination whereas rename replaces it, so this
changes behaviour beyond the refactor and is better decided separately.
@ezynda3

ezynda3 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Review follow-up (07f0c84)

Both actionable findings addressed; one nitpick intentionally skipped.

🟠 Major — temp file leak in writeShareFile — fixed, details on the thread. Reproduced standalone, fixed structurally (the file is now passed to finishShareFile so no named-result/defer interaction exists), and covered by a regression test verified to fail against the old code. Close() is now checked as well, which fixes a second latent case where a flush-time failure was reported as success.

🔵 Nitpick — log swallowed listing errors — applied. Verified this is safe here before doing it: cmd/root.go points charmlog.SetOutput at /tmp/kit/kit.log precisely so structured logging can't corrupt the alt-screen, so a log.Warn in the picker is consistent with the guideline rather than a rendering hazard.

🔵 Nitpick — atomic write for ExportSession — skipped deliberately.

The concern is legitimate, but temp-and-rename is not behaviour-preserving: os.WriteFile opens with O_TRUNC and writes through a symlinked destination, whereas rename replaces it. Someone doing /export ~/latest.jsonl where that path is a symlink into a synced folder would silently have their symlink clobbered. That's a real behavioural change, and this PR is a pure move whose contract is that /export keeps doing exactly what it did in the UI.

The blast radius also argues for patience: unlike the session file itself, an export is a user-initiated copy to a user-chosen path — a partial write is immediately visible and retried with one command.

Happy to do it as a follow-up if you want it; it just deserves its own decision about the symlink semantics rather than riding along in a refactor.

@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: 1

Caution

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

⚠️ Outside diff range comments (1)
internal/ui/session_selector.go (1)

65-77: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Propagate context.Context through session-store I/O.

SessionStore.ListSessions, ListAllSessions, and DeleteSession ultimately call filesystem-backed app/session operations (internal/session/store.go), but the injected API and callers provide no cancellation. Add context.Context as the first parameter at each layer: the selector constructor/interface, NewSessionSelector/reloads/deletes, internal/app/session_store.go, the app controller contract/stubs, and test stubs.

🤖 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/ui/session_selector.go` around lines 65 - 77, Propagate
context.Context as the first parameter through the session-store I/O chain:
update SessionStore methods and NewSessionSelector, reload/delete call sites,
internal app session-store functions, the app controller contract and stubs, and
all test stubs. Pass the same context into the underlying filesystem-backed
operations in internal/session/store.go, preserving existing behavior aside from
cancellation support.

Source: Coding guidelines

🤖 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/app/session_store_test.go`:
- Around line 565-567: Update the doc comment above
TestWriteShareableSessionCleansUpAfterCaller to reference the function’s current
name instead of TestWriteShareableSessionLeavesNoTempFilesOnSuccess, without
changing the test behavior.

---

Outside diff comments:
In `@internal/ui/session_selector.go`:
- Around line 65-77: Propagate context.Context as the first parameter through
the session-store I/O chain: update SessionStore methods and NewSessionSelector,
reload/delete call sites, internal app session-store functions, the app
controller contract and stubs, and all test stubs. Pass the same context into
the underlying filesystem-backed operations in internal/session/store.go,
preserving existing behavior aside from cancellation support.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7a8cb8ac-6264-489e-b950-68ffdc4a6381

📥 Commits

Reviewing files that changed from the base of the PR and between 9d721cd and 07f0c84.

📒 Files selected for processing (3)
  • internal/app/session_store.go
  • internal/app/session_store_test.go
  • internal/ui/session_selector.go

Comment thread internal/app/session_store_test.go Outdated
The comment named a function that had been renamed. Renamed the test to
match what it asserts instead: a successful share leaves exactly the
returned file behind, since removing it is the caller's job.
@ezynda3

ezynda3 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@ezynda3

ezynda3 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Re: context.Context through session-store I/O (🟠 Major, outside diff range)

Flagging this one explicitly since it was posted outside the diff and has no thread to reply on.

Skipping the fix as prescribed — but the finding is pointing at something real, and I think it's worth a follow-up issue.

Why not context propagation

The cited guideline (AGENTS.md: "Context: First parameter for blocking operations") does exist, so this isn't baseless. But in the current architecture the parameter would be inert:

  • NewSessionSelector is called synchronously inside Update() (handleResumeCommand returns a nil tea.Cmd). The UI goroutine is blocked inside the listing call, so there is no concurrent goroutine that could ever call cancel().
  • internal/session has zero context.Context in its entire API and does synchronous os.ReadDir/ReadFile. The context would be threaded through five layers to be discarded at the bottom.
  • Every call site would necessarily pass context.Background().

The repo already applies the guideline where it bites — RunOnce, executeStep, and the agent turn path all take ctx, because those have a real canceller (CancelCurrentStep) and real cancellable work (provider HTTP calls). Session listing has neither today. Adding the parameter would advertise cancellation that cannot happen.

What the finding is actually catching

That said, "blocking file I/O on the UI goroutine" deserved a measurement rather than a dismissal, so I took one against my real session store (3,236 sessions / 780 MB):

ListAllSessions: 3236 sessions in 863.500686ms
ListAllSessions: 3236 sessions in 843.727075ms

That is ~850 ms of frozen TUI every time you press /resume. Reproducible, and it scales with session count. So the underlying instinct is correct even though the prescribed remedy wouldn't have helped — context.Background() threaded through would freeze for exactly as long.

Why not in this PR

It's pre-existing and untouched by this refactor — master calls session.ListSessions/ListAllSessions synchronously in the same constructor:

// master, internal/ui/session_selector.go
ss.cwdSessions, _ = session.ListSessions(cwd)
ss.allSessions, _ = session.ListAllSessions()

This PR changed which function is called, not when or on which goroutine. The real fix is to move loading into a tea.Cmd so the picker opens immediately and populates asynchronously — and at that point a context.Context becomes genuinely useful, because Esc could cancel an in-flight scan. But that needs a design call (loading state? incremental population? cache?) and is behaviour change, not refactor.

Happy to pick that up as a separate PR — it'd deliver the responsiveness win and the guideline compliance, in the order that makes both meaningful.

@ezynda3
ezynda3 merged commit 14ce729 into master Jul 30, 2026
3 checks passed
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.

feat: decouple session state from the TUI layer (AppController/TreeManager coupling)

1 participant