Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 83 additions & 24 deletions desktop/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ func (a *App) SubmitDisplayToTab(tabID, display, input string) {
if ctrl == nil {
return
}
_ = recordSessionDisplay(config.SessionDir(), ctrl.SessionPath(), input, display)
_ = recordSessionDisplay(controllerSessionDir(ctrl), ctrl.SessionPath(), input, display)
ctrl.Submit(input)
}

Expand Down Expand Up @@ -611,11 +611,42 @@ type WorkspaceMeta struct {
Current bool `json:"current"`
}

func controllerSessionDir(ctrl *control.Controller) string {
if ctrl != nil {
if dir := ctrl.SessionDir(); dir != "" {
return dir
}
}
return desktopSessionDir("")
}

func tabSessionDir(tab *WorkspaceTab) string {
if tab != nil {
if tab.Ctrl != nil {
if dir := tab.Ctrl.SessionDir(); dir != "" {
return dir
}
}
if tab.WorkspaceRoot != "" {
return desktopSessionDir(tab.WorkspaceRoot)
}
}
return desktopSessionDir("")
}

func (a *App) activeSessionDir() string {
a.mu.RLock()
tab := a.activeTabLocked()
dir := tabSessionDir(tab)
a.mu.RUnlock()
return dir
}

// ListSessions returns the saved sessions newest-first for the history panel,
// marking the one the current conversation is writing to and attaching any
// user-chosen titles.
func (a *App) ListSessions() []SessionMeta {
dir := config.SessionDir()
dir := a.activeSessionDir()
infos, err := agent.ListSessions(dir)
if err != nil {
return []SessionMeta{}
Expand All @@ -633,20 +664,21 @@ func (a *App) ListSessions() []SessionMeta {
// ListTrashedSessions returns sessions that were moved to the local trash,
// newest-deleted first. These can be previewed, restored, or permanently purged.
func (a *App) ListTrashedSessions() []SessionMeta {
dir := config.SessionDir()
paths, err := listTrashedSessionFiles(dir)
if err != nil {
return []SessionMeta{}
}
titles := loadSessionTitles(dir)
out := make([]SessionMeta, 0, len(paths))
for _, path := range paths {
infos, err := agent.ListSessions(filepath.Dir(path))
if err != nil || len(infos) == 0 {
out := []SessionMeta{}
for _, dir := range a.knownSessionDirs() {
paths, err := listTrashedSessionFiles(dir)
if err != nil {
continue
}
deletedAt := trashedSessionDeletedAt(path)
out = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, deletedAt))
titles := loadSessionTitles(dir)
for _, path := range paths {
infos, err := agent.ListSessions(filepath.Dir(path))
if err != nil || len(infos) == 0 {
continue
}
deletedAt := trashedSessionDeletedAt(path)
out = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, deletedAt))
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].DeletedAt == out[j].DeletedAt {
Expand All @@ -657,6 +689,15 @@ func (a *App) ListTrashedSessions() []SessionMeta {
return out
}

func (a *App) trashedSessionDir(path string) (string, error) {
for _, dir := range a.knownSessionDirs() {
if _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {
return dir, nil
}
}
return "", fmt.Errorf("trashed session path outside known session dirs: %s", path)
}

func sessionMetaFromInfo(s agent.SessionInfo, title string, current bool, deletedAt int64) SessionMeta {
return SessionMeta{
Path: s.Path,
Expand All @@ -678,7 +719,7 @@ func sessionMetaFromInfo(s agent.SessionInfo, title string, current bool, delete
// DeleteSession moves a saved session to the local trash. It refuses any open
// session because tab auto-save would recreate or append to the file later.
func (a *App) DeleteSession(path string) error {
dir := config.SessionDir()
dir := a.activeSessionDir()
sessionPath, key, err := validateSessionPath(dir, path)
if err != nil {
return err
Expand Down Expand Up @@ -711,19 +752,27 @@ func (a *App) openSessionPaths(dir string) map[string]struct{} {

// RestoreSession moves a trashed session back into the saved-session list.
func (a *App) RestoreSession(path string) error {
return restoreTrashedSessionFile(config.SessionDir(), path)
dir, err := a.trashedSessionDir(path)
if err != nil {
return err
}
return restoreTrashedSessionFile(dir, path)
}

// PurgeTrashedSession permanently removes a trashed session and its title/display
// sidecars.
func (a *App) PurgeTrashedSession(path string) error {
return purgeTrashedSessionFile(config.SessionDir(), path)
dir, err := a.trashedSessionDir(path)
if err != nil {
return err
}
return purgeTrashedSessionFile(dir, path)
}

// RenameSession sets a custom display name for a session (empty clears it back to
// the preview). It only affects the history panel; the file on disk is unchanged.
func (a *App) RenameSession(path, title string) error {
return setSessionTitle(config.SessionDir(), path, title)
return setSessionTitle(a.activeSessionDir(), path, title)
}

// ResumeSession snapshots the current conversation, then loads the session at
Expand All @@ -743,19 +792,23 @@ func (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error)
if ctrl == nil {
return []HistoryMessage{}, fmt.Errorf("tab is not ready")
}
loaded, err := agent.LoadSession(path)
sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)
if err != nil {
return nil, err
}
loaded, err := agent.LoadSession(sessionPath)
if err != nil {
return nil, err
}
_ = ctrl.Snapshot() // persist the current session before switching away
ctrl.Resume(loaded, path)
ctrl.Resume(loaded, sessionPath)
return a.HistoryForTab(tabID), nil
}

// PreviewSession reads a saved session for display only. It does not snapshot or
// swap the active controller, so the history drawer can call it while a turn runs.
func (a *App) PreviewSession(path string) ([]HistoryMessage, error) {
return previewSessionMessages(config.SessionDir(), path)
return previewSessionMessages(a.activeSessionDir(), path)
}

// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab
Expand Down Expand Up @@ -898,7 +951,7 @@ func (a *App) HistoryForTab(tabID string) []HistoryMessage {
return []HistoryMessage{}
}
msgs := ctrl.History()
return historyMessages(msgs, sessionDisplayResolver(config.SessionDir(), ctrl.SessionPath()))
return historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))
}

func historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {
Expand All @@ -918,11 +971,15 @@ func historyMessages(msgs []provider.Message, resolveUserContent func(string) st
}

func previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {
loaded, err := agent.LoadSession(path)
sessionPath, _, err := validateSessionPath(sessionDir, path)
if err != nil {
return nil, err
}
loaded, err := agent.LoadSession(sessionPath)
if err != nil {
return nil, err
}
return historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, path)), nil
return historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil
}

// ContextInfo is the prompt-vs-window gauge payload. Both zero means no data yet.
Expand Down Expand Up @@ -2246,6 +2303,7 @@ func (a *App) SetModelForTab(tabID, name string) error {
RequireKey: false,
Sink: tab.sink,
WorkspaceRoot: tab.WorkspaceRoot,
SessionDir: tabSessionDir(tab),
EffortOverride: cloneStringPtr(effortOverride),
})
if err != nil {
Expand Down Expand Up @@ -2335,6 +2393,7 @@ func (a *App) SetEffortForTab(tabID, level string) error {
RequireKey: false,
Sink: tab.sink,
WorkspaceRoot: tab.WorkspaceRoot,
SessionDir: tabSessionDir(tab),
EffortOverride: &effort,
})
if err != nil {
Expand Down
93 changes: 93 additions & 0 deletions desktop/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -415,6 +416,98 @@ func TestDeleteSessionRejectsInactiveOpenTab(t *testing.T) {
}
}

func TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {
isolateDesktopUserDirs(t)

dirA := filepath.Join(t.TempDir(), "workspace-a-sessions")
dirB := filepath.Join(t.TempDir(), "workspace-b-sessions")
if err := os.MkdirAll(dirA, 0o755); err != nil {
t.Fatalf("mkdir dirA: %v", err)
}
if err := os.MkdirAll(dirB, 0o755); err != nil {
t.Fatalf("mkdir dirB: %v", err)
}
pathA := filepath.Join(dirA, "a.jsonl")
pathB := filepath.Join(dirB, "b.jsonl")
if err := os.WriteFile(pathA, []byte(`{"role":"user","content":"workspace A"}`+"\n"), 0o644); err != nil {
t.Fatalf("write pathA: %v", err)
}
if err := os.WriteFile(pathB, []byte(`{"role":"user","content":"workspace B"}`+"\n"), 0o644); err != nil {
t.Fatalf("write pathB: %v", err)
}

app := NewApp()
app.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: "test"}), "")
defer app.activeCtrl().Close()

sessions := app.ListSessions()
if len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != "workspace A" {
t.Fatalf("ListSessions should read the active controller session dir only, got %+v", sessions)
}
if err := app.RenameSession(pathA, "A title"); err != nil {
t.Fatalf("RenameSession in active session dir: %v", err)
}
if titles := loadSessionTitles(dirA); titles["a.jsonl"] != "A title" {
t.Fatalf("title should be written beside the active session, got %+v", titles)
}
if titles := loadSessionTitles(dirB); len(titles) != 0 {
t.Fatalf("inactive workspace title sidecar should remain untouched, got %+v", titles)
}
}

func TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {
dirA := t.TempDir()
dirB := t.TempDir()
activePath := filepath.Join(dirA, "active.jsonl")
outsidePath := filepath.Join(dirB, "outside.jsonl")
for _, path := range []string{activePath, outsidePath} {
if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}

app := NewApp()
app.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: "test"}), "")
defer app.activeCtrl().Close()

if _, err := app.ResumeSession(outsidePath); err == nil {
t.Fatal("ResumeSession should reject a transcript outside the active session dir")
}
if _, err := app.PreviewSession(outsidePath); err == nil {
t.Fatal("PreviewSession should reject a transcript outside the active session dir")
}
}

func BenchmarkDesktopListSessionsScoped(b *testing.B) {
dirA := filepath.Join(b.TempDir(), "workspace-a-sessions")
dirB := filepath.Join(b.TempDir(), "workspace-b-sessions")
for _, dir := range []string{dirA, dirB} {
if err := os.MkdirAll(dir, 0o755); err != nil {
b.Fatalf("mkdir %s: %v", dir, err)
}
for i := 0; i < 120; i++ {
path := filepath.Join(dir, fmt.Sprintf("session-%03d.jsonl", i))
body := fmt.Sprintf(`{"role":"user","content":"session %03d"}`+"\n", i)
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
b.Fatalf("write session: %v", err)
}
}
}

app := NewApp()
app.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, "session-000.jsonl"), Label: "test"}), "")
defer app.activeCtrl().Close()

b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
sessions := app.ListSessions()
if len(sessions) != 120 {
b.Fatalf("ListSessions len = %d, want 120", len(sessions))
}
}
}

type appendingDesktopRunner struct {
session *agent.Session
started chan string
Expand Down
24 changes: 24 additions & 0 deletions desktop/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strings"
"time"

"reasonix/internal/config"
"reasonix/internal/fileutil"
)

Expand All @@ -32,6 +33,29 @@ func sessionTitlesPath(dir string) string { return filepath.Join(dir, sessionTi
func sessionDisplayPath(dir string) string { return filepath.Join(dir, sessionDisplayFile) }
func sessionTrashPath(dir string) string { return filepath.Join(dir, sessionTrashDir) }

func desktopSessionDir(root string) string {
base := config.MemoryUserDir()
if base == "" {
return config.SessionDir()
}
root = strings.TrimSpace(root)
if root == "" {
cwd, err := os.Getwd()
if err != nil {
return config.SessionDir()
}
root = cwd
}
if abs, err := filepath.Abs(root); err == nil {
root = abs
}
return filepath.Join(base, "projects", desktopWorkspaceSlug(root), "sessions")
}

func desktopWorkspaceSlug(absPath string) string {
return strings.NewReplacer(string(os.PathSeparator), "-", "/", "-", "\\", "-", ":", "-").Replace(absPath)
}

// loadSessionTitles reads the basename→title map (missing/corrupt → empty).
func loadSessionTitles(dir string) map[string]string {
m := map[string]string{}
Expand Down
1 change: 1 addition & 0 deletions desktop/settings_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ func (a *App) rebuild() error {
Model: model, RequireKey: false,
Sink: tab.sink,
WorkspaceRoot: tab.WorkspaceRoot,
SessionDir: tabSessionDir(tab),
EffortOverride: cloneStringPtr(tab.effort),
})
if err != nil {
Expand Down
Loading
Loading