package main

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"sync"
	"testing"
	"time"

	"reasonix/internal/agent"
	"reasonix/internal/config"
	"reasonix/internal/control"
)

type runtimeStatusSessionController struct {
	stubSessionAPI
	status control.RuntimeStatus
}

func (c *runtimeStatusSessionController) RuntimeStatus() control.RuntimeStatus {
	return c.status
}

func waitForTabReady(t *testing.T, app *App, tabID string) *WorkspaceTab {
	t.Helper()
	deadline := time.Now().Add(5 * time.Second)
	for time.Now().Before(deadline) {
		app.mu.RLock()
		tab := app.tabs[tabID]
		ready := tab != nil && tab.Ready
		startupErr := ""
		if tab != nil {
			startupErr = tab.StartupErr
		}
		app.mu.RUnlock()
		if tab == nil {
			t.Fatalf("tab %q was not found", tabID)
		}
		if ready {
			if startupErr != "" {
				t.Fatalf("tab %q startup error: %s", tabID, startupErr)
			}
			if tab.Ctrl != nil {
				t.Cleanup(func() { tab.Ctrl.Close() })
			}
			return tab
		}
		time.Sleep(10 * time.Millisecond)
	}
	t.Fatalf("tab %q was not ready before timeout", tabID)
	return nil
}

func waitForTopicDirMarker(t *testing.T, dir, marker string) {
	t.Helper()
	markerPath := filepath.Join(dir, marker)
	deadline := time.Now().Add(5 * time.Second)
	var last error
	for {
		if _, last = os.Stat(markerPath); last == nil {
			return
		}
		if time.Now().After(deadline) {
			t.Fatalf("expected %s after migration: %v", marker, last)
		}
		time.Sleep(10 * time.Millisecond)
	}
}

func waitForCatalogTopic(t *testing.T, app *App, scope, workspaceRoot, topicID string) []ProjectNode {
	t.Helper()
	app.startSessionCatalog()
	t.Cleanup(func() { app.stopSessionCatalog(time.Second) })
	deadline := time.Now().Add(5 * time.Second)
	for time.Now().Before(deadline) {
		nodes := app.ListProjectTree()
		for _, folder := range nodes {
			if scope == "project" && (!sameProjectRoot(folder.Root, workspaceRoot) || folder.Kind != "project") {
				continue
			}
			if scope != "project" && folder.Kind != "global_folder" {
				continue
			}
			for _, topic := range folder.Children {
				if topic.TopicID == topicID {
					return nodes
				}
			}
		}
		time.Sleep(10 * time.Millisecond)
	}
	t.Fatalf("catalog topic %q did not become visible", topicID)
	return nil
}

func waitForCatalogTreeCondition(t *testing.T, app *App, description string, matches func([]ProjectNode) bool) []ProjectNode {
	t.Helper()
	deadline := time.Now().Add(5 * time.Second)
	var nodes []ProjectNode
	for time.Now().Before(deadline) {
		nodes = app.ListProjectTree()
		if matches(nodes) {
			return nodes
		}
		time.Sleep(10 * time.Millisecond)
	}
	t.Fatalf("catalog did not reach %s: %#v", description, nodes)
	return nil
}

func writeTopicSession(t *testing.T, dir, name, topicID, topicTitle, workspaceRoot string) string {
	t.Helper()
	path := filepath.Join(dir, name)
	if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
		t.Fatalf("write session: %v", err)
	}
	if err := agent.SaveBranchMeta(path, agent.BranchMeta{
		CreatedAt:     time.Now().Add(-time.Minute),
		UpdatedAt:     time.Now(),
		Scope:         "project",
		WorkspaceRoot: workspaceRoot,
		TopicID:       topicID,
		TopicTitle:    topicTitle,
	}); err != nil {
		t.Fatalf("save branch meta: %v", err)
	}
	return path
}

func writeTopicSessionWithPrompt(t *testing.T, dir, name, topicID, topicTitle, workspaceRoot, prompt string, updatedAt time.Time) string {
	t.Helper()
	path := filepath.Join(dir, name)
	if err := os.WriteFile(path, []byte(`{"role":"user","content":`+strconv.Quote(prompt)+`}`+"\n"), 0o644); err != nil {
		t.Fatalf("write session: %v", err)
	}
	scope := "global"
	if strings.TrimSpace(workspaceRoot) != "" {
		scope = "project"
	}
	if err := agent.SaveBranchMetaPreserveUpdated(path, agent.BranchMeta{
		CreatedAt:     updatedAt.Add(-time.Minute),
		UpdatedAt:     updatedAt,
		Scope:         scope,
		WorkspaceRoot: workspaceRoot,
		TopicID:       topicID,
		TopicTitle:    topicTitle,
	}); err != nil {
		t.Fatalf("save branch meta: %v", err)
	}
	return path
}

func writeLegacySession(t *testing.T, dir, name, prompt string, modTime time.Time) string {
	t.Helper()
	path := filepath.Join(dir, name)
	if err := os.WriteFile(path, []byte(`{"role":"user","content":`+strconv.Quote(prompt)+`}`+"\n"), 0o644); err != nil {
		t.Fatalf("write legacy session: %v", err)
	}
	if err := os.Chtimes(path, modTime, modTime); err != nil {
		t.Fatalf("chtimes legacy session: %v", err)
	}
	return path
}

func writeLegacyEventSession(t *testing.T, dir, name, prompt, reply string, modTime time.Time) string {
	t.Helper()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir legacy sessions: %v", err)
	}
	path := filepath.Join(dir, name)
	body := `{"type":"user.message","id":1,"ts":"t","turn":0,"text":` + strconv.Quote(prompt) + `}` + "\n" +
		`{"type":"model.final","id":2,"ts":"t","turn":0,"content":` + strconv.Quote(reply) + `,"toolCalls":[],"usage":{},"costUsd":0}` + "\n"
	if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
		t.Fatalf("write legacy event session: %v", err)
	}
	if err := os.Chtimes(path, modTime, modTime); err != nil {
		t.Fatalf("chtimes legacy event session: %v", err)
	}
	return path
}

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

	projectRoot := t.TempDir()
	if err := saveTopicTitles(projectRoot, map[string]string{"old": "Old"}); err != nil {
		t.Fatalf("save old title: %v", err)
	}
	if err := saveTopicTitleSources(projectRoot, map[string]string{"old": topicTitleSourceManual}); err != nil {
		t.Fatalf("save old source: %v", err)
	}
	if err := saveTopicCreatedAts(projectRoot, map[string]int64{"old": 100}); err != nil {
		t.Fatalf("save old created-at: %v", err)
	}

	release := occupyReadFileWithTimeoutSlots(t)
	if err := setTopicTitleWithSource(projectRoot, "new", "New", topicTitleSourceAuto); err != nil {
		t.Fatalf("setTopicTitleWithSource: %v", err)
	}
	if err := setTopicCreatedAt(projectRoot, "new", 200); err != nil {
		t.Fatalf("setTopicCreatedAt: %v", err)
	}
	release()

	titles := loadTopicTitles(projectRoot)
	if got := titles["old"]; got != "Old" {
		t.Fatalf("old title = %q, want Old (all titles: %v)", got, titles)
	}
	if got := titles["new"]; got != "New" {
		t.Fatalf("new title = %q, want New (all titles: %v)", got, titles)
	}
	sources := loadTopicTitleSources(projectRoot)
	if got := sources["old"]; got != topicTitleSourceManual {
		t.Fatalf("old source = %q, want %q (all sources: %v)", got, topicTitleSourceManual, sources)
	}
	if got := sources["new"]; got != topicTitleSourceAuto {
		t.Fatalf("new source = %q, want %q (all sources: %v)", got, topicTitleSourceAuto, sources)
	}
	created := loadTopicCreatedAts(projectRoot)
	if got := created["old"]; got != 100 {
		t.Fatalf("old created-at = %d, want 100 (all created: %v)", got, created)
	}
	if got := created["new"]; got != 200 {
		t.Fatalf("new created-at = %d, want 200 (all created: %v)", got, created)
	}
}

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

	projectRoot := t.TempDir()
	topicID := "topic_keep_history"
	if err := addProject(projectRoot, ""); err != nil {
		t.Fatalf("add project: %v", err)
	}
	if err := setTopicTitle(projectRoot, topicID, "Keep history"); err != nil {
		t.Fatalf("set topic title: %v", err)
	}
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	sessionPath := writeTopicSession(t, dir, "keep.jsonl", topicID, "Keep history", projectRoot)

	if err := NewApp().DeleteTopic(topicID); err != nil {
		t.Fatalf("delete topic: %v", err)
	}
	if _, err := os.Stat(sessionPath); err != nil {
		t.Fatalf("delete topic should keep session history: %v", err)
	}
	if got := loadTopicTitle(projectRoot, topicID); got != "" {
		t.Fatalf("topic title should be removed, got %q", got)
	}
}

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

	projectRoot := t.TempDir()
	if err := addProject(projectRoot, ""); err != nil {
		t.Fatalf("add project: %v", err)
	}
	if err := setTopicTitle(projectRoot, "topic_a", "Alpha"); err != nil {
		t.Fatalf("set topic a title: %v", err)
	}
	if err := setTopicTitle(projectRoot, "topic_b", "Beta"); err != nil {
		t.Fatalf("set topic b title: %v", err)
	}
	app := NewApp()
	nodes := app.ListProjectTree()
	if got := []string{nodes[0].Children[0].TopicID, nodes[0].Children[1].TopicID}; got[0] != "topic_a" || got[1] != "topic_b" {
		t.Fatalf("initial topic order = %v, want [topic_a topic_b]", got)
	}

	if err := app.SetTopicPinned("topic_b", true); err != nil {
		t.Fatalf("pin topic: %v", err)
	}
	nodes = app.ListProjectTree()
	if got := []string{nodes[0].Children[0].TopicID, nodes[0].Children[1].TopicID}; got[0] != "topic_b" || got[1] != "topic_a" {
		t.Fatalf("pinned topic order = %v, want [topic_b topic_a]", got)
	}
	if !nodes[0].Children[0].Pinned {
		t.Fatalf("pinned topic should expose pinned=true")
	}

	if err := app.SetTopicPinned("topic_b", false); err != nil {
		t.Fatalf("unpin topic: %v", err)
	}
	nodes = app.ListProjectTree()
	if nodes[0].Children[0].Pinned || nodes[0].Children[1].Pinned {
		t.Fatalf("unpin should clear pinned flags: %#v", nodes[0].Children)
	}
}

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

	first := t.TempDir()
	second := t.TempDir()
	third := t.TempDir()
	if err := addProject(first, "First"); err != nil {
		t.Fatalf("add first project: %v", err)
	}
	if err := addProject(second, "Second"); err != nil {
		t.Fatalf("add second project: %v", err)
	}
	if err := addProject(third, "Third"); err != nil {
		t.Fatalf("add third project: %v", err)
	}

	app := NewApp()
	if err := app.ReorderProjects([]string{third, first, second}); err != nil {
		t.Fatalf("ReorderProjects: %v", err)
	}
	if err := app.SetProjectPinned(second, true); err != nil {
		t.Fatalf("pin project: %v", err)
	}
	nodes := app.ListProjectTree()
	if got := []string{nodes[0].Root, nodes[1].Root, nodes[2].Root}; got[0] != second || got[1] != third || got[2] != first {
		t.Fatalf("pinned project order = %v, want %v", got, []string{second, third, first})
	}
	if !nodes[0].Pinned {
		t.Fatalf("pinned project should expose pinned=true")
	}

	if err := app.SetProjectPinned(second, false); err != nil {
		t.Fatalf("unpin project: %v", err)
	}
	nodes = app.ListProjectTree()
	if got := []string{nodes[0].Root, nodes[1].Root, nodes[2].Root}; got[0] != third || got[1] != first || got[2] != second {
		t.Fatalf("unpinned project order = %v, want %v", got, []string{third, first, second})
	}
	if nodes[0].Pinned || nodes[1].Pinned || nodes[2].Pinned {
		t.Fatalf("unpin should clear pinned flags: %#v", nodes)
	}
}

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

	projectRoot := t.TempDir()
	if err := addProject(projectRoot, ""); err != nil {
		t.Fatalf("add project: %v", err)
	}
	if err := setTopicTitle(projectRoot, "topic_pinned_delete", "Pinned"); err != nil {
		t.Fatalf("set topic title: %v", err)
	}
	app := NewApp()
	if err := app.SetTopicPinned("topic_pinned_delete", true); err != nil {
		t.Fatalf("pin topic: %v", err)
	}
	if err := app.DeleteTopic("topic_pinned_delete"); err != nil {
		t.Fatalf("delete topic: %v", err)
	}
	projects := loadProjectsFile().Projects
	if len(projects) != 1 {
		t.Fatalf("projects len = %d, want 1", len(projects))
	}
	if got := projects[0].PinnedTopics; len(got) != 0 {
		t.Fatalf("pinned topics after delete = %v, want empty", got)
	}
}

func assertTopicFullyDeleted(t *testing.T, projectRoot, topicID string) {
	t.Helper()
	if got := loadTopicTitle(projectRoot, topicID); got != "" {
		t.Fatalf("topic title = %q, want deleted", got)
	}
	if got := loadTopicTitleSources(projectRoot); got[topicID] != "" {
		t.Fatalf("title source = %q, want deleted (all sources: %v)", got[topicID], got)
	}
	if got := loadTopicCreatedAts(projectRoot); got[topicID] != 0 {
		t.Fatalf("created-at = %d, want deleted (all created: %v)", got[topicID], got)
	}
	if got := loadTopicAutoTitleMeta(projectRoot); got != nil {
		if meta, ok := got[topicID]; ok {
			t.Fatalf("auto-title meta = %+v, want deleted (all auto-title meta: %v)", meta, got)
		}
	}
	f := loadProjectsFile()
	i := projectIndexByRoot(f.Projects, projectRoot)
	if i < 0 {
		t.Fatalf("projects = %#v, want entry for %q", f.Projects, projectRoot)
	}
	if containsDesktopString(f.Projects[i].Topics, topicID) {
		t.Fatalf("project topics = %#v, %q should be removed", f.Projects[i].Topics, topicID)
	}
	if !containsDesktopString(f.DeletedTopics, topicID) {
		t.Fatalf("deletedTopics = %#v, want tombstone for %q", f.DeletedTopics, topicID)
	}
}

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

	projectRoot := t.TempDir()
	seedLegacyTopicBridge(t, projectRoot)
	topicID := "topic_partial_delete"
	if err := addProject(projectRoot, ""); err != nil {
		t.Fatalf("add project: %v", err)
	}
	if err := setTopicTitle(projectRoot, topicID, "Doomed"); err != nil {
		t.Fatalf("set topic title: %v", err)
	}
	if err := setTopicCreatedAt(projectRoot, topicID, 4242); err != nil {
		t.Fatalf("set created-at: %v", err)
	}
	if err := prependTopicInProjectsFile(projectRoot, topicID, false); err != nil {
		t.Fatalf("index topic: %v", err)
	}

	// Block the legacy source mirror while the title locator is still intact.
	sourcesPath := topicTitleSourcesPath(projectRoot)
	backupPath := sourcesPath + ".bak"
	if err := os.Rename(sourcesPath, backupPath); err != nil {
		t.Fatalf("stash title sources: %v", err)
	}
	if err := os.Mkdir(sourcesPath, 0o755); err != nil {
		t.Fatalf("block title sources: %v", err)
	}

	app := NewApp()
	if err := app.DeleteTopic(topicID); err == nil {
		t.Fatalf("delete with failing title-sources load should report an error")
	}
	if got := loadTopicTitle(projectRoot, topicID); got != "Doomed" {
		t.Fatalf("failed attempt must keep the title as the root locator, got %q", got)
	}

	// Heal the fault and retry: the retry must finish the remaining cleanup
	// instead of treating a partially-deleted topic as an already-finished
	// deletion.
	if err := os.Remove(sourcesPath); err != nil {
		t.Fatalf("unblock title sources: %v", err)
	}
	if err := os.Rename(backupPath, sourcesPath); err != nil {
		t.Fatalf("restore title sources: %v", err)
	}
	if err := app.DeleteTopic(topicID); err != nil {
		t.Fatalf("retry delete: %v", err)
	}
	assertTopicFullyDeleted(t, projectRoot, topicID)
}

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

	projectRoot := t.TempDir()
	topicID := "topic_leftover_delete"
	if err := addProject(projectRoot, ""); err != nil {
		t.Fatalf("add project: %v", err)
	}
	if err := setTopicTitle(projectRoot, topicID, "Doomed"); err != nil {
		t.Fatalf("set topic title: %v", err)
	}
	if err := setTopicCreatedAt(projectRoot, topicID, 4242); err != nil {
		t.Fatalf("set created-at: %v", err)
	}
	if err := prependTopicInProjectsFile(projectRoot, topicID, false); err != nil {
		t.Fatalf("index topic: %v", err)
	}
	// Strip just the title entry to mimic an interrupted earlier deletion:
	// sources, created-at, and the sidebar index survived it.
	titles, err := loadTopicTitlesForUpdate(projectRoot)
	if err != nil {
		t.Fatalf("load titles: %v", err)
	}
	delete(titles, topicID)
	if err := saveTopicTitles(projectRoot, titles); err != nil {
		t.Fatalf("save titles: %v", err)
	}

	if err := NewApp().DeleteTopic(topicID); err != nil {
		t.Fatalf("delete topic: %v", err)
	}
	assertTopicFullyDeleted(t, projectRoot, topicID)
}

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

	projectRoot := t.TempDir()
	seedLegacyTopicBridge(t, projectRoot)
	topicID := "topic_title_only_delete"
	if err := addProject(projectRoot, ""); err != nil {
		t.Fatalf("add project: %v", err)
	}
	// Without a sidebar index, the title is the retry's only locator.
	if err := setTopicTitle(projectRoot, topicID, "Doomed"); err != nil {
		t.Fatalf("set topic title: %v", err)
	}
	if err := setTopicCreatedAt(projectRoot, topicID, 4242); err != nil {
		t.Fatalf("set created-at: %v", err)
	}

	sourcesPath := topicTitleSourcesPath(projectRoot)
	backupPath := sourcesPath + ".bak"
	if err := os.Rename(sourcesPath, backupPath); err != nil {
		t.Fatalf("stash title sources: %v", err)
	}
	if err := os.Mkdir(sourcesPath, 0o755); err != nil {
		t.Fatalf("block title sources: %v", err)
	}

	app := NewApp()
	if err := app.DeleteTopic(topicID); err == nil {
		t.Fatalf("delete with failing title-sources load should report an error")
	}
	if got := loadTopicTitle(projectRoot, topicID); got != "Doomed" {
		t.Fatalf("failed attempt must keep the title as the root locator, got %q", got)
	}

	if err := os.Remove(sourcesPath); err != nil {
		t.Fatalf("unblock title sources: %v", err)
	}
	if err := os.Rename(backupPath, sourcesPath); err != nil {
		t.Fatalf("restore title sources: %v", err)
	}
	if err := app.DeleteTopic(topicID); err != nil {
		t.Fatalf("retry delete: %v", err)
	}
	assertTopicFullyDeleted(t, projectRoot, topicID)
}

func TestDeleteTopicTitleOnlyRetryAfterSecondaryMetadataFailureCompletesCleanup(t *testing.T) {
	tests := []struct {
		name string
		path func(string) string
	}{
		{name: "created-at", path: topicCreatedAtsPath},
		{name: "auto-title-meta", path: topicAutoTitleMetaPath},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			isolateDesktopUserDirs(t)

			projectRoot := t.TempDir()
			seedLegacyTopicBridge(t, projectRoot)
			topicID := "topic_title_only_" + strings.ReplaceAll(tt.name, "-", "_")
			if err := addProject(projectRoot, ""); err != nil {
				t.Fatalf("add project: %v", err)
			}
			if err := setTopicTitle(projectRoot, topicID, "Doomed"); err != nil {
				t.Fatalf("set topic title: %v", err)
			}
			if err := setTopicCreatedAt(projectRoot, topicID, 4242); err != nil {
				t.Fatalf("set created-at: %v", err)
			}
			if err := recordTopicAutoTitleMeta(projectRoot, topicID, autoTopicTitleProposal{
				Stage: 1, UserTurns: 1, BasisHash: "review-basis",
			}); err != nil {
				t.Fatalf("record auto-title meta: %v", err)
			}

			blockedPath := tt.path(projectRoot)
			backupPath := blockedPath + ".bak"
			if err := os.Rename(blockedPath, backupPath); err != nil {
				t.Fatalf("stash %s: %v", tt.name, err)
			}
			if err := os.Mkdir(blockedPath, 0o755); err != nil {
				t.Fatalf("block %s: %v", tt.name, err)
			}

			app := NewApp()
			if err := app.DeleteTopic(topicID); err == nil {
				t.Fatalf("delete with failing %s cleanup should report an error", tt.name)
			}
			if got := loadTopicTitle(projectRoot, topicID); got != "Doomed" {
				t.Fatalf("failed attempt must keep the title as the root locator, got %q", got)
			}

			if err := os.Remove(blockedPath); err != nil {
				t.Fatalf("unblock %s: %v", tt.name, err)
			}
			if err := os.Rename(backupPath, blockedPath); err != nil {
				t.Fatalf("restore %s: %v", tt.name, err)
			}
			if err := app.DeleteTopic(topicID); err != nil {
				t.Fatalf("retry delete after %s failure: %v", tt.name, err)
			}
			assertTopicFullyDeleted(t, projectRoot, topicID)
		})
	}
}

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

	// The broken project is added first so the cleanup sweep meets it before
	// reaching the target root.
	brokenRoot := t.TempDir()
	targetRoot := t.TempDir()
	seedLegacyTopicBridge(t, brokenRoot)
	topicID := "topic_target_delete"
	if err := addProject(brokenRoot, ""); err != nil {
		t.Fatalf("add broken project: %v", err)
	}
	if err := addProject(targetRoot, ""); err != nil {
		t.Fatalf("add target project: %v", err)
	}
	if err := setTopicTitle(brokenRoot, "topic_unrelated", "Unrelated"); err != nil {
		t.Fatalf("set unrelated topic title: %v", err)
	}
	if err := setTopicTitle(targetRoot, topicID, "Doomed"); err != nil {
		t.Fatalf("set topic title: %v", err)
	}
	if err := setTopicCreatedAt(targetRoot, topicID, 4242); err != nil {
		t.Fatalf("set created-at: %v", err)
	}
	if err := prependTopicInProjectsFile(targetRoot, topicID, false); err != nil {
		t.Fatalf("index topic: %v", err)
	}

	// Unrelated unreadable legacy metadata must not abort target deletion.
	for _, path := range []string{topicTitlesPath(brokenRoot), topicTitleSourcesPath(brokenRoot)} {
		if err := os.Remove(path); err != nil {
			t.Fatalf("remove %s: %v", path, err)
		}
		if err := os.Mkdir(path, 0o755); err != nil {
			t.Fatalf("block %s: %v", path, err)
		}
	}

	if err := NewApp().DeleteTopic(topicID); err != nil {
		t.Fatalf("delete topic with unrelated broken project: %v", err)
	}
	assertTopicFullyDeleted(t, targetRoot, topicID)

	f := loadProjectsFile()
	if i := projectIndexByRoot(f.Projects, brokenRoot); i < 0 {
		t.Fatalf("projects = %#v, want entry for broken root", f.Projects)
	}
	if containsDesktopString(f.DeletedTopics, "topic_unrelated") {
		t.Fatalf("deletedTopics = %#v, unrelated topic must not be tombstoned", f.DeletedTopics)
	}
}

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

	projectRoot := t.TempDir()
	if err := addProject(projectRoot, ""); err != nil {
		t.Fatalf("add project: %v", err)
	}
	if err := NewApp().RenameProject(projectRoot, "Client API"); err != nil {
		t.Fatalf("rename project: %v", err)
	}

	nodes := NewApp().ListProjectTree()
	if len(nodes) != 1 {
		t.Fatalf("project tree len = %d, want 1", len(nodes))
	}
	if got := nodes[0].Label; got != "Client API" {
		t.Fatalf("project label = %q, want Client API", got)
	}

	if err := NewApp().RenameProject(projectRoot, ""); err != nil {
		t.Fatalf("clear project title: %v", err)
	}
	nodes = NewApp().ListProjectTree()
	if got, want := nodes[0].Label, filepath.Base(projectRoot); got != want {
		t.Fatalf("cleared project label = %q, want %q", got, want)
	}
}

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

	projectRoot := t.TempDir()
	if err := addProject(projectRoot, "Client API"); err != nil {
		t.Fatalf("add project: %v", err)
	}

	workspaces := NewApp().ListWorkspaces()
	if len(workspaces) != 1 {
		t.Fatalf("workspaces len = %d, want 1: %+v", len(workspaces), workspaces)
	}
	if got := workspaces[0].Path; got != projectRoot {
		t.Fatalf("workspace path = %q, want %q", got, projectRoot)
	}
	if got := workspaces[0].Name; got != "Client API" {
		t.Fatalf("workspace name = %q, want Client API", got)
	}
}

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

	legacyRoot := t.TempDir()
	rememberWorkspace(legacyRoot)

	workspaces := NewApp().ListWorkspaces()
	if len(workspaces) != 1 {
		t.Fatalf("workspaces len = %d, want 1: %+v", len(workspaces), workspaces)
	}
	if got := workspaces[0].Path; got != legacyRoot {
		t.Fatalf("workspace path = %q, want %q", got, legacyRoot)
	}
	projects := loadProjectsFile().Projects
	if len(projects) != 1 || projects[0].Root != legacyRoot {
		t.Fatalf("legacy workspace was not migrated into projects: %+v", projects)
	}
}

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

	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	older := writeLegacySession(t, dir, "older.jsonl", "older imported prompt", time.Now().Add(-2*time.Hour))
	newer := writeLegacySession(t, dir, "newer.jsonl", "newer imported prompt", time.Now().Add(-time.Hour))

	app := NewApp()
	nodes := waitForCatalogTopic(t, app, "global", "", legacySessionTopicID(newer))
	if len(nodes) != 1 || nodes[0].Kind != "global_folder" {
		t.Fatalf("project tree = %#v, want global folder", nodes)
	}
	if got := len(nodes[0].Children); got != 2 {
		t.Fatalf("global migrated topics = %d, want 2: %#v", got, nodes[0].Children)
	}
	if got, want := nodes[0].Children[0].TopicID, legacySessionTopicID(newer); got != want {
		t.Fatalf("newest topic first = %q, want %q", got, want)
	}
	if got, want := nodes[0].Children[1].TopicID, legacySessionTopicID(older); got != want {
		t.Fatalf("older topic second = %q, want %q", got, want)
	}

	meta, ok, err := agent.LoadBranchMeta(newer)
	if err != nil || !ok {
		t.Fatalf("load migrated meta: ok=%v err=%v", ok, err)
	}
	if meta.Scope != "global" || meta.WorkspaceRoot != "" || meta.TopicID != legacySessionTopicID(newer) {
		t.Fatalf("migrated meta = %+v", meta)
	}

	nodes = app.ListProjectTree()
	if got := len(nodes[0].Children); got != 2 {
		t.Fatalf("migration should be idempotent, global topics = %d", got)
	}
}

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

	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	normal := writeLegacySession(t, dir, "normal.jsonl", "normal imported prompt", time.Now().Add(-2*time.Hour))
	recovery := writeLegacySession(t, dir, "normal-recovery-0123456789abcdef.jsonl", "legacy recovery prompt", time.Now().Add(-time.Hour))
	// Simulate an upgrade from the filename-only classifier: the v1 marker
	// must not prevent the new conservative pass from recovering this history.
	if err := os.WriteFile(filepath.Join(dir, ".topics-migrated"), nil, 0o644); err != nil {
		t.Fatalf("write v1 migration marker: %v", err)
	}

	app := NewApp()
	// Filename recovery folds into the root ordinary row; History keeps the
	// physical recovery file reachable as another saved version.
	app.startSessionCatalog()
	t.Cleanup(func() { app.stopSessionCatalog(time.Second) })
	nodes := waitForCatalogTreeCondition(t, app, "filename recovery folded into one ordinary row", func(nodes []ProjectNode) bool {
		for _, folder := range nodes {
			if folder.Kind != "global_folder" {
				continue
			}
			if len(folder.Children) != 1 {
				return false
			}
			return folder.Children[0].TopicID == legacySessionTopicID(normal)
		}
		return false
	})
	if len(nodes) != 1 || nodes[0].Kind != "global_folder" {
		t.Fatalf("project tree = %#v, want global folder", nodes)
	}
	if got := len(nodes[0].Children); got != 1 {
		t.Fatalf("global ordinary topics = %d, want 1 folded conversation: %#v", got, nodes[0].Children)
	}
	if _, err := os.Stat(recovery); err != nil {
		t.Fatalf("physical recovery file must remain on disk: %v", err)
	}
	if meta, ok, err := agent.LoadBranchMeta(recovery); err != nil {
		t.Fatalf("load recovery meta: %v", err)
	} else if ok && strings.TrimSpace(meta.TopicID) == "" && !agent.LooksLikeRecoveryFilename(recovery) {
		t.Fatal("legacy recovery branch lost both meta topic and filename lineage")
	}
}

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

	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	parent, recovery, branchMsgs := forkDesktopRecoveryBranch(t, dir, "normal")
	coverDesktopRecoveryParent(t, parent, branchMsgs)

	app := NewApp()
	nodes := waitForCatalogTopic(t, app, "global", "", legacySessionTopicID(parent))
	if len(nodes) != 1 || len(nodes[0].Children) != 1 {
		t.Fatalf("project tree = %#v, want only the covering parent topic", nodes)
	}
	if meta, ok, err := agent.LoadBranchMeta(recovery); err != nil || !ok {
		t.Fatalf("load recovery meta: ok=%v err=%v", ok, err)
	} else if strings.TrimSpace(meta.TopicID) != "" {
		t.Fatalf("parent-covered recovery copy was migrated into topic %q", meta.TopicID)
	}
}

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

	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	parent, recovery, branchMsgs := forkDesktopRecoveryBranch(t, dir, "parent-delete")
	coverDesktopRecoveryParent(t, parent, branchMsgs)
	app := NewApp()

	waitForCatalogTopic(t, app, "global", "", legacySessionTopicID(parent))
	waitForTopicDirMarker(t, dir, topicMigrationMarker)
	waitForTopicDirMarker(t, dir, topicIndexRepairMarker)
	if meta, ok, err := agent.LoadBranchMeta(recovery); err != nil || !ok {
		t.Fatalf("load skipped recovery meta: ok=%v err=%v", ok, err)
	} else if strings.TrimSpace(meta.TopicID) != "" {
		t.Fatalf("covered recovery copy was migrated before parent deletion: %+v", meta)
	}

	if err := app.DeleteSession(parent); err != nil {
		t.Fatalf("DeleteSession parent: %v", err)
	}
	for _, marker := range []string{topicMigrationMarker, topicIndexRepairMarker} {
		if _, err := os.Stat(filepath.Join(dir, marker)); !os.IsNotExist(err) {
			t.Fatalf("%s survived live session deletion: %v", marker, err)
		}
	}

	nodes := waitForCatalogTopic(t, app, "global", "", legacySessionTopicID(recovery))
	meta, ok, err := agent.LoadBranchMeta(recovery)
	if err != nil || !ok {
		t.Fatalf("load recovery meta after parent deletion: ok=%v err=%v", ok, err)
	}
	if meta.TopicID != legacySessionTopicID(recovery) {
		t.Fatalf("recovery topic after parent deletion = %q, want %q", meta.TopicID, legacySessionTopicID(recovery))
	}
	for _, root := range nodes {
		for _, node := range root.Children {
			if node.TopicID == meta.TopicID {
				return
			}
		}
	}
	t.Fatalf("project tree after parent deletion = %#v, want recovery topic %q", nodes, meta.TopicID)
}

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

	dir := t.TempDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	recovery := writeLegacySession(t, dir, "desktop-recovery-0123456789abcdef.jsonl", "legacy recovery prompt", time.Now())
	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: recovery, Label: "test"})
	defer ctrl.Close()
	app := NewApp()
	app.setTestCtrl(ctrl, "")
	installSessionCatalogForTest(t, app, dir, "global", "")
	sessions := app.ListSessions()
	for _, session := range sessions {
		if filepath.Clean(session.Path) != filepath.Clean(recovery) {
			continue
		}
		if !session.Recovered {
			t.Fatalf("history session recovered flag = false, want true: %+v", session)
		}
		if session.RecoveryCopy {
			t.Fatalf("legacy filename-only recovery was marked safe for bulk cleanup: %+v", session)
		}
		return
	}
	t.Fatalf("history sessions = %#v, want recovery session %q", sessions, recovery)
}

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

	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	recovery := writeLegacySession(t, dir, "desktop-recovery-0123456789abcdef.jsonl", "legacy recovery prompt", time.Now())
	if err := deleteSessionFile(dir, recovery); err != nil {
		t.Fatalf("delete recovery session: %v", err)
	}
	trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(recovery), filepath.Base(recovery))

	sessions := NewApp().ListTrashedSessions()
	if len(sessions) != 1 || filepath.Clean(sessions[0].Path) != filepath.Clean(trashPath) {
		t.Fatalf("trashed sessions = %#v, want %q", sessions, trashPath)
	}
	if !sessions[0].Recovered {
		t.Fatalf("trashed recovery session recovered flag = false, want true: %+v", sessions[0])
	}
	if sessions[0].RecoveryCopy {
		t.Fatalf("legacy filename-only recovery was marked safe for bulk purge: %+v", sessions[0])
	}
}

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

	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	recovery := writeLegacySession(t, dir, "desktop-recovery-0123456789abcdef.jsonl", "legacy recovery prompt", time.Now().Add(-time.Hour))
	topicID := legacySessionTopicID(recovery)
	if err := agent.SaveBranchMetaPreserveUpdated(recovery, agent.BranchMeta{
		ID:         agent.BranchID(recovery),
		CreatedAt:  time.Now().Add(-2 * time.Hour),
		UpdatedAt:  time.Now().Add(-time.Hour),
		Scope:      "global",
		TopicID:    topicID,
		TopicTitle: "恢复分支",
		Turns:      1,
		Preview:    "legacy recovery prompt",
	}); err != nil {
		t.Fatalf("save migrated recovery meta: %v", err)
	}
	// A v1 repair pass skipped this recovery-named record. The v2 pass must
	// revisit it and restore its existing topic to the sidebar index.
	if err := os.WriteFile(filepath.Join(dir, ".topic-indexes-repaired"), nil, 0o644); err != nil {
		t.Fatalf("write v1 repair marker: %v", err)
	}

	app := NewApp()
	_ = waitForCatalogTopic(t, app, "global", "", topicID)
	nodes := waitForCatalogTreeCondition(t, app, "a repaired ambiguous recovery topic", func(nodes []ProjectNode) bool {
		if len(nodes) == 0 {
			return false
		}
		for _, node := range nodes[0].Children {
			if node.TopicID == topicID {
				return node.TurnsState == "valid" && node.Turns == 1
			}
		}
		return false
	})
	if len(nodes) == 0 {
		t.Fatal("project tree is empty")
	}
	for _, node := range nodes[0].Children {
		if node.TopicID == topicID {
			if node.Turns != 1 {
				t.Fatalf("recovery topic turns = %d, want 1", node.Turns)
			}
			return
		}
	}
	t.Fatalf("ambiguous recovery topic should stay visible: %#v", nodes)
}

func TestTopicMigrationMarkerRescansWhenSessionFileChanges(t *testing.T) {
	isolateDesktopUserDirs(t)
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	writeLegacySession(t, dir, "first.jsonl", "first legacy prompt", time.Now().Add(-time.Hour))

	// Background catalog reconciliation migrates the legacy session and, with
	// nothing deferred, stamps the one-shot marker. Tree reads never do this I/O.
	app := NewApp()
	firstTopicID := legacySessionTopicID(filepath.Join(dir, "first.jsonl"))
	waitForCatalogTopic(t, app, "global", "", firstTopicID)
	// Catalog publication and the migration marker are written on the same
	// background path but not under one fsync barrier. Wait for the marker
	// explicitly so Windows CI does not observe the topic before the stamp.
	markerPath := filepath.Join(dir, topicMigrationMarker)
	deadline := time.Now().Add(5 * time.Second)
	var lastMarkerErr error
	for {
		if _, lastMarkerErr = os.Stat(markerPath); lastMarkerErr == nil {
			break
		}
		if time.Now().After(deadline) {
			t.Fatalf("expected migration marker after a complete pass: %v", lastMarkerErr)
		}
		time.Sleep(10 * time.Millisecond)
	}

	// A CLI-created session added after the marker invalidates the lightweight
	// gate and gets a fresh migration pass.
	time.Sleep(10 * time.Millisecond)
	second := writeLegacySession(t, dir, "second.jsonl", "second legacy prompt", time.Now())
	app.requestSessionCatalogReconcile(dir)
	waitForCatalogTopic(t, app, "global", "", legacySessionTopicID(second))
	meta, ok, err := agent.LoadBranchMeta(second)
	if err != nil {
		t.Fatalf("load second meta: %v", err)
	}
	if !ok || strings.TrimSpace(meta.TopicID) != legacySessionTopicID(second) {
		t.Fatalf("new session after marker should be migrated, got ok=%v meta=%+v", ok, meta)
	}
}

func TestProjectTreeRepairsIndexedGlobalTopicsAfterMigrationMarker(t *testing.T) {
	isolateDesktopUserDirs(t)
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	if err := addProject(t.TempDir(), "Existing project"); err != nil {
		t.Fatalf("add project: %v", err)
	}

	sessionPath := writeLegacySession(t, dir, "desktop-legacy.jsonl", "who are you", time.Now().Add(-time.Hour))
	topicID := "legacy_desktop-legacy_1234"
	if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
		ID:         agent.BranchID(sessionPath),
		CreatedAt:  time.Now().Add(-2 * time.Hour),
		UpdatedAt:  time.Now().Add(-time.Hour),
		Scope:      "global",
		TopicID:    topicID,
		TopicTitle: "你是谁",
		Turns:      1,
		Preview:    "who are you",
	}); err != nil {
		t.Fatalf("save meta: %v", err)
	}
	markTopicMigrationDone(dir)

	repaired := migrateLegacySessionsIntoGlobalTopics(dir)
	if len(repaired) != 1 || repaired[0] != topicID {
		t.Fatalf("repaired topics = %#v, want %q", repaired, topicID)
	}

	nodes := NewApp().ListProjectTree()
	var global *ProjectNode
	for i := range nodes {
		if nodes[i].Kind == "global_folder" {
			global = &nodes[i]
			break
		}
	}
	if global == nil {
		t.Fatalf("project tree = %#v, want repaired Global folder", nodes)
	}
	if len(global.Children) != 1 || global.Children[0].TopicID != topicID || global.Children[0].Label != "你是谁" {
		t.Fatalf("global children = %#v, want repaired topic %q with preserved title", global.Children, topicID)
	}
	f := loadProjectsFile()
	if !containsDesktopString(f.GlobalTopics, topicID) {
		t.Fatalf("globalTopics = %#v, want %q", f.GlobalTopics, topicID)
	}
	if got := loadTopicTitle("", topicID); got != "你是谁" {
		t.Fatalf("global topic title = %q, want 你是谁", got)
	}
}

func TestDeletedRepairedGlobalTopicIsNotAutoRestored(t *testing.T) {
	isolateDesktopUserDirs(t)
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	if err := addProject(t.TempDir(), "Existing project"); err != nil {
		t.Fatalf("add project: %v", err)
	}

	sessionPath := writeLegacySession(t, dir, "desktop-delete.jsonl", "delete repaired topic", time.Now().Add(-time.Hour))
	topicID := "legacy_desktop-delete_1234"
	if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
		ID:         agent.BranchID(sessionPath),
		CreatedAt:  time.Now().Add(-2 * time.Hour),
		UpdatedAt:  time.Now().Add(-time.Hour),
		Scope:      "global",
		TopicID:    topicID,
		TopicTitle: "临时 Global",
		Turns:      1,
		Preview:    "delete repaired topic",
	}); err != nil {
		t.Fatalf("save meta: %v", err)
	}
	markTopicMigrationDone(dir)
	if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 1 || repaired[0] != topicID {
		t.Fatalf("initial repaired topics = %#v, want %q", repaired, topicID)
	}
	if err := NewApp().DeleteTopic(topicID); err != nil {
		t.Fatalf("delete repaired topic: %v", err)
	}

	time.Sleep(10 * time.Millisecond)
	later := time.Now()
	if err := os.Chtimes(sessionPath, later, later); err != nil {
		t.Fatalf("touch session after delete: %v", err)
	}
	if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 0 {
		t.Fatalf("deleted repaired topic was restored: %#v", repaired)
	}
	f := loadProjectsFile()
	if containsDesktopString(f.GlobalTopics, topicID) {
		t.Fatalf("globalTopics = %#v, deleted topic %q should stay removed", f.GlobalTopics, topicID)
	}
	if got := loadTopicTitle("", topicID); got != "" {
		t.Fatalf("global topic title = %q, want deleted", got)
	}
	if !containsDesktopString(f.DeletedTopics, topicID) {
		t.Fatalf("deletedTopics = %#v, want tombstone for %q", f.DeletedTopics, topicID)
	}
}

func TestRepairRescanKeepsIndexedTopicsUntouched(t *testing.T) {
	isolateDesktopUserDirs(t)
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	if err := addProject(t.TempDir(), "Existing project"); err != nil {
		t.Fatalf("add project: %v", err)
	}

	writeIndexedSession := func(name, topicID, title string, at time.Time) string {
		p := writeLegacySession(t, dir, name, "prompt "+title, at)
		if err := agent.SaveBranchMetaPreserveUpdated(p, agent.BranchMeta{
			ID:         agent.BranchID(p),
			CreatedAt:  at.Add(-time.Hour),
			UpdatedAt:  at,
			Scope:      "global",
			TopicID:    topicID,
			TopicTitle: title,
			Turns:      1,
			Preview:    "prompt " + title,
		}); err != nil {
			t.Fatalf("save meta %s: %v", name, err)
		}
		if err := os.Chtimes(p, at, at); err != nil {
			t.Fatalf("chtimes %s: %v", name, err)
		}
		return p
	}
	now := time.Now()
	olderPath := writeIndexedSession("older.jsonl", "legacy_older_000000000001", "旧话题", now.Add(-3*time.Hour))
	writeIndexedSession("newer.jsonl", "legacy_newer_000000000002", "新话题", now.Add(-time.Hour))
	markTopicMigrationDone(dir)

	// First pass repairs both missing topics.
	if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 2 {
		t.Fatalf("initial repaired topics = %#v, want 2 entries", repaired)
	}
	before := loadProjectsFile()
	projPath := filepath.Join(desktopConfigDir(), desktopProjectsFile)
	statBefore, err := os.Stat(projPath)
	if err != nil {
		t.Fatalf("stat projects file: %v", err)
	}

	// Ordinary session activity invalidates the repair marker; the rescan must
	// not reorder the indexed topics, rewrite the projects file, or report the
	// already-visible topics as repaired (the callers bind blank Global tabs
	// to repaired[0]).
	time.Sleep(10 * time.Millisecond)
	later := time.Now()
	if err := os.Chtimes(olderPath, later, later); err != nil {
		t.Fatalf("touch session: %v", err)
	}
	if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 0 {
		t.Fatalf("steady-state rescan reported repairs: %#v", repaired)
	}
	after := loadProjectsFile()
	if !sameStringList(before.GlobalTopics, after.GlobalTopics) {
		t.Fatalf("rescan reordered globalTopics: before=%v after=%v", before.GlobalTopics, after.GlobalTopics)
	}
	statAfter, err := os.Stat(projPath)
	if err != nil {
		t.Fatalf("stat projects file: %v", err)
	}
	if !statAfter.ModTime().Equal(statBefore.ModTime()) {
		t.Fatalf("steady-state rescan rewrote the projects file")
	}
}

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

	topicID := "legacy_race_000000000001"
	// Simulate a DeleteTopic landing between a repair scan's DeletedTopics
	// snapshot and its batch write: the tombstone exists by the time the
	// prepend runs, so the batch must drop the topic instead of resurrecting it.
	if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
		f.DeletedTopics = prependUniqueString(f.DeletedTopics, topicID)
		return true, nil
	}); err != nil {
		t.Fatalf("seed tombstone: %v", err)
	}
	if err := prependTopicsInProjectsFile("", []string{topicID, "legacy_live_000000000002"}, false); err != nil {
		t.Fatalf("batch prepend: %v", err)
	}
	f := loadProjectsFile()
	if containsDesktopString(f.GlobalTopics, topicID) {
		t.Fatalf("globalTopics = %#v, tombstoned topic %q must not be batch-prepended", f.GlobalTopics, topicID)
	}
	if !containsDesktopString(f.GlobalTopics, "legacy_live_000000000002") {
		t.Fatalf("globalTopics = %#v, live topic should still be prepended", f.GlobalTopics)
	}
	if !containsDesktopString(f.DeletedTopics, topicID) {
		t.Fatalf("deletedTopics = %#v, tombstone should survive a batch prepend", f.DeletedTopics)
	}

	// Intentional single-topic writes (create/restore/tab indexing) clear the
	// tombstone and bring the topic back in the same projects-file transaction.
	if err := prependTopicInProjectsFile("", topicID, false); err != nil {
		t.Fatalf("single prepend: %v", err)
	}
	f = loadProjectsFile()
	if !containsDesktopString(f.GlobalTopics, topicID) {
		t.Fatalf("globalTopics = %#v, single prepend should restore %q", f.GlobalTopics, topicID)
	}
	if containsDesktopString(f.DeletedTopics, topicID) {
		t.Fatalf("deletedTopics = %#v, single prepend should clear the tombstone", f.DeletedTopics)
	}
}

func TestTombstonedTitleOnlyTopicStaysHiddenInProjectTree(t *testing.T) {
	isolateDesktopUserDirs(t)
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	if err := addProject(t.TempDir(), "Existing project"); err != nil {
		t.Fatalf("add project: %v", err)
	}

	// Control: a legitimate title-only topic (not in GlobalTopics) must keep
	// rendering through the orderedTopicIDs title-map fallback.
	controlID := "topic_control_visible"
	if err := setTopicTitle("", controlID, "正常话题"); err != nil {
		t.Fatalf("set control title: %v", err)
	}
	// Race product: DeleteTopic landed, but a stale whole-map save wrote the
	// topic's title back — tombstoned, absent from GlobalTopics, title present.
	tombstonedID := "legacy_raced_000000000009"
	if err := setTopicTitle("", tombstonedID, "被删除的话题"); err != nil {
		t.Fatalf("set stale title: %v", err)
	}
	if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
		f.DeletedTopics = prependUniqueString(f.DeletedTopics, tombstonedID)
		return true, nil
	}); err != nil {
		t.Fatalf("seed tombstone: %v", err)
	}

	nodes := NewApp().ListProjectTree()
	var global *ProjectNode
	for i := range nodes {
		if nodes[i].Kind == "global_folder" {
			global = &nodes[i]
			break
		}
	}
	if global == nil {
		t.Fatalf("project tree = %#v, want Global folder", nodes)
	}
	seen := map[string]bool{}
	for _, c := range global.Children {
		seen[c.TopicID] = true
	}
	if seen[tombstonedID] {
		t.Fatalf("global children = %#v, tombstoned title-only topic %q must stay hidden", global.Children, tombstonedID)
	}
	if !seen[controlID] {
		t.Fatalf("global children = %#v, legitimate title-only topic %q should still render", global.Children, controlID)
	}
}

func TestRepairPassPrunesStaleTitleOfDeletedTopic(t *testing.T) {
	isolateDesktopUserDirs(t)
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	if err := addProject(t.TempDir(), "Existing project"); err != nil {
		t.Fatalf("add project: %v", err)
	}

	// Stale race product on disk: tombstoned topic whose title lingers in the
	// global title map. The next repair pass that saves the whole map must
	// prune it instead of persisting it again.
	tombstonedID := "legacy_raced_000000000010"
	if err := setTopicTitle("", tombstonedID, "被删除的话题"); err != nil {
		t.Fatalf("set stale title: %v", err)
	}
	if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
		f.DeletedTopics = prependUniqueString(f.DeletedTopics, tombstonedID)
		return true, nil
	}); err != nil {
		t.Fatalf("seed tombstone: %v", err)
	}

	sessionPath := writeLegacySession(t, dir, "desktop-prune.jsonl", "needs repair", time.Now().Add(-time.Hour))
	repairID := "legacy_desktop-prune_1234"
	if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
		ID:         agent.BranchID(sessionPath),
		CreatedAt:  time.Now().Add(-2 * time.Hour),
		UpdatedAt:  time.Now().Add(-time.Hour),
		Scope:      "global",
		TopicID:    repairID,
		TopicTitle: "待修复话题",
		Turns:      1,
		Preview:    "needs repair",
	}); err != nil {
		t.Fatalf("save meta: %v", err)
	}
	markTopicMigrationDone(dir)

	if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 1 || repaired[0] != repairID {
		t.Fatalf("repaired topics = %#v, want %q", repaired, repairID)
	}
	if got := loadTopicTitle("", tombstonedID); got != "" {
		t.Fatalf("stale title = %q, repair save should prune the tombstoned entry", got)
	}
	if got := loadTopicTitle("", repairID); got != "待修复话题" {
		t.Fatalf("repaired title = %q, want 待修复话题", got)
	}
	f := loadProjectsFile()
	if containsDesktopString(f.GlobalTopics, tombstonedID) {
		t.Fatalf("globalTopics = %#v, tombstoned topic must stay out", f.GlobalTopics)
	}
	if !containsDesktopString(f.GlobalTopics, repairID) {
		t.Fatalf("globalTopics = %#v, want repaired topic %q", f.GlobalTopics, repairID)
	}
}

func TestTopicMigrationDefersEmptyLegacySession(t *testing.T) {
	isolateDesktopUserDirs(t)
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	// An empty legacy session (no user turns) is not migratable yet but could gain
	// content later, so the pass must NOT mark the dir done — otherwise the gate
	// would hide it forever.
	if err := os.WriteFile(filepath.Join(dir, "empty.jsonl"), nil, 0o644); err != nil {
		t.Fatalf("write empty session: %v", err)
	}

	NewApp().ListProjectTree()
	if _, err := os.Stat(filepath.Join(dir, topicMigrationMarker)); err == nil {
		t.Fatal("an empty legacy session must defer marking, but the dir was marked done")
	}
}

func TestV05LegacyEventSessionsImportIntoGlobalTopic(t *testing.T) {
	home := isolateDesktopUserDirs(t)

	legacyDir := filepath.Join(home, ".reasonix", "sessions")
	destDir := config.SessionDir()
	writeLegacyEventSession(t, legacyDir, "v053-chat.events.jsonl", "hello from v0.53", "hi from v0.53", time.Now().Add(-time.Hour))

	imported, err := agent.MigrateLegacySessions(legacyDir, destDir, config.ProjectSessionDir)
	if err != nil {
		t.Fatalf("migrate legacy sessions: %v", err)
	}
	if imported != 1 {
		t.Fatalf("imported legacy sessions = %d, want 1", imported)
	}
	migratedSession := filepath.Join(destDir, "v053-chat.jsonl")
	if _, err := os.Stat(migratedSession); err != nil {
		t.Fatalf("legacy v0.5 session was not imported to %s: %v", migratedSession, err)
	}

	wantTopicID := legacySessionTopicID(migratedSession)
	migratedTopics := migrateLegacySessionsIntoGlobalTopics(destDir)
	if len(migratedTopics) != 1 || migratedTopics[0] != wantTopicID {
		t.Fatalf("migrated topics = %#v, want imported v0.5 topic %q", migratedTopics, wantTopicID)
	}

	nodes := NewApp().ListProjectTree()
	if len(nodes) != 1 || nodes[0].Kind != "global_folder" {
		t.Fatalf("project tree = %#v, want global folder", nodes)
	}
	if len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != wantTopicID {
		t.Fatalf("global topics = %#v, want imported v0.5 topic %q", nodes[0].Children, wantTopicID)
	}
	meta, ok, err := agent.LoadBranchMeta(migratedSession)
	if err != nil || !ok {
		t.Fatalf("load imported v0.5 meta: ok=%v err=%v", ok, err)
	}
	if meta.Scope != "global" || meta.TopicID != wantTopicID {
		t.Fatalf("imported v0.5 meta = %+v", meta)
	}
}

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

	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	dotted := writeLegacySession(t, dir, "chat.1.jsonl", "dotted prompt", time.Now().Add(-2*time.Hour))
	underscored := writeLegacySession(t, dir, "chat_1.jsonl", "underscored prompt", time.Now().Add(-time.Hour))

	dottedTopic := legacySessionTopicID(dotted)
	underscoredTopic := legacySessionTopicID(underscored)
	if dottedTopic == underscoredTopic {
		t.Fatalf("normalized legacy topic IDs collided: %q", dottedTopic)
	}

	app := NewApp()
	nodes := waitForCatalogTopic(t, app, "global", "", dottedTopic)
	if len(nodes) != 1 || nodes[0].Kind != "global_folder" {
		t.Fatalf("project tree = %#v, want global folder", nodes)
	}
	if got := len(nodes[0].Children); got != 2 {
		t.Fatalf("global migrated topics = %d, want 2: %#v", got, nodes[0].Children)
	}
	seen := map[string]bool{}
	for _, child := range nodes[0].Children {
		seen[child.TopicID] = true
	}
	if !seen[dottedTopic] || !seen[underscoredTopic] {
		t.Fatalf("global topics = %#v, want %q and %q", nodes[0].Children, dottedTopic, underscoredTopic)
	}
}

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

	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	sessionPath := writeLegacySession(t, dir, "legacy-tab.jsonl", "resume this legacy tab", time.Now().Add(-time.Hour))

	tab := &WorkspaceTab{
		ID:            "tab_legacy",
		Scope:         "global",
		WorkspaceRoot: globalTabWorkspaceRoot(),
		Ready:         false,
		disabledMCP:   map[string]ServerView{},
	}
	app := &App{
		tabs:        map[string]*WorkspaceTab{"tab_legacy": tab},
		tabOrder:    []string{"tab_legacy"},
		activeTabID: "tab_legacy",
	}
	app.buildTabController(tab)
	if tab.Ctrl != nil {
		defer tab.Ctrl.Close()
	}

	if tab.TopicID != "" {
		t.Fatalf("blank tab synchronously adopted legacy topic %q", tab.TopicID)
	}
	if tab.Ctrl == nil {
		t.Fatalf("tab controller was not built")
	}
	if tab.Ctrl.SessionPath() == sessionPath {
		t.Fatalf("blank tab synchronously adopted legacy session %q", sessionPath)
	}
	wantTopicID := legacySessionTopicID(sessionPath)
	nodes := waitForCatalogTopic(t, app, "global", "", wantTopicID)
	if len(nodes) != 1 || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != wantTopicID {
		t.Fatalf("legacy history was not eventually indexed: %+v", nodes)
	}
}

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

	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	topicID := "topic_same"
	topicTitle := "Pinned topic"
	pinned := writeTopicSessionWithPrompt(t, dir, "long.jsonl", topicID, topicTitle, "", "full 64-turn conversation", time.Now().Add(-2*time.Hour))
	_ = writeTopicSessionWithPrompt(t, dir, "short.jsonl", topicID, topicTitle, "", "early 5-turn snapshot", time.Now().Add(time.Hour))

	app := NewApp()
	tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), topicID, "tab_pinned")
	tab.TopicTitle = topicTitle
	tab.SessionPath = pinned
	tab.sink = &tabEventSink{tabID: tab.ID, app: app}
	app.tabs[tab.ID] = tab
	app.tabOrder = []string{tab.ID}
	app.activeTabID = tab.ID

	app.buildTabController(tab)
	if tab.Ctrl == nil {
		t.Fatalf("tab controller was not built: %s", tab.StartupErr)
	}
	defer tab.Ctrl.Close()

	if got := filepath.Clean(tab.Ctrl.SessionPath()); got != filepath.Clean(pinned) {
		t.Fatalf("restored session path = %q, want pinned %q", got, pinned)
	}
	history := tab.Ctrl.History()
	if len(history) != 2 || string(history[0].Role) != "system" || strings.TrimSpace(history[0].Content) == "" ||
		string(history[1].Role) != "user" || history[1].Content != "full 64-turn conversation" {
		t.Fatalf("restored history = %+v, want fresh system prompt and pinned long conversation", history)
	}
	f := loadTabsFile()
	if len(f.Tabs) != 1 || filepath.Clean(f.Tabs[0].SessionPath) != filepath.Clean(pinned) {
		t.Fatalf("desktop tabs file = %+v, want pinned session path %q", f, pinned)
	}
}

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

	projectA := t.TempDir()
	projectB := t.TempDir()
	if err := addProject(projectA, "Project A"); err != nil {
		t.Fatalf("add project A: %v", err)
	}
	if err := addProject(projectB, "Project B"); err != nil {
		t.Fatalf("add project B: %v", err)
	}

	topicID := "topic_restore_workspace"
	topicTitle := "Restore workspace"
	sessionDirA := desktopSessionDir(projectA)
	if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
		t.Fatalf("mkdir project A sessions: %v", err)
	}
	pinned := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", topicID, topicTitle, projectA, "project A prompt", time.Now())

	app := NewApp()
	tab := app.createTabEntryWithID("project", projectB, topicID, "tab_stale_workspace")
	tab.TopicTitle = topicTitle
	tab.SessionPath = pinned
	tab.sink = &tabEventSink{tabID: tab.ID, app: app}
	app.tabs[tab.ID] = tab
	app.tabOrder = []string{tab.ID}
	app.activeTabID = tab.ID

	app.buildTabController(tab)
	if tab.Ctrl == nil {
		t.Fatalf("tab controller was not built: %s", tab.StartupErr)
	}
	defer tab.Ctrl.Close()

	if got := filepath.Clean(tab.Ctrl.SessionPath()); got != filepath.Clean(pinned) {
		t.Fatalf("restored session path = %q, want pinned %q", got, pinned)
	}
	if got := normalizeProjectRoot(tab.WorkspaceRoot); got != normalizeProjectRoot(projectA) {
		t.Fatalf("tab workspace root = %q, want project A %q", got, normalizeProjectRoot(projectA))
	}
	history := tab.Ctrl.History()
	if len(history) != 2 || string(history[0].Role) != "system" || strings.TrimSpace(history[0].Content) == "" ||
		string(history[1].Role) != "user" || history[1].Content != "project A prompt" {
		t.Fatalf("restored history = %+v, want fresh system prompt and project A prompt", history)
	}
}

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

	projectA := t.TempDir()
	projectB := t.TempDir()
	if err := addProject(projectA, "Project A"); err != nil {
		t.Fatalf("add project A: %v", err)
	}
	if err := addProject(projectB, "Project B"); err != nil {
		t.Fatalf("add project B: %v", err)
	}

	topicID := "topic_owner_before_meta"
	topicTitle := "Owner before meta"
	sessionDirA := desktopSessionDir(projectA)
	if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
		t.Fatalf("mkdir project A sessions: %v", err)
	}
	sessionPath := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", topicID, topicTitle, projectA, "project A prompt", time.Now())
	meta, ok, err := agent.LoadBranchMeta(sessionPath)
	if err != nil || !ok {
		t.Fatalf("load branch meta: ok=%v err=%v", ok, err)
	}
	meta.WorkspaceRoot = projectB
	if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, meta); err != nil {
		t.Fatalf("pollute branch meta: %v", err)
	}

	app := NewApp()
	tab := app.createTabEntryWithID("project", projectB, topicID, "tab_stale_workspace")
	tab.TopicTitle = topicTitle
	tab.SessionPath = sessionPath
	app.tabs[tab.ID] = tab
	app.tabOrder = []string{tab.ID}
	app.activeTabID = tab.ID

	app.persistTabSessionPath(tab, sessionPath)

	if got := normalizeProjectRoot(tab.WorkspaceRoot); got != normalizeProjectRoot(projectA) {
		t.Fatalf("tab workspace root = %q, want project A %q", got, normalizeProjectRoot(projectA))
	}
	gotMeta, ok, err := agent.LoadBranchMeta(sessionPath)
	if err != nil || !ok {
		t.Fatalf("reload branch meta: ok=%v err=%v", ok, err)
	}
	if gotMeta.Scope != "project" || normalizeProjectRoot(gotMeta.WorkspaceRoot) != normalizeProjectRoot(projectA) {
		t.Fatalf("saved branch meta scope/root = %q/%q, want project/%q", gotMeta.Scope, gotMeta.WorkspaceRoot, normalizeProjectRoot(projectA))
	}
}

func TestBuildTabControllerIgnoresStaleSessionModelWhenTabModelResolves(t *testing.T) {
	isolateDesktopUserDirs(t)
	t.Setenv("REASONIX_TEST_KEY", "sk-test")
	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
		t.Fatalf("mkdir config dir: %v", err)
	}
	if err := os.WriteFile(config.UserConfigPath(), []byte(`
default_model = "default-provider/default-model"

[[providers]]
name = "default-provider"
kind = "openai"
base_url = "https://default.invalid/v1"
model = "default-model"
api_key_env = "REASONIX_TEST_KEY"

[[providers]]
name = "tab-provider"
kind = "openai"
base_url = "https://tab.invalid/v1"
model = "tab-model"
api_key_env = "REASONIX_TEST_KEY"
`), 0o644); err != nil {
		t.Fatalf("write config: %v", err)
	}

	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	pinned := writeLegacySession(t, dir, "stale-model.jsonl", "resume with tab model", time.Now())
	meta, err := agent.EnsureBranchMeta(pinned)
	if err != nil {
		t.Fatal(err)
	}
	meta.Model = "missing-provider/missing-model"
	if err := agent.SaveBranchMetaPreserveUpdated(pinned, meta); err != nil {
		t.Fatal(err)
	}

	app := NewApp()
	tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "tab_stale_model")
	tab.SessionPath = pinned
	tab.model = "tab-provider/tab-model"
	tab.sink = &tabEventSink{tabID: tab.ID, app: app}
	app.tabs[tab.ID] = tab
	app.tabOrder = []string{tab.ID}
	app.activeTabID = tab.ID

	app.buildTabController(tab)
	if tab.Ctrl == nil {
		t.Fatalf("tab controller was not built: %s", tab.StartupErr)
	}
	defer tab.Ctrl.Close()
	if tab.model != "tab-provider/tab-model" {
		t.Fatalf("tab model = %q, want valid tab model", tab.model)
	}
}

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

	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	path := writeLegacySession(t, dir, "migrated-tab.jsonl", "resume after path migration", time.Now())
	oldPath := filepath.Join(t.TempDir(), "old-reasonix", "projects", "slug", "sessions", filepath.Base(path))

	loaded, pinnedPath, ok, err := loadPinnedTabSession(dir, oldPath)
	if err != nil {
		t.Fatalf("loadPinnedTabSession: %v", err)
	}
	if !ok || loaded == nil {
		t.Fatalf("loadPinnedTabSession did not recover migrated basename: ok=%v loaded=%v path=%q", ok, loaded, pinnedPath)
	}
	if filepath.Clean(pinnedPath) != filepath.Clean(path) {
		t.Fatalf("pinned path = %q, want %q", pinnedPath, path)
	}
}

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

	dirA := t.TempDir()
	dirB := t.TempDir()
	pathA := writeLegacySession(t, dirA, "same-name.jsonl", "project A", time.Now())
	_ = writeLegacySession(t, dirB, filepath.Base(pathA), "project B", time.Now())

	if got, ok := pinnedTabSessionPath(dirB, pathA); ok {
		t.Fatalf("pinnedTabSessionPath mapped existing absolute path outside dir to %q", got)
	}
}

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

	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	path := writeLegacySession(t, dir, "pending-pinned.jsonl", "pending pinned", time.Now())
	if err := agent.MarkCleanupPending(path, "delete"); err != nil {
		t.Fatal(err)
	}

	if loaded, pinnedPath, ok, err := loadPinnedTabSession(dir, path); err != nil || ok || loaded != nil || pinnedPath != "" {
		t.Fatalf("loadPinnedTabSession cleanup-pending = loaded:%v path:%q ok:%v, want skipped", loaded, pinnedPath, ok)
	}
}

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

	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	path := writeLegacySession(t, dir, "unsafe-pinned.jsonl", "checkpoint", time.Now())
	events := `{"schema_version":99,"type":"replace","messages":[{"role":"user","content":"newer"}]}` + "\n"
	if err := os.WriteFile(agent.SessionEventLogPath(path), []byte(events), 0o600); err != nil {
		t.Fatalf("write future event log: %v", err)
	}

	loaded, pinnedPath, ok, err := loadPinnedTabSession(dir, path)
	if err == nil || !strings.Contains(err.Error(), "uses schema 99") {
		t.Fatalf("loadPinnedTabSession error = %v, want future-schema refusal", err)
	}
	if loaded != nil || !ok || filepath.Clean(pinnedPath) != filepath.Clean(path) {
		t.Fatalf("load result = loaded:%v path:%q ok:%v, want pinned path retained with hard error", loaded, pinnedPath, ok)
	}
	if got, readErr := os.ReadFile(agent.SessionEventLogPath(path)); readErr != nil || string(got) != events {
		t.Fatalf("event log changed after refusal: bytes=%q err=%v", got, readErr)
	}
}

func TestBuildTabControllerSurfacesPinnedSessionLoadError(t *testing.T) {
	isolateDesktopUserDirs(t)
	t.Setenv("REASONIX_TEST_KEY", "sk-test")
	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
		t.Fatalf("mkdir config dir: %v", err)
	}
	if err := os.WriteFile(config.UserConfigPath(), []byte(`
default_model = "test-provider/test-model"

[[providers]]
name = "test-provider"
kind = "openai"
base_url = "https://test.invalid/v1"
model = "test-model"
api_key_env = "REASONIX_TEST_KEY"
`), 0o600); err != nil {
		t.Fatalf("write config: %v", err)
	}

	dir := desktopSessionDir(globalWorkspaceRoot())
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	path := writeLegacySession(t, dir, "unsafe-startup.jsonl", "checkpoint", time.Now())
	events := `{"schema_version":1,"type":"replace","messages":[{"role":"user","content":"newer"}]}` + "\n"
	logPath := agent.SessionEventLogPath(path)
	if err := os.WriteFile(logPath, []byte(events), 0o600); err != nil {
		t.Fatalf("write native event log: %v", err)
	}
	const oversizedSparseLog = int64(1 << 30)
	if err := os.Truncate(logPath, oversizedSparseLog); err != nil {
		t.Fatalf("make sparse oversized event log: %v", err)
	}

	app := NewApp()
	tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "tab_unsafe_startup")
	tab.SessionPath = path
	tab.model = "test-provider/test-model"
	tab.sink = &tabEventSink{tabID: tab.ID, app: app}
	app.tabs[tab.ID] = tab
	app.tabOrder = []string{tab.ID}
	app.activeTabID = tab.ID

	app.buildTabController(tab)
	if tab.Ctrl != nil || tab.Ready {
		t.Fatalf("unsafe session runtime = hasCtrl:%v ready:%v, want failed startup", tab.Ctrl != nil, tab.Ready)
	}
	if !strings.Contains(tab.StartupErr, agent.ErrSessionReplayLimitExceeded.Error()) || strings.Contains(tab.StartupErr, path) {
		t.Fatalf("startup error = %q, want path-free replay-budget error", tab.StartupErr)
	}
	if filepath.Clean(tab.SessionPath) != filepath.Clean(path) {
		t.Fatalf("session path = %q, want original %q", tab.SessionPath, path)
	}
	info, err := os.Stat(logPath)
	if err != nil {
		t.Fatalf("stat event log after startup refusal: %v", err)
	}
	if info.Size() != oversizedSparseLog {
		t.Fatalf("event log size after startup refusal = %d, want %d", info.Size(), oversizedSparseLog)
	}
	app.sharedHostsMu.Lock()
	sharedHosts := len(app.sharedHosts)
	app.sharedHostsMu.Unlock()
	if sharedHosts != 0 {
		t.Fatalf("shared hosts after failed startup = %d, want 0", sharedHosts)
	}
}

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

	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	pending := writeLegacySession(t, dir, "pending-startup.jsonl", "pending startup", time.Now())
	if err := agent.MarkCleanupPending(pending, "delete"); err != nil {
		t.Fatal(err)
	}

	app := NewApp()
	tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "tab_pending")
	tab.SessionPath = pending
	tab.sink = &tabEventSink{tabID: tab.ID, app: app}
	app.tabs[tab.ID] = tab
	app.tabOrder = []string{tab.ID}
	app.activeTabID = tab.ID

	app.buildTabController(tab)
	if tab.Ctrl == nil {
		t.Fatalf("tab controller was not built: %s", tab.StartupErr)
	}
	defer tab.Ctrl.Close()

	if got := filepath.Clean(tab.Ctrl.SessionPath()); got == filepath.Clean(pending) {
		t.Fatalf("startup bound cleanup-pending pinned session path %q", got)
	}
	for _, msg := range tab.Ctrl.History() {
		if msg.Content == "pending startup" {
			t.Fatalf("startup loaded cleanup-pending history: %+v", tab.Ctrl.History())
		}
	}
}

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

	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	topicID := "topic_empty"
	topicTitle := "Empty pinned topic"
	_ = writeTopicSessionWithPrompt(t, dir, "old.jsonl", topicID, topicTitle, "", "old topic history", time.Now())
	pinned := filepath.Join(dir, "empty-new.jsonl")

	app := NewApp()
	tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), topicID, "tab_empty")
	tab.TopicTitle = topicTitle
	tab.SessionPath = pinned
	tab.sink = &tabEventSink{tabID: tab.ID, app: app}
	app.tabs[tab.ID] = tab
	app.tabOrder = []string{tab.ID}
	app.activeTabID = tab.ID

	app.buildTabController(tab)
	if tab.Ctrl == nil {
		t.Fatalf("tab controller was not built: %s", tab.StartupErr)
	}
	defer tab.Ctrl.Close()

	if got := filepath.Clean(tab.Ctrl.SessionPath()); got != filepath.Clean(pinned) {
		t.Fatalf("empty pinned session path = %q, want %q", got, pinned)
	}
	for _, msg := range tab.Ctrl.History() {
		if msg.Content == "old topic history" {
			t.Fatalf("empty pinned session loaded fallback topic history: %+v", tab.Ctrl.History())
		}
	}
}

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

	first := t.TempDir()
	second := t.TempDir()
	third := t.TempDir()
	if err := addProject(first, "First"); err != nil {
		t.Fatalf("add first project: %v", err)
	}
	if err := addProject(second, "Second"); err != nil {
		t.Fatalf("add second project: %v", err)
	}
	if err := addProject(third, "Third"); err != nil {
		t.Fatalf("add third project: %v", err)
	}

	app := NewApp()
	if err := app.ReorderProjects([]string{third, first, second}); err != nil {
		t.Fatalf("ReorderProjects: %v", err)
	}

	nodes := app.ListProjectTree()
	if len(nodes) != 3 {
		t.Fatalf("project tree len = %d, want 3: %+v", len(nodes), nodes)
	}
	if got := []string{nodes[0].Root, nodes[1].Root, nodes[2].Root}; got[0] != third || got[1] != first || got[2] != second {
		t.Fatalf("project tree order = %v, want %v", got, []string{third, first, second})
	}
	workspaces := app.ListWorkspaces()
	if len(workspaces) != 3 {
		t.Fatalf("workspaces len = %d, want 3: %+v", len(workspaces), workspaces)
	}
	if got := []string{workspaces[0].Path, workspaces[1].Path, workspaces[2].Path}; got[0] != third || got[1] != first || got[2] != second {
		t.Fatalf("workspace order = %v, want %v", got, []string{third, first, second})
	}
}

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

	first := t.TempDir()
	second := t.TempDir()
	if err := addProject(first, "First"); err != nil {
		t.Fatalf("add first project: %v", err)
	}
	if err := addProject(second, "Second"); err != nil {
		t.Fatalf("add second project: %v", err)
	}

	app := NewApp()
	if _, err := app.CreateTopic("global", "", "Global note"); err != nil {
		t.Fatalf("create global topic: %v", err)
	}
	if err := app.ReorderProjects([]string{second, desktopGlobalOrderToken, first}); err != nil {
		t.Fatalf("ReorderProjects with global: %v", err)
	}

	nodes := app.ListProjectTree()
	if len(nodes) != 3 {
		t.Fatalf("project tree len = %d, want 3: %+v", len(nodes), nodes)
	}
	if got := []string{nodes[0].Root, nodes[1].Kind, nodes[2].Root}; got[0] != second || got[1] != "global_folder" || got[2] != first {
		t.Fatalf("project tree order = %v, want [%s global_folder %s]", got, second, first)
	}
	workspaces := app.ListWorkspaces()
	if len(workspaces) != 2 {
		t.Fatalf("workspaces len = %d, want 2: %+v", len(workspaces), workspaces)
	}
	if got := []string{workspaces[0].Path, workspaces[1].Path}; got[0] != second || got[1] != first {
		t.Fatalf("workspace order = %v, want %v", got, []string{second, first})
	}
}

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

	first := t.TempDir()
	second := t.TempDir()
	if err := addProject(first, "First"); err != nil {
		t.Fatalf("add first project: %v", err)
	}
	if err := addProject(second, "Second"); err != nil {
		t.Fatalf("add second project: %v", err)
	}
	app := NewApp()
	for name, order := range map[string][]string{
		"missing":          {first},
		"unknown":          {first, filepath.Join(t.TempDir(), "missing")},
		"duplicate":        {first, first},
		"duplicate-global": {desktopGlobalOrderToken, first, desktopGlobalOrderToken, second},
	} {
		t.Run(name, func(t *testing.T) {
			if err := app.ReorderProjects(order); err == nil {
				t.Fatalf("ReorderProjects(%v) succeeded, want error", order)
			}
		})
	}

	nodes := app.ListProjectTree()
	if got := []string{nodes[0].Root, nodes[1].Root}; got[0] != first || got[1] != second {
		t.Fatalf("project tree order changed after invalid reorder: %v", got)
	}
}

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

	projectRoot := t.TempDir()
	if err := addProject(projectRoot, "Current Project"); err != nil {
		t.Fatalf("add project: %v", err)
	}
	app := NewApp()
	tab := app.createTabEntryWithID("project", projectRoot, "topic_current", "tab_current")
	app.tabs[tab.ID] = tab
	app.tabOrder = []string{tab.ID}
	app.activeTabID = tab.ID

	if err := app.RemoveWorkspace(projectRoot); err != nil {
		t.Fatalf("remove current project: %v", err)
	}
	if got := app.ListWorkspaces(); len(got) != 0 {
		t.Fatalf("workspaces after remove = %+v, want empty", got)
	}
	if got := app.ListProjectTree(); len(got) != 1 || got[0].Kind != "global_folder" || len(got[0].Children) != 0 {
		t.Fatalf("project tree after remove = %+v, want empty Global folder", got)
	}
}

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

	projectRoot := t.TempDir()
	topicID := "topic_stored_title"
	if err := addProject(projectRoot, ""); err != nil {
		t.Fatalf("add project: %v", err)
	}
	if err := setTopicTitle(projectRoot, topicID, "你是谁"); err != nil {
		t.Fatalf("set topic title: %v", err)
	}

	app := NewApp()
	tab := app.createTabEntryWithID("project", projectRoot, topicID, "tab1")
	app.tabs[tab.ID] = tab
	app.tabOrder = []string{tab.ID}
	app.activeTabID = tab.ID

	tabs := app.ListTabs()
	if len(tabs) != 1 {
		t.Fatalf("tabs len = %d, want 1", len(tabs))
	}
	if got := tabs[0].TopicTitle; got != "你是谁" {
		t.Fatalf("tab title = %q, want 你是谁", got)
	}
	nodes := app.ListProjectTree()
	if len(nodes) != 1 || len(nodes[0].Children) != 1 {
		t.Fatalf("project tree = %#v, want one project with one topic", nodes)
	}
	if got := nodes[0].Children[0].Label; got != tabs[0].TopicTitle {
		t.Fatalf("tree title = %q, want same as tab title %q", got, tabs[0].TopicTitle)
	}
}

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

	projectRoot := t.TempDir()
	topicID := "topic_without_title"
	if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{
		Root:   projectRoot,
		Topics: []string{topicID},
	}}}); err != nil {
		t.Fatalf("save projects: %v", err)
	}

	app := NewApp()
	tab := app.createTabEntryWithID("project", projectRoot, topicID, "tab1")
	app.tabs[tab.ID] = tab
	app.tabOrder = []string{tab.ID}
	app.activeTabID = tab.ID

	tabs := app.ListTabs()
	if len(tabs) != 1 {
		t.Fatalf("tabs len = %d, want 1", len(tabs))
	}
	if got := tabs[0].TopicTitle; got != defaultTopicTitle {
		t.Fatalf("tab title = %q, want %q", got, defaultTopicTitle)
	}
	nodes := app.ListProjectTree()
	if len(nodes) != 1 || len(nodes[0].Children) != 1 {
		t.Fatalf("project tree = %#v, want one project with one topic", nodes)
	}
	if got := nodes[0].Children[0].Label; got != defaultTopicTitle {
		t.Fatalf("tree title = %q, want %q", got, defaultTopicTitle)
	}
}

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

	projectRoot := t.TempDir()
	before := time.Now().UnixMilli()
	topic, err := NewApp().CreateTopic("project", projectRoot, "")
	after := time.Now().UnixMilli()
	if err != nil {
		t.Fatalf("create topic: %v", err)
	}
	if got := topic.Title; got != defaultTopicTitle {
		t.Fatalf("topic title = %q, want %q", got, defaultTopicTitle)
	}
	if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
		t.Fatalf("stored title = %q, want %q", got, defaultTopicTitle)
	}
	if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceAuto {
		t.Fatalf("title source = %q, want auto", got)
	}
	if got := loadTopicCreatedAt(projectRoot, topic.ID); got < before || got > after {
		t.Fatalf("createdAt = %d, want between %d and %d", got, before, after)
	}
	nodes := NewApp().ListProjectTree()
	if len(nodes) != 1 || len(nodes[0].Children) != 1 {
		t.Fatalf("project tree = %#v, want one project with one topic", nodes)
	}
	if got := nodes[0].Children[0].CreatedAt; got != topic.CreatedAt {
		t.Fatalf("project tree createdAt = %d, want %d", got, topic.CreatedAt)
	}
}

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

	const topicID = "legacy_20260606-114914_2276f13fd87c"
	if err := setTopicTitleWithSource("", topicID, "你好，你是谁", topicTitleSourceManual); err != nil {
		t.Fatalf("set topic title: %v", err)
	}
	if err := prependTopicInProjectsFile("", topicID, false); err != nil {
		t.Fatalf("prepend topic: %v", err)
	}

	nodes := NewApp().ListProjectTree()
	if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 {
		t.Fatalf("project tree = %#v, want Global with one topic", nodes)
	}
	expected := time.Date(2026, 6, 6, 11, 49, 14, 0, time.UTC).UnixMilli()
	if got := nodes[0].Children[0].CreatedAt; got != expected {
		t.Fatalf("project tree createdAt = %d, want %d", got, expected)
	}
}

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

	projectRoot := t.TempDir()
	app := NewApp()
	first, err := app.CreateTopic("project", projectRoot, "")
	if err != nil {
		t.Fatalf("create first topic: %v", err)
	}
	waitForLaterTopicTimestamp(first.CreatedAt)
	second, err := app.CreateTopic("project", projectRoot, "")
	if err != nil {
		t.Fatalf("create second topic: %v", err)
	}
	nodes := app.ListProjectTree()
	if len(nodes) != 1 || len(nodes[0].Children) != 2 {
		t.Fatalf("project tree = %#v, want one project with two topics", nodes)
	}
	if got := nodes[0].Children[0].TopicID; got != second.ID {
		t.Fatalf("first visible topic = %q, want newest %q", got, second.ID)
	}
	if got := nodes[0].Children[1].TopicID; got != first.ID {
		t.Fatalf("second visible topic = %q, want older %q", got, first.ID)
	}
}

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

	app := NewApp()
	first, err := app.CreateTopic("global", "", "")
	if err != nil {
		t.Fatalf("create first global topic: %v", err)
	}
	waitForLaterTopicTimestamp(first.CreatedAt)
	second, err := app.CreateTopic("global", "", "")
	if err != nil {
		t.Fatalf("create second global topic: %v", err)
	}
	nodes := app.ListProjectTree()
	if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 2 {
		t.Fatalf("project tree = %#v, want Global with two topics", nodes)
	}
	if got := nodes[0].Children[0].TopicID; got != second.ID {
		t.Fatalf("first visible global topic = %q, want newest %q", got, second.ID)
	}
	if got := nodes[0].Children[1].TopicID; got != first.ID {
		t.Fatalf("second visible global topic = %q, want older %q", got, first.ID)
	}
}

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

	nodes := NewApp().ListProjectTree()
	if len(nodes) != 1 {
		t.Fatalf("project tree = %#v, want one Global folder", nodes)
	}
	if nodes[0].Kind != "global_folder" || nodes[0].Label != "Global" || len(nodes[0].Children) != 0 {
		t.Fatalf("project tree = %#v, want empty Global folder", nodes)
	}
}

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

	projectRoot := t.TempDir()
	app := NewApp()
	if got, err := app.SwitchWorkspace(projectRoot); err != nil {
		t.Fatalf("SwitchWorkspace: %v", err)
	} else if got != projectRoot {
		t.Fatalf("SwitchWorkspace root = %q, want %q", got, projectRoot)
	}

	nodes := app.ListProjectTree()
	if len(nodes) != 1 {
		t.Fatalf("project tree len = %d, want 1: %+v", len(nodes), nodes)
	}
	if got := nodes[0].Root; got != projectRoot {
		t.Fatalf("project root = %q, want %q", got, projectRoot)
	}
	if len(nodes[0].Children) != 1 {
		t.Fatalf("project children len = %d, want 1: %+v", len(nodes[0].Children), nodes[0].Children)
	}
	child := nodes[0].Children[0]
	if got := child.Label; got != defaultTopicTitle {
		t.Fatalf("default topic label = %q, want %q", got, defaultTopicTitle)
	}
	if strings.TrimSpace(child.TopicID) == "" {
		t.Fatalf("default topic ID should be persisted in the project tree: %+v", child)
	}
	tabs := app.ListTabs()
	if len(tabs) != 1 || tabs[0].TopicID != child.TopicID {
		t.Fatalf("opened tab should use the persisted topic, tabs=%+v child=%+v", tabs, child)
	}
}

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

	projectRoot := t.TempDir()
	app := NewApp()
	topic, err := app.CreateTopic("project", projectRoot, "")
	if err != nil {
		t.Fatalf("create topic: %v", err)
	}
	if err := app.RenameTopic(topic.ID, "手动标题"); err != nil {
		t.Fatalf("rename topic: %v", err)
	}
	if got := loadTopicTitle(projectRoot, topic.ID); got != "手动标题" {
		t.Fatalf("stored title = %q, want 手动标题", got)
	}
	if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
		t.Fatalf("title source = %q, want manual", got)
	}
}

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

	projectRoot := t.TempDir()
	app := NewApp()
	topic, err := app.CreateTopic("project", projectRoot, "旧标题")
	if err != nil {
		t.Fatalf("create topic: %v", err)
	}
	tab, err := app.OpenProjectTab(projectRoot, topic.ID)
	if err != nil {
		t.Fatalf("open project tab: %v", err)
	}
	waitForTabReady(t, app, tab.ID)
	if tab.TopicTitle != "旧标题" {
		t.Fatalf("opened tab title = %q, want 旧标题", tab.TopicTitle)
	}

	if err := app.RenameTopic(topic.ID, "新标题"); err != nil {
		t.Fatalf("rename topic: %v", err)
	}
	tabs := app.ListTabs()
	if len(tabs) != 1 {
		t.Fatalf("tabs len = %d, want 1: %+v", len(tabs), tabs)
	}
	if got := tabs[0].TopicTitle; got != "新标题" {
		t.Fatalf("open tab title = %q, want 新标题", got)
	}
}

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

	projectRoot := t.TempDir()
	app := NewApp()
	topic, err := app.CreateTopic("project", projectRoot, "旧标题")
	if err != nil {
		t.Fatalf("create topic: %v", err)
	}
	tab, err := app.OpenProjectTab(projectRoot, topic.ID)
	if err != nil {
		t.Fatalf("open project tab: %v", err)
	}
	waitForTabReady(t, app, tab.ID)
	if err := saveTopicTitles(projectRoot, map[string]string{}); err != nil {
		t.Fatalf("clear topic titles: %v", err)
	}
	if err := saveTopicTitleSources(projectRoot, map[string]string{}); err != nil {
		t.Fatalf("clear topic title sources: %v", err)
	}

	if err := app.RenameTopic(topic.ID, "恢复标题"); err != nil {
		t.Fatalf("rename topic after deleting title index: %v", err)
	}
	if got := loadTopicTitle(projectRoot, topic.ID); got != "恢复标题" {
		t.Fatalf("restored topic title = %q, want 恢复标题", got)
	}
	nodes := app.ListProjectTree()
	if len(nodes) != 1 || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topic.ID {
		t.Fatalf("project tree should still contain topic, got %#v", nodes)
	}
}

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

	projectRoot := t.TempDir()
	topicID := "topic_missing_index"
	if err := addProject(projectRoot, ""); err != nil {
		t.Fatalf("add project: %v", err)
	}
	if err := setTopicTitle(projectRoot, topicID, "旧标题"); err != nil {
		t.Fatalf("set topic title: %v", err)
	}
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	writeTopicSession(t, dir, "missing-index.jsonl", topicID, "旧标题", projectRoot)
	if err := saveTopicTitles(projectRoot, map[string]string{}); err != nil {
		t.Fatalf("clear topic titles: %v", err)
	}
	if err := saveTopicTitleSources(projectRoot, map[string]string{}); err != nil {
		t.Fatalf("clear topic title sources: %v", err)
	}

	if err := NewApp().RenameTopic(topicID, "恢复标题"); err != nil {
		t.Fatalf("rename topic from session meta after deleting title index: %v", err)
	}
	if got := loadTopicTitle(projectRoot, topicID); got != "恢复标题" {
		t.Fatalf("restored topic title = %q, want 恢复标题", got)
	}
	nodes := NewApp().ListProjectTree()
	if len(nodes) != 1 || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
		t.Fatalf("project tree should contain restored topic, got %#v", nodes)
	}
}

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

	projectRoot := robustTempDir(t)
	app := NewApp()
	topic, err := app.CreateTopic("project", projectRoot, "旧标题")
	if err != nil {
		t.Fatalf("create topic: %v", err)
	}
	dir := desktopSessionDir(projectRoot)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	sessionPath := writeTopicSessionWithPrompt(t, dir, "stored-meta.jsonl", topic.ID, "用户保存标题", projectRoot, "first prompt should not win", time.Now())
	if err := saveTopicTitles(projectRoot, map[string]string{}); err != nil {
		t.Fatalf("clear topic titles: %v", err)
	}
	if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
		t.Fatalf("precondition title source = %q, want manual", got)
	}

	meta, err := app.OpenProjectTab(projectRoot, topic.ID)
	if err != nil {
		t.Fatalf("open project tab: %v", err)
	}
	tab := waitForTabReady(t, app, meta.ID)
	if got := filepath.Clean(tab.Ctrl.SessionPath()); got != filepath.Clean(sessionPath) {
		t.Fatalf("opened session path = %q, want %q", got, sessionPath)
	}
	if got := meta.TopicTitle; got != "用户保存标题" {
		t.Fatalf("opened topic title = %q, want 用户保存标题", got)
	}
	if got := loadTopicTitle(projectRoot, topic.ID); got != "用户保存标题" {
		t.Fatalf("stored topic title = %q, want 用户保存标题", got)
	}
	if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
		t.Fatalf("title source = %q, want manual", got)
	}
}

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

	projectRoot := robustTempDir(t)
	app := NewApp()
	topic, err := app.CreateTopic("project", projectRoot, "旧标题")
	if err != nil {
		t.Fatalf("create topic: %v", err)
	}
	dir := desktopSessionDir(projectRoot)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	sessionPath := writeTopicSessionWithPrompt(t, dir, "stored-session-title.jsonl", topic.ID, "", projectRoot, "first prompt should not win", time.Now())
	if err := setSessionTitle(dir, sessionPath, "历史手动标题"); err != nil {
		t.Fatalf("set session title: %v", err)
	}
	if err := saveTopicTitles(projectRoot, map[string]string{}); err != nil {
		t.Fatalf("clear topic titles: %v", err)
	}
	if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
		t.Fatalf("precondition title source = %q, want manual", got)
	}

	meta, err := app.OpenProjectTab(projectRoot, topic.ID)
	if err != nil {
		t.Fatalf("open project tab: %v", err)
	}
	waitForTabReady(t, app, meta.ID)
	if got := meta.TopicTitle; got != "历史手动标题" {
		t.Fatalf("opened topic title = %q, want 历史手动标题", got)
	}
	if got := loadTopicTitle(projectRoot, topic.ID); got != "历史手动标题" {
		t.Fatalf("stored topic title = %q, want 历史手动标题", got)
	}
	if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
		t.Fatalf("title source = %q, want manual", got)
	}
}

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

	projectRoot := robustTempDir(t)
	app := NewApp()
	topic, err := app.CreateTopic("project", projectRoot, "")
	if err != nil {
		t.Fatalf("create topic: %v", err)
	}
	if err := app.RenameTopic(topic.ID, defaultTopicTitle); err != nil {
		t.Fatalf("rename topic: %v", err)
	}
	dir := desktopSessionDir(projectRoot)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	writeTopicSessionWithPrompt(t, dir, "manual-default.jsonl", topic.ID, defaultTopicTitle, projectRoot, "first prompt should not replace manual default", time.Now())

	meta, err := app.OpenProjectTab(projectRoot, topic.ID)
	if err != nil {
		t.Fatalf("open project tab: %v", err)
	}
	waitForTabReady(t, app, meta.ID)
	if got := meta.TopicTitle; got != defaultTopicTitle {
		t.Fatalf("opened topic title = %q, want %q", got, defaultTopicTitle)
	}
	if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
		t.Fatalf("stored topic title = %q, want %q", got, defaultTopicTitle)
	}
	if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
		t.Fatalf("title source = %q, want manual", got)
	}
}

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

	topicID := "topic_global_auto"
	if err := setTopicTitleWithSource("", topicID, defaultTopicTitle, topicTitleSourceAuto); err != nil {
		t.Fatalf("set global topic title: %v", err)
	}
	source := loadTopicTitleSource(topicTitleRoot("global", globalTabWorkspaceRoot()), topicID)
	if err := ensureTopicIndexed("global", globalTabWorkspaceRoot(), topicID, defaultTopicTitle, source); err != nil {
		t.Fatalf("ensure global topic indexed: %v", err)
	}

	if got := loadTopicTitleSource("", topicID); got != topicTitleSourceAuto {
		t.Fatalf("global title source = %q, want %q", got, topicTitleSourceAuto)
	}
}

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

	projectRoot := t.TempDir()
	topic, err := NewApp().CreateTopic("project", projectRoot, "")
	if err != nil {
		t.Fatalf("create topic: %v", err)
	}
	sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
	if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"讲讲这个代码库的架构"}`+"\n"), 0o644); err != nil {
		t.Fatalf("write session: %v", err)
	}

	title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath)
	if !updated {
		t.Fatal("auto title should update")
	}
	if title != "讲讲这个代码库的架构" {
		t.Fatalf("generated title = %q", title)
	}
	if got := loadTopicTitle(projectRoot, topic.ID); got != title {
		t.Fatalf("stored title = %q, want %q", got, title)
	}
	if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceAuto {
		t.Fatalf("title source = %q, want auto", got)
	}
}

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

	projectRoot := t.TempDir()
	topic, err := NewApp().CreateTopic("project", projectRoot, "")
	if err != nil {
		t.Fatalf("create topic: %v", err)
	}
	prompt := control.New(control.Options{ReasoningLanguage: "zh"}).Compose("讲讲这个代码库的架构")
	sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
	if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":`+strconv.Quote(prompt)+`}`+"\n"), 0o644); err != nil {
		t.Fatalf("write session: %v", err)
	}

	title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath)
	if !updated {
		t.Fatal("auto title should update")
	}
	if title != "讲讲这个代码库的架构" {
		t.Fatalf("generated title = %q", title)
	}
}

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

	projectRoot := t.TempDir()
	topic, err := NewApp().CreateTopic("project", projectRoot, "")
	if err != nil {
		t.Fatalf("create topic: %v", err)
	}
	sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
	firstTurn := strings.Join([]string{
		`{"role":"user","content":"帮我看看"}`,
		`{"role":"assistant","content":"可以"}`,
	}, "\n") + "\n"
	if err := os.WriteFile(sessionPath, []byte(firstTurn), 0o644); err != nil {
		t.Fatalf("write first session: %v", err)
	}

	title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath)
	if !updated || title != "帮我看看" {
		t.Fatalf("first auto title = %q updated=%v, want 帮我看看/true", title, updated)
	}

	thirdTurn := strings.Join([]string{
		`{"role":"user","content":"帮我看看"}`,
		`{"role":"assistant","content":"可以"}`,
		`{"role":"user","content":"继续"}`,
		`{"role":"assistant","content":"继续分析"}`,
		`{"role":"user","content":"实现自动更新会话标题"}`,
		`{"role":"assistant","content":"已实现"}`,
	}, "\n") + "\n"
	if err := os.WriteFile(sessionPath, []byte(thirdTurn), 0o644); err != nil {
		t.Fatalf("write third session: %v", err)
	}

	title, updated = autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath)
	if !updated || title != "实现自动更新会话标题" {
		t.Fatalf("third-turn auto title = %q updated=%v, want 实现自动更新会话标题/true", title, updated)
	}
	if got := loadTopicTitle(projectRoot, topic.ID); got != "实现自动更新会话标题" {
		t.Fatalf("stored title = %q, want 实现自动更新会话标题", got)
	}
	meta := loadTopicAutoTitleMeta(projectRoot)[topic.ID]
	if meta.Stage != 3 {
		t.Fatalf("auto title stage = %d, want 3", meta.Stage)
	}
}

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

	projectRoot := t.TempDir()
	app := NewApp()
	topic, err := app.CreateTopic("project", projectRoot, "")
	if err != nil {
		t.Fatalf("create topic: %v", err)
	}
	if err := app.RenameTopic(topic.ID, "手动标题"); err != nil {
		t.Fatalf("rename topic: %v", err)
	}
	sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
	if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"讲讲这个代码库的架构"}`+"\n"), 0o644); err != nil {
		t.Fatalf("write session: %v", err)
	}

	if title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath); updated || title != "" {
		t.Fatalf("manual title should not auto-update, title=%q updated=%v", title, updated)
	}
	if got := loadTopicTitle(projectRoot, topic.ID); got != "手动标题" {
		t.Fatalf("stored title = %q, want 手动标题", got)
	}
}

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

	projectRoot := t.TempDir()
	topic, err := NewApp().CreateTopic("project", projectRoot, "")
	if err != nil {
		t.Fatalf("create topic: %v", err)
	}
	sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
	if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"讲讲这个代码库的架构"}`+"\n"), 0o644); err != nil {
		t.Fatalf("write session: %v", err)
	}
	if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{CustomTitle: "手动会话标题"}); err != nil {
		t.Fatalf("save branch meta: %v", err)
	}

	if title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath); updated || title != "" {
		t.Fatalf("manual session title should not auto-update, title=%q updated=%v", title, updated)
	}
	if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
		t.Fatalf("stored title = %q, want default title", got)
	}
}

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

	projectRoot := t.TempDir()
	app := NewApp()
	topic, err := app.CreateTopic("project", projectRoot, "")
	if err != nil {
		t.Fatalf("create topic: %v", err)
	}
	if err := app.RenameTopic(topic.ID, "   "); err != nil {
		t.Fatalf("rename blank topic: %v", err)
	}
	if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
		t.Fatalf("stored title = %q, want %q", got, defaultTopicTitle)
	}
	if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
		t.Fatalf("title source = %q, want manual", got)
	}
}

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

	projectRoot := t.TempDir()
	topicID := "topic_trash_history"
	if err := addProject(projectRoot, ""); err != nil {
		t.Fatalf("add project: %v", err)
	}
	if err := setTopicTitle(projectRoot, topicID, "Trash history"); err != nil {
		t.Fatalf("set topic title: %v", err)
	}
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	sessionPath := writeTopicSession(t, dir, "trash-me.jsonl", topicID, "Trash history", projectRoot)
	placeholderPath := filepath.Join(dir, "trash-placeholder-session.jsonl")
	if err := os.WriteFile(placeholderPath, nil, 0o644); err != nil {
		t.Fatalf("write placeholder session: %v", err)
	}
	now := time.Now()
	if err := agent.SaveBranchMetaPreserveUpdated(placeholderPath, agent.BranchMeta{
		CreatedAt:     now.Add(-time.Minute),
		UpdatedAt:     now,
		Scope:         "project",
		WorkspaceRoot: projectRoot,
		TopicID:       topicID,
		TopicTitle:    "Trash history",
	}); err != nil {
		t.Fatalf("save placeholder branch meta: %v", err)
	}
	placeholderGoalPath := strings.TrimSuffix(placeholderPath, ".jsonl") + ".goal-state.json"
	if err := os.WriteFile(placeholderGoalPath, []byte(`{"done":true}`), 0o644); err != nil {
		t.Fatalf("write placeholder goal state: %v", err)
	}
	ref := "sa_20260102_030405_000000000_aabbccddeeff"
	writeSubagentArtifact(t, dir, ref, agent.BranchID(sessionPath))

	if err := NewApp().TrashTopic(topicID); err != nil {
		t.Fatalf("trash topic: %v", err)
	}
	if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
		t.Fatalf("topic session should be removed from active history, stat err = %v", err)
	}
	trashPath := filepath.Join(dir, sessionTrashDir, "trash-me.jsonl", "trash-me.jsonl")
	if _, err := os.Stat(trashPath); err != nil {
		t.Fatalf("topic session should be moved to trash: %v", err)
	}
	if _, err := os.Stat(placeholderPath); !os.IsNotExist(err) {
		t.Fatalf("placeholder session should be removed from active history, stat err = %v", err)
	}
	placeholderTrashDir := filepath.Join(dir, sessionTrashDir, "trash-placeholder-session.jsonl")
	if _, err := os.Stat(filepath.Join(placeholderTrashDir, "trash-placeholder-session.jsonl")); err != nil {
		t.Fatalf("placeholder session should be moved to trash: %v", err)
	}
	if _, err := os.Stat(filepath.Join(placeholderTrashDir, "trash-placeholder-session.jsonl.meta")); err != nil {
		t.Fatalf("placeholder meta should be moved to trash: %v", err)
	}
	if _, err := os.Stat(filepath.Join(placeholderTrashDir, "trash-placeholder-session.goal-state.json")); err != nil {
		t.Fatalf("placeholder goal state should be moved to trash: %v", err)
	}
	if _, err := os.Stat(filepath.Join(dir, sessionTrashDir, "trash-me.jsonl", "subagents", ref+".jsonl")); err != nil {
		t.Fatalf("topic subagent should be moved to trash: %v", err)
	}
	if got := loadTopicTitle(projectRoot, topicID); got != "" {
		t.Fatalf("topic title should be removed, got %q", got)
	}
}

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

	projectRoot := t.TempDir()
	topicID := "topic_missing_trash"
	if err := addProject(projectRoot, ""); err != nil {
		t.Fatalf("add project: %v", err)
	}
	if err := setTopicTitle(projectRoot, topicID, "Missing trash"); err != nil {
		t.Fatalf("set topic title: %v", err)
	}
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	missingPath := filepath.Join(dir, "already-gone.jsonl")
	app := &App{
		tabs: map[string]*WorkspaceTab{
			"stale": {
				ID:            "stale",
				Scope:         "project",
				WorkspaceRoot: projectRoot,
				TopicID:       topicID,
				TopicTitle:    "Missing trash",
				SessionPath:   missingPath,
				Ready:         true,
				disabledMCP:   map[string]ServerView{},
			},
			"other": {ID: "other", Scope: "project", WorkspaceRoot: projectRoot, TopicID: "other", Ready: true},
		},
		tabOrder:    []string{"stale", "other"},
		activeTabID: "stale",
	}

	if err := app.TrashTopic(topicID); err != nil {
		t.Fatalf("TrashTopic should remove stale missing session: %v", err)
	}
	if got := loadTopicTitle(projectRoot, topicID); got != "" {
		t.Fatalf("topic title should be removed, got %q", got)
	}
	if _, ok := app.tabs["stale"]; ok {
		t.Fatalf("stale tab should be removed")
	}
	if got := app.activeTabID; got != "other" {
		t.Fatalf("active tab = %q, want other", got)
	}
}

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

	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	sessionPath := writeLegacySession(t, dir, "restore-global.jsonl", "restore global history", time.Now().Add(-time.Hour))
	topicID := legacySessionTopicID(sessionPath)
	app := NewApp()

	nodes := waitForCatalogTopic(t, app, "global", "", topicID)
	if len(nodes) != 1 || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
		t.Fatalf("legacy session should start in Global, got %#v", nodes)
	}
	if err := app.TrashTopic(topicID); err != nil {
		t.Fatalf("trash global topic: %v", err)
	}
	trashPath := filepath.Join(dir, sessionTrashDir, "restore-global.jsonl", "restore-global.jsonl")
	if _, err := os.Stat(trashPath); err != nil {
		t.Fatalf("global session should be in trash: %v", err)
	}
	if got := app.ListProjectTree(); len(got) != 1 || got[0].Kind != "global_folder" || len(got[0].Children) != 0 {
		t.Fatalf("trashed global topic should leave empty Global folder, got %#v", got)
	}

	if err := app.RestoreSession(trashPath); err != nil {
		t.Fatalf("restore global session: %v", err)
	}
	if got := app.ListTrashedSessions(); len(got) != 0 {
		t.Fatalf("trash should be empty after restore, got %#v", got)
	}
	nodes = waitForCatalogTopic(t, app, "global", "", topicID)
	if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
		t.Fatalf("restored global session should reappear in Global, got %#v", nodes)
	}
}

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

	projectRoot := t.TempDir()
	topicID := "topic_restore_project"
	if err := addProject(projectRoot, ""); err != nil {
		t.Fatalf("add project: %v", err)
	}
	if err := setTopicTitle(projectRoot, topicID, "Project restore"); err != nil {
		t.Fatalf("set topic title: %v", err)
	}
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	writeTopicSession(t, dir, "restore-project.jsonl", topicID, "Project restore", projectRoot)
	app := NewApp()

	if err := app.TrashTopic(topicID); err != nil {
		t.Fatalf("trash project topic: %v", err)
	}
	trashPath := filepath.Join(dir, sessionTrashDir, "restore-project.jsonl", "restore-project.jsonl")
	if _, err := os.Stat(trashPath); err != nil {
		t.Fatalf("project session should be in trash: %v", err)
	}
	if got := loadTopicTitle(projectRoot, topicID); got != "" {
		t.Fatalf("topic title should be removed while trashed, got %q", got)
	}

	if err := app.RestoreSession(trashPath); err != nil {
		t.Fatalf("restore project session: %v", err)
	}
	nodes := waitForCatalogTopic(t, app, "project", projectRoot, topicID)
	if len(nodes) != 1 || nodes[0].Kind != "project" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
		t.Fatalf("restored project session should reappear in project tree, got %#v", nodes)
	}
	if got := loadTopicTitle(projectRoot, topicID); got != "Project restore" {
		t.Fatalf("restored topic title = %q, want Project restore", got)
	}
}

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

	projectRoot := t.TempDir()
	topicID := "topic_legacy_project"
	topicTitle := "Legacy project topic"
	if err := addProject(projectRoot, ""); err != nil {
		t.Fatalf("add project: %v", err)
	}
	if err := setTopicTitle(projectRoot, topicID, topicTitle); err != nil {
		t.Fatalf("set topic title: %v", err)
	}
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	sessionPath := writeTopicSessionWithPrompt(t, dir, "legacy-project.jsonl", topicID, topicTitle, projectRoot, "legacy project prompt", time.Now())
	app := NewApp()

	nodes := waitForCatalogTopic(t, app, "project", projectRoot, topicID)
	if len(nodes) != 1 || nodes[0].Kind != "project" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
		t.Fatalf("legacy project session should appear in project tree, got %#v", nodes)
	}
	meta, err := app.OpenProjectTab(projectRoot, topicID)
	if err != nil {
		t.Fatalf("OpenProjectTab: %v", err)
	}
	tab := waitForTabReady(t, app, meta.ID)
	if tab.Ctrl == nil {
		t.Fatalf("tab controller was not built")
	}
	if got := filepath.Clean(tab.Ctrl.SessionPath()); got != filepath.Clean(sessionPath) {
		t.Fatalf("opened session path = %q, want %q", got, sessionPath)
	}
	history := tab.Ctrl.History()
	if len(history) != 2 || string(history[0].Role) != "system" || strings.TrimSpace(history[0].Content) == "" ||
		string(history[1].Role) != "user" || history[1].Content != "legacy project prompt" {
		t.Fatalf("opened history = %+v, want fresh system prompt and legacy project prompt", history)
	}
}

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

	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	sessionPath := writeLegacySession(t, dir, "restore-orphan.jsonl", "restore orphan history", time.Now().Add(-time.Hour))
	topicID := legacySessionTopicID(sessionPath)
	app := NewApp()
	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "test"})
	app.setTestCtrl(ctrl, "")
	defer ctrl.Close()
	if err := app.DeleteSession(sessionPath); err != nil {
		t.Fatalf("delete orphan session: %v", err)
	}
	trashPath := filepath.Join(dir, sessionTrashDir, "restore-orphan.jsonl", "restore-orphan.jsonl")

	if err := app.RestoreSession(trashPath); err != nil {
		t.Fatalf("restore orphan session: %v", err)
	}
	nodes := app.ListProjectTree()
	if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
		t.Fatalf("restored orphan session should fall back to Global, got %#v", nodes)
	}
}

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

	projectRoot := t.TempDir()
	topicID := "topic_open_trash"
	if err := addProject(projectRoot, ""); err != nil {
		t.Fatalf("add project: %v", err)
	}
	if err := setTopicTitle(projectRoot, topicID, "Open trash"); err != nil {
		t.Fatalf("set topic title: %v", err)
	}
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	sessionPath := filepath.Join(dir, "open-trash.jsonl")
	if err := agent.SaveBranchMeta(sessionPath, agent.BranchMeta{
		CreatedAt:     time.Now().Add(-time.Minute),
		UpdatedAt:     time.Now(),
		Scope:         "project",
		WorkspaceRoot: projectRoot,
		TopicID:       topicID,
		TopicTitle:    "Open trash",
	}); err != nil {
		t.Fatalf("save branch meta: %v", err)
	}
	openTab := &WorkspaceTab{
		ID:            "tab_open",
		Scope:         "project",
		WorkspaceRoot: projectRoot,
		TopicID:       topicID,
		TopicTitle:    "Open trash",
		Ctrl:          controllerWithContent(t, sessionPath),
		Ready:         true,
		disabledMCP:   map[string]ServerView{},
	}
	otherTab := &WorkspaceTab{
		ID:            "tab_other",
		Scope:         "project",
		WorkspaceRoot: projectRoot,
		TopicID:       "topic_keep",
		TopicTitle:    "Keep",
		Ready:         true,
		disabledMCP:   map[string]ServerView{},
	}
	app := &App{
		tabs:        map[string]*WorkspaceTab{"tab_open": openTab, "tab_other": otherTab},
		tabOrder:    []string{"tab_open", "tab_other"},
		activeTabID: "tab_open",
	}

	if err := app.TrashTopic(topicID); err != nil {
		t.Fatalf("trash topic: %v", err)
	}
	if _, ok := app.tabs["tab_open"]; ok {
		t.Fatalf("open tab for trashed topic should be removed")
	}
	if got := app.activeTabID; got != "tab_other" {
		t.Fatalf("active tab = %q, want tab_other", got)
	}
	if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
		t.Fatalf("open topic session should be removed from active history, stat err = %v", err)
	}
	trashPath := filepath.Join(dir, sessionTrashDir, "open-trash.jsonl", "open-trash.jsonl")
	if _, err := os.Stat(trashPath); err != nil {
		t.Fatalf("open topic session should be moved to trash: %v", err)
	}
	if agent.IsCleanupPending(sessionPath) {
		t.Fatal("completed topic archive left a cleanup-pending marker")
	}
	trashed := app.ListTrashedSessions()
	if len(trashed) != 1 || trashed[0].Path != trashPath {
		t.Fatalf("trashed sessions = %#v, want %q", trashed, trashPath)
	}
	preview, err := app.PreviewSession(trashPath)
	if err != nil {
		t.Fatalf("preview trashed session: %v", err)
	}
	if !hasHistoryContent(preview, "remember this turn") {
		t.Fatalf("preview trashed session = %#v, want remembered turn", preview)
	}
	if got := loadTopicTitle(projectRoot, topicID); got != "" {
		t.Fatalf("topic title should be removed, got %q", got)
	}
}

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

	projectRoot := t.TempDir()
	topicID := "topic_running_trash"
	if err := addProject(projectRoot, ""); err != nil {
		t.Fatalf("add project: %v", err)
	}
	if err := setTopicTitle(projectRoot, topicID, "Running trash"); err != nil {
		t.Fatalf("set topic title: %v", err)
	}
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	sessionPath := writeTopicSession(t, dir, "running-trash.jsonl", topicID, "Running trash", projectRoot)
	runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
	ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: sessionPath, Label: "test", WorkspaceRoot: projectRoot})
	defer ctrl.Close()
	app := &App{
		tabs: map[string]*WorkspaceTab{
			"running": {
				ID:            "running",
				Scope:         "project",
				WorkspaceRoot: projectRoot,
				TopicID:       topicID,
				TopicTitle:    "Running trash",
				Ctrl:          ctrl,
				Ready:         true,
				disabledMCP:   map[string]ServerView{},
			},
			"keep": {
				ID:            "keep",
				Scope:         "project",
				WorkspaceRoot: projectRoot,
				TopicID:       "topic_keep",
				TopicTitle:    "Keep",
				Ready:         true,
				disabledMCP:   map[string]ServerView{},
			},
		},
		tabOrder:    []string{"running", "keep"},
		activeTabID: "running",
	}

	ctrl.Submit("long turn")
	<-runner.started
	defer close(runner.release)
	if err := app.TrashTopic(topicID); !errors.Is(err, errTopicHasActiveWork) {
		t.Fatalf("trash running topic error = %v, want %v", err, errTopicHasActiveWork)
	}
	if !ctrl.Running() {
		t.Fatal("rejected archive should leave the controller running")
	}
	if _, ok := app.tabs["running"]; !ok {
		t.Fatal("rejected archive should keep the running topic tab")
	}
	if got := app.activeTabID; got != "running" {
		t.Fatalf("active tab = %q, want running", got)
	}
	if _, err := os.Stat(sessionPath); err != nil {
		t.Fatalf("rejected archive should preserve the live session: %v", err)
	}
	trashPath := filepath.Join(dir, sessionTrashDir, "running-trash.jsonl", "running-trash.jsonl")
	if _, err := os.Stat(trashPath); !os.IsNotExist(err) {
		t.Fatalf("rejected archive created a trash entry, stat err = %v", err)
	}
	if got := loadTopicTitle(projectRoot, topicID); got != "Running trash" {
		t.Fatalf("rejected archive topic title = %q, want Running trash", got)
	}
}

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

	projectRoot := t.TempDir()
	topicID := "topic_detached_running_trash"
	if err := addProject(projectRoot, ""); err != nil {
		t.Fatalf("add project: %v", err)
	}
	if err := setTopicTitle(projectRoot, topicID, "Detached running trash"); err != nil {
		t.Fatalf("set topic title: %v", err)
	}
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	sessionPath := writeTopicSession(t, dir, "detached-running-trash.jsonl", topicID, "Detached running trash", projectRoot)
	runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
	ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: sessionPath, Label: "test", WorkspaceRoot: projectRoot})
	defer ctrl.Close()
	defer close(runner.release)
	detachedKey := sessionRuntimeKey(sessionPath)
	detached := &WorkspaceTab{
		ID:            detachedRuntimeTabID(detachedKey),
		Scope:         "project",
		WorkspaceRoot: projectRoot,
		TopicID:       topicID,
		TopicTitle:    "Detached running trash",
		SessionPath:   sessionPath,
		Ctrl:          ctrl,
		Ready:         true,
		disabledMCP:   map[string]ServerView{},
	}
	app := &App{
		tabs:             map[string]*WorkspaceTab{},
		detachedSessions: map[string]*WorkspaceTab{detachedKey: detached},
	}

	ctrl.Submit("long detached turn")
	<-runner.started
	if err := app.TrashTopic(topicID); !errors.Is(err, errTopicHasActiveWork) {
		t.Fatalf("trash detached running topic error = %v, want %v", err, errTopicHasActiveWork)
	}
	if !ctrl.Running() {
		t.Fatal("rejected archive should leave the detached controller running")
	}
	if got := app.detachedSessions[detachedKey]; got != detached {
		t.Fatalf("rejected archive detached runtime = %p, want %p", got, detached)
	}
	if _, err := os.Stat(sessionPath); err != nil {
		t.Fatalf("rejected archive should preserve the detached session: %v", err)
	}
	trashPath := filepath.Join(dir, sessionTrashDir, "detached-running-trash.jsonl", "detached-running-trash.jsonl")
	if _, err := os.Stat(trashPath); !os.IsNotExist(err) {
		t.Fatalf("rejected archive created a trash entry, stat err = %v", err)
	}
	if got := loadTopicTitle(projectRoot, topicID); got != "Detached running trash" {
		t.Fatalf("rejected archive topic title = %q, want Detached running trash", got)
	}
}

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

	topicID := "topic_concurrent_turn_trash"
	if err := setTopicTitle("", topicID, "Concurrent turn trash"); err != nil {
		t.Fatalf("set topic title: %v", err)
	}
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	sessionPath := writeTopicSessionWithPrompt(
		t, dir, "concurrent-turn-trash.jsonl", topicID, "Concurrent turn trash", "", "existing turn", time.Now(),
	)
	runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
	ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: sessionPath, Label: "test"})
	defer ctrl.Close()
	defer close(runner.release)
	tab := &WorkspaceTab{
		ID:            "concurrent",
		Scope:         "global",
		WorkspaceRoot: globalTabWorkspaceRoot(),
		TopicID:       topicID,
		TopicTitle:    "Concurrent turn trash",
		SessionPath:   sessionPath,
		Ctrl:          ctrl,
		Ready:         true,
		disabledMCP:   map[string]ServerView{},
	}
	app := &App{
		tabs:        map[string]*WorkspaceTab{tab.ID: tab},
		tabOrder:    []string{tab.ID},
		activeTabID: tab.ID,
	}

	// Hold the per-tab gate so SubmitToTab owns the shared admission lock but
	// cannot make the controller observably busy yet.
	tab.turnStartMu.Lock()
	turnGateHeld := true
	defer func() {
		if turnGateHeld {
			tab.turnStartMu.Unlock()
		}
	}()
	submitDone := make(chan error, 1)
	go func() { submitDone <- app.SubmitToTab(tab.ID, "concurrent turn") }()

	deadline := time.Now().Add(5 * time.Second)
	for app.runtimeAdmissionMu.TryLock() {
		app.runtimeAdmissionMu.Unlock()
		if time.Now().After(deadline) {
			t.Fatal("concurrent turn never acquired the runtime admission read lock")
		}
		time.Sleep(time.Millisecond)
	}

	started := time.Now()
	if err := app.TrashTopic(topicID); !errors.Is(err, errTopicArchiveBusy) {
		t.Fatalf("concurrent TrashTopic error = %v, want %v", err, errTopicArchiveBusy)
	}
	if elapsed := time.Since(started); elapsed > time.Second {
		t.Fatalf("concurrent TrashTopic waited %s instead of returning busy", elapsed)
	}
	tab.turnStartMu.Unlock()
	turnGateHeld = false

	if err := <-submitDone; err != nil {
		t.Fatalf("SubmitToTab: %v", err)
	}
	<-runner.started
	if !ctrl.Running() {
		t.Fatal("rejected archive should leave the concurrently admitted turn running")
	}
	if got := app.tabs[tab.ID]; got != tab {
		t.Fatalf("rejected archive tab = %p, want %p", got, tab)
	}
	if _, err := os.Stat(sessionPath); err != nil {
		t.Fatalf("rejected archive should preserve the concurrently active session: %v", err)
	}
	trashPath := filepath.Join(dir, sessionTrashDir, "concurrent-turn-trash.jsonl", "concurrent-turn-trash.jsonl")
	if _, err := os.Stat(trashPath); !os.IsNotExist(err) {
		t.Fatalf("rejected archive created a trash entry, stat err = %v", err)
	}
	if got := loadTopicTitle("", topicID); got != "Concurrent turn trash" {
		t.Fatalf("rejected archive topic title = %q, want Concurrent turn trash", got)
	}
}

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

	projectRoot := t.TempDir()
	topicID := "topic_pending_trash"
	if err := addProject(projectRoot, ""); err != nil {
		t.Fatalf("add project: %v", err)
	}
	if err := setTopicTitle(projectRoot, topicID, "Pending trash"); err != nil {
		t.Fatalf("set topic title: %v", err)
	}
	app := &App{
		tabs: map[string]*WorkspaceTab{
			"pending": {
				ID:            "pending",
				Scope:         "project",
				WorkspaceRoot: projectRoot,
				TopicID:       topicID,
				TopicTitle:    "Pending trash",
				Ctrl:          &runtimeStatusSessionController{status: control.RuntimeStatus{PendingPrompt: true}},
				Ready:         true,
				disabledMCP:   map[string]ServerView{},
			},
		},
		tabOrder:    []string{"pending"},
		activeTabID: "pending",
	}

	if err := app.TrashTopic(topicID); !errors.Is(err, errTopicHasActiveWork) {
		t.Fatalf("trash pending topic error = %v, want %v", err, errTopicHasActiveWork)
	}
	if _, ok := app.tabs["pending"]; !ok {
		t.Fatal("rejected archive should keep the pending topic tab")
	}
	if got := loadTopicTitle(projectRoot, topicID); got != "Pending trash" {
		t.Fatalf("rejected archive topic title = %q, want Pending trash", got)
	}
}

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

	projectRoot := t.TempDir()
	topicID := "topic_only"
	if err := addProject(projectRoot, ""); err != nil {
		t.Fatalf("add project: %v", err)
	}
	if err := setTopicTitle(projectRoot, topicID, "Only topic"); err != nil {
		t.Fatalf("set topic title: %v", err)
	}
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	sessionPath := writeTopicSession(t, dir, "only-topic.jsonl", topicID, "Only topic", projectRoot)
	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: "test", WorkspaceRoot: projectRoot})
	defer ctrl.Close()
	app := &App{
		tabs: map[string]*WorkspaceTab{
			"only": {
				ID:            "only",
				Scope:         "project",
				WorkspaceRoot: projectRoot,
				TopicID:       topicID,
				TopicTitle:    "Only topic",
				Ctrl:          ctrl,
				Ready:         true,
				disabledMCP:   map[string]ServerView{},
			},
		},
		tabOrder:    []string{"only"},
		activeTabID: "only",
	}

	if err := app.TrashTopic(topicID); err != nil {
		t.Fatalf("TrashTopic: %v", err)
	}
	if len(app.tabs) != 1 {
		t.Fatalf("fallback should create exactly one visible tab, got %d", len(app.tabs))
	}
	for id, tab := range app.tabs {
		if strings.TrimSpace(tab.TopicID) != "" {
			t.Fatalf("fallback tab %q topic ID = %q, want transient unindexed blank", id, tab.TopicID)
		}
		if strings.TrimSpace(tab.SessionPath) == "" {
			t.Fatalf("fallback tab %q has no precreated session path", id)
		}
		f := loadProjectsFile()
		if len(f.Projects) != 1 || containsDesktopString(f.Projects[0].Topics, topicID) {
			t.Fatalf("deleted topic %q should be removed without indexing a replacement: %#v", topicID, f.Projects)
		}
	}
	nodes := app.ListProjectTree()
	if len(nodes) != 1 || len(nodes[0].Children) != 0 {
		t.Fatalf("transient fallback blank should stay out of project tree: %+v", nodes)
	}
}

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

	projectRoot := t.TempDir()
	topicID := "topic_only"
	if err := addProject(projectRoot, ""); err != nil {
		t.Fatalf("add project: %v", err)
	}
	if err := setTopicTitle(projectRoot, topicID, "Only topic"); err != nil {
		t.Fatalf("set topic title: %v", err)
	}
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	sessionPath := writeTopicSession(t, dir, "only-topic.jsonl", topicID, "Only topic", projectRoot)
	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: "test", WorkspaceRoot: projectRoot})
	defer ctrl.Close()
	app := &App{
		tabs: map[string]*WorkspaceTab{
			"only": {
				ID:            "only",
				Scope:         "project",
				WorkspaceRoot: projectRoot,
				TopicID:       topicID,
				TopicTitle:    "Only topic",
				Ctrl:          ctrl,
				Ready:         true,
				disabledMCP:   map[string]ServerView{},
			},
		},
		tabOrder:    []string{"only"},
		activeTabID: "only",
	}

	if err := app.TrashTopic(topicID); err != nil {
		t.Fatalf("TrashTopic: %v", err)
	}
	var fallback *WorkspaceTab
	for _, tab := range app.tabs {
		fallback = tab
	}
	if fallback == nil {
		t.Fatal("fallback tab missing")
	}
	if fallback.TopicID != "" {
		t.Fatalf("fallback topic before first turn = %q, want empty", fallback.TopicID)
	}

	app.ensureTabTopicIndexedForUserTurn(fallback)
	if strings.TrimSpace(fallback.TopicID) == "" {
		t.Fatal("first user turn should assign a topic ID")
	}
	f := loadProjectsFile()
	if len(f.Projects) != 1 || !containsDesktopString(f.Projects[0].Topics, fallback.TopicID) {
		t.Fatalf("first user turn should index fallback topic %q: %#v", fallback.TopicID, f.Projects)
	}
	meta, ok, err := agent.LoadBranchMeta(fallback.SessionPath)
	if err != nil || !ok {
		t.Fatalf("LoadBranchMeta(%q): ok=%v err=%v", fallback.SessionPath, ok, err)
	}
	if meta.TopicID != fallback.TopicID || meta.TopicTitle != defaultTopicTitle {
		t.Fatalf("fallback session meta = %+v, want topic %q title %q", meta, fallback.TopicID, defaultTopicTitle)
	}
}

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

	projectRoot := t.TempDir()
	if err := addProject(projectRoot, ""); err != nil {
		t.Fatalf("add project: %v", err)
	}
	dir := desktopSessionDir(projectRoot)
	transientPath, err := createEmptySessionFile(dir, "test")
	if err != nil {
		t.Fatalf("create transient session: %v", err)
	}
	if err := agent.SaveBranchMetaPreserveUpdated(transientPath, agent.BranchMeta{
		Scope:         "project",
		WorkspaceRoot: projectRoot,
	}); err != nil {
		t.Fatalf("save transient meta: %v", err)
	}

	targetTopicID := "topic_target"
	if err := setTopicTitle(projectRoot, targetTopicID, "Target"); err != nil {
		t.Fatalf("set target topic title: %v", err)
	}
	targetPath := writeTopicSession(t, dir, "target.jsonl", targetTopicID, "Target", projectRoot)
	app := &App{
		tabs: map[string]*WorkspaceTab{
			"transient": {
				ID:            "transient",
				Scope:         "project",
				WorkspaceRoot: projectRoot,
				TopicTitle:    defaultTopicTitle,
				SessionPath:   transientPath,
				Ready:         true,
				disabledMCP:   map[string]ServerView{},
			},
			"target": {
				ID:            "target",
				Scope:         "project",
				WorkspaceRoot: projectRoot,
				TopicID:       targetTopicID,
				TopicTitle:    "Target",
				SessionPath:   targetPath,
				Ready:         true,
				disabledMCP:   map[string]ServerView{},
			},
		},
		tabOrder:    []string{"transient", "target"},
		activeTabID: "transient",
	}

	if _, err := app.keepOnlyVisibleTab("target"); err != nil {
		t.Fatalf("keepOnlyVisibleTab: %v", err)
	}
	if _, ok := app.tabs["transient"]; ok {
		t.Fatal("transient tab should be removed after single-surface navigation")
	}
	if _, err := os.Stat(transientPath); !os.IsNotExist(err) {
		t.Fatalf("transient session artifact should be removed, stat err = %v", err)
	}
	if _, err := os.Stat(agent.BranchMetaPath(transientPath)); !os.IsNotExist(err) {
		t.Fatalf("transient session meta should be removed, stat err = %v", err)
	}
	f := loadProjectsFile()
	if len(f.Projects) != 1 || containsDesktopString(f.Projects[0].Topics, "") {
		t.Fatalf("transient blank should not be indexed in project topics: %#v", f.Projects)
	}
}

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

	projectRoot := t.TempDir()
	dir := desktopSessionDir(projectRoot)
	transientPath, err := createEmptySessionFile(dir, "test")
	if err != nil {
		t.Fatalf("create transient session: %v", err)
	}
	if err := agent.SaveBranchMetaPreserveUpdated(transientPath, agent.BranchMeta{
		Scope:         "project",
		WorkspaceRoot: projectRoot,
	}); err != nil {
		t.Fatalf("save transient meta: %v", err)
	}
	app := &App{
		tabs: map[string]*WorkspaceTab{
			"transient": {
				ID:            "transient",
				Scope:         "project",
				WorkspaceRoot: projectRoot,
				TopicTitle:    defaultTopicTitle,
				SessionPath:   transientPath,
				Ready:         true,
				disabledMCP:   map[string]ServerView{},
			},
			"other": {
				ID:          "other",
				Scope:       "global",
				TopicID:     "topic_other",
				TopicTitle:  "Other",
				Ready:       true,
				disabledMCP: map[string]ServerView{},
			},
		},
		tabOrder:    []string{"transient", "other"},
		activeTabID: "transient",
	}

	if err := app.CloseTab("transient"); err != nil {
		t.Fatalf("CloseTab: %v", err)
	}
	if _, ok := app.tabs["transient"]; ok {
		t.Fatal("transient tab should be closed")
	}
	if _, err := os.Stat(transientPath); !os.IsNotExist(err) {
		t.Fatalf("transient session artifact should be removed, stat err = %v", err)
	}
	if _, err := os.Stat(agent.BranchMetaPath(transientPath)); !os.IsNotExist(err) {
		t.Fatalf("transient session meta should be removed, stat err = %v", err)
	}
}

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

	projectRoot := t.TempDir()
	topicID := "topic_indexed_blank"
	if err := addProject(projectRoot, ""); err != nil {
		t.Fatalf("add project: %v", err)
	}
	if err := setTopicTitle(projectRoot, topicID, defaultTopicTitle); err != nil {
		t.Fatalf("set topic title: %v", err)
	}
	dir := desktopSessionDir(projectRoot)
	indexedPath, err := createEmptySessionFile(dir, "test")
	if err != nil {
		t.Fatalf("create indexed blank session: %v", err)
	}
	app := &App{
		tabs: map[string]*WorkspaceTab{
			"indexed": {
				ID:            "indexed",
				Scope:         "project",
				WorkspaceRoot: projectRoot,
				TopicID:       topicID,
				TopicTitle:    defaultTopicTitle,
				SessionPath:   indexedPath,
				Ready:         true,
				disabledMCP:   map[string]ServerView{},
			},
			"other": {
				ID:          "other",
				Scope:       "global",
				TopicID:     "topic_other",
				TopicTitle:  "Other",
				Ready:       true,
				disabledMCP: map[string]ServerView{},
			},
		},
		tabOrder:    []string{"indexed", "other"},
		activeTabID: "indexed",
	}

	if err := app.CloseTab("indexed"); err != nil {
		t.Fatalf("CloseTab: %v", err)
	}
	if _, err := os.Stat(indexedPath); err != nil {
		t.Fatalf("indexed blank session should be preserved, stat err = %v", err)
	}
}

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

	projectRoot := t.TempDir()
	topicID := "topic_trash_conflict"
	if err := addProject(projectRoot, ""); err != nil {
		t.Fatalf("add project: %v", err)
	}
	if err := setTopicTitle(projectRoot, topicID, "Trash conflict"); err != nil {
		t.Fatalf("set topic title: %v", err)
	}
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	sessionPath := writeTopicSession(t, dir, "trash-conflict.jsonl", topicID, "Trash conflict", projectRoot)
	if err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath)), 0o755); err != nil {
		t.Fatalf("create trash conflict: %v", err)
	}
	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: "test", WorkspaceRoot: projectRoot})
	defer ctrl.Close()
	app := &App{
		tabs: map[string]*WorkspaceTab{
			"idle": {
				ID:            "idle",
				Scope:         "project",
				WorkspaceRoot: projectRoot,
				TopicID:       topicID,
				TopicTitle:    "Trash conflict",
				Ctrl:          ctrl,
				Ready:         true,
				disabledMCP:   map[string]ServerView{},
			},
		},
		tabOrder:    []string{"idle"},
		activeTabID: "idle",
	}

	err := app.TrashTopic(topicID)
	if err != nil {
		t.Fatalf("TrashTopic should succeed after cleaning empty trash dir: %v", err)
	}
	if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
		t.Fatalf("session file should be moved to trash, stat err = %v", err)
	}
}

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

	projectRoot := t.TempDir()
	topicID := "topic_valid_trash"
	if err := addProject(projectRoot, ""); err != nil {
		t.Fatalf("add project: %v", err)
	}
	if err := setTopicTitle(projectRoot, topicID, "Valid trash"); err != nil {
		t.Fatalf("set topic title: %v", err)
	}
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	sessionPath := filepath.Join(dir, "valid-trash.jsonl")
	if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
		t.Fatalf("write live stub: %v", err)
	}
	if err := agent.SaveBranchMeta(sessionPath, agent.BranchMeta{
		CreatedAt:     time.Now().Add(-time.Minute),
		UpdatedAt:     time.Now(),
		Scope:         "project",
		WorkspaceRoot: projectRoot,
		TopicID:       topicID,
		TopicTitle:    "Valid trash",
	}); err != nil {
		t.Fatalf("save branch meta: %v", err)
	}
	trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))
	if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil {
		t.Fatalf("create trash dir: %v", err)
	}
	if err := os.WriteFile(trashPath, []byte(`{"role":"user","content":"already trashed"}`+"\n"), 0o644); err != nil {
		t.Fatalf("write trash session: %v", err)
	}

	app := &App{
		tabs: map[string]*WorkspaceTab{
			"stale": {
				ID:            "stale",
				Scope:         "project",
				WorkspaceRoot: projectRoot,
				TopicID:       topicID,
				TopicTitle:    "Valid trash",
				SessionPath:   sessionPath,
				Ready:         true,
				disabledMCP:   map[string]ServerView{},
			},
			"other": {ID: "other", Scope: "project", WorkspaceRoot: projectRoot, TopicID: "other", Ready: true},
		},
		tabOrder:    []string{"stale", "other"},
		activeTabID: "other",
	}

	if err := app.TrashTopic(topicID); err != nil {
		t.Fatalf("TrashTopic should remove stale live stub: %v", err)
	}
	if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
		t.Fatalf("live stub should be removed, stat err = %v", err)
	}
	if _, err := os.Stat(trashPath); err != nil {
		t.Fatalf("existing trash should remain authoritative: %v", err)
	}
	trashed, err := listTrashedSessionFiles(dir)
	if err != nil {
		t.Fatalf("listTrashedSessionFiles: %v", err)
	}
	if len(trashed) != 1 || !sameDesktopPath(trashed[0], trashPath) {
		t.Fatalf("trashed sessions = %v, want only authoritative copy %q", trashed, trashPath)
	}
}

func hasHistoryContent(messages []HistoryMessage, content string) bool {
	for _, m := range messages {
		if m.Content == content {
			return true
		}
	}
	return false
}

func TestLegacyMigrationSkipsProjectScopedSessions(t *testing.T) {
	isolateDesktopUserDirs(t)
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatal(err)
	}
	path := writeLegacySession(t, dir, "scoped.jsonl", "hello", time.Now())
	meta, err := agent.EnsureBranchMeta(path)
	if err != nil {
		t.Fatal(err)
	}
	meta.Scope = "project"
	meta.WorkspaceRoot = filepath.Join(t.TempDir(), "proj")
	meta.TopicID = ""
	if err := agent.SaveBranchMeta(path, meta); err != nil {
		t.Fatal(err)
	}

	migrateLegacySessionsIntoGlobalTopics(dir)

	got, err := agent.EnsureBranchMeta(path)
	if err != nil {
		t.Fatal(err)
	}
	if got.Scope != "project" || got.WorkspaceRoot != meta.WorkspaceRoot {
		t.Fatalf("project-scoped legacy session must not be forced into Global: %+v", got)
	}
}

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

	projectRoot := t.TempDir()
	if err := addProject(projectRoot, ""); err != nil {
		t.Fatalf("add project: %v", err)
	}
	dir := config.ProjectSessionDir(projectRoot)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatal(err)
	}
	sessionPath := writeLegacySession(t, dir, "cli-project.jsonl", "cli project prompt", time.Now())
	wantTopicID := legacySessionTopicID(sessionPath)

	app := NewApp()
	nodes := waitForCatalogTopic(t, app, "project", projectRoot, wantTopicID)
	if len(nodes) != 1 || nodes[0].Kind != "project" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != wantTopicID {
		t.Fatalf("project CLI session should appear in project tree, got %#v; want topic %q", nodes, wantTopicID)
	}
}

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

	projectRoot := t.TempDir()
	if err := addProject(projectRoot, ""); err != nil {
		t.Fatalf("add project: %v", err)
	}
	dir := config.ProjectSessionDir(projectRoot)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatal(err)
	}
	first := writeLegacySession(t, dir, "first-cli-project.jsonl", "first cli project prompt", time.Now().Add(-time.Hour))
	firstTopicID := legacySessionTopicID(first)

	app := NewApp()
	nodes := waitForCatalogTopic(t, app, "project", projectRoot, firstTopicID)
	if len(nodes) != 1 || nodes[0].Kind != "project" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != firstTopicID {
		t.Fatalf("first project CLI session should appear in project tree, got %#v; want topic %q", nodes, firstTopicID)
	}
	waitForTopicDirMarker(t, dir, topicMigrationMarker)

	time.Sleep(10 * time.Millisecond)
	second := writeLegacySession(t, dir, "second-cli-project.jsonl", "second cli project prompt", time.Now())
	secondTopicID := legacySessionTopicID(second)

	app.requestSessionCatalogReconcile(dir)
	nodes = waitForCatalogTreeCondition(t, app, "a reconciled newest project CLI session", func(nodes []ProjectNode) bool {
		return len(nodes) == 1 && nodes[0].Kind == "project" && len(nodes[0].Children) == 2 &&
			nodes[0].Children[0].TopicID == secondTopicID && nodes[0].Children[0].LastActivityAt > 0 &&
			nodes[0].Children[1].TopicID == firstTopicID
	})
	if len(nodes) != 1 || nodes[0].Kind != "project" || len(nodes[0].Children) != 2 {
		t.Fatalf("second project CLI session should trigger re-scan, got %#v", nodes)
	}
	if nodes[0].Children[0].TopicID != secondTopicID || nodes[0].Children[1].TopicID != firstTopicID {
		t.Fatalf("project CLI topics = %#v, want newest %q then %q", nodes[0].Children, secondTopicID, firstTopicID)
	}
}

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

	globalRoot := globalWorkspaceRoot()
	dir := desktopSessionDir(globalRoot)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatal(err)
	}
	sessionPath := writeLegacySession(t, dir, "cli-global.jsonl", "cli global prompt", time.Now())
	if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
		CreatedAt:     time.Now().Add(-time.Minute),
		UpdatedAt:     time.Now(),
		Scope:         "global",
		WorkspaceRoot: globalRoot,
	}); err != nil {
		t.Fatal(err)
	}
	wantTopicID := legacySessionTopicID(sessionPath)

	app := NewApp()
	nodes := waitForCatalogTopic(t, app, "global", "", wantTopicID)
	if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != wantTopicID {
		t.Fatalf("global workspace CLI session should appear in Global, got %#v; want topic %q", nodes, wantTopicID)
	}
}

func TestLegacyMigrationConcurrentRunsHaveNoLostUpdates(t *testing.T) {
	isolateDesktopUserDirs(t)
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatal(err)
	}
	const n = 8
	want := make(map[string]bool, n)
	for i := range n {
		p := writeLegacySession(t, dir, fmt.Sprintf("legacy-%d.jsonl", i), "hi", time.Now())
		want[legacySessionTopicID(p)] = true
	}

	var wg sync.WaitGroup
	for range n {
		wg.Go(func() {
			migrateLegacySessionsIntoGlobalTopics(dir)
		})
	}
	wg.Wait()

	gotSet := map[string]bool{}
	for _, id := range loadProjectsFile().GlobalTopics {
		gotSet[id] = true
	}
	for id := range want {
		if !gotSet[id] {
			t.Fatalf("concurrent migration lost topic %q; GlobalTopics=%v", id, loadProjectsFile().GlobalTopics)
		}
	}
}

func TestFindTopicSessionIndexRefreshesWhenMetaChanges(t *testing.T) {
	isolateDesktopUserDirs(t)
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatal(err)
	}
	topicID := "topic_cache_refresh"
	now := time.Now().UTC()
	first := writeTopicSessionWithPrompt(t, dir, "first.jsonl", topicID, "First", "", "first prompt", now.Add(-time.Hour))

	if got := findTopicSession(dir, topicID); got != first {
		t.Fatalf("first lookup = %q, want %q", got, first)
	}

	second := writeTopicSessionWithPrompt(t, dir, "second.jsonl", topicID, "Second", "", "second prompt", now)
	if got := findTopicSession(dir, topicID); got != second {
		t.Fatalf("lookup after new session = %q, want newer %q", got, second)
	}

	meta, ok, err := agent.LoadBranchMeta(second)
	if err != nil || !ok {
		t.Fatalf("load second meta: ok=%v err=%v", ok, err)
	}
	meta.TopicID = "topic_cache_other"
	meta.UpdatedAt = now.Add(time.Hour)
	if err := agent.SaveBranchMetaPreserveUpdated(second, meta); err != nil {
		t.Fatal(err)
	}
	future := time.Now().Add(2 * time.Second)
	if err := os.Chtimes(agent.BranchMetaPath(second), future, future); err != nil {
		t.Fatal(err)
	}

	if got := findTopicSession(dir, topicID); got != first {
		t.Fatalf("lookup after retopic = %q, want remaining %q", got, first)
	}
	if got := findTopicSession(dir, "topic_cache_other"); got != second {
		t.Fatalf("lookup for retopic session = %q, want %q", got, second)
	}
}

func TestFindTopicSessionSkipsCleanupPending(t *testing.T) {
	isolateDesktopUserDirs(t)
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatal(err)
	}
	topicID := "topic_skip_pending"
	now := time.Now().UTC()
	normal := writeTopicSessionWithPrompt(t, dir, "normal.jsonl", topicID, "Normal", "", "normal prompt", now)
	pending := writeTopicSessionWithPrompt(t, dir, "pending.jsonl", topicID, "Pending", "", "pending prompt", now.Add(time.Hour))

	if got := findTopicSession(dir, topicID); got != pending {
		t.Fatalf("pre-marker lookup = %q, want newest pending %q", got, pending)
	}
	if err := agent.MarkCleanupPending(pending, "delete"); err != nil {
		t.Fatal(err)
	}
	if got := findTopicSession(dir, topicID); got != normal {
		t.Fatalf("lookup with cleanup-pending newest = %q, want normal %q", got, normal)
	}
	if err := agent.MarkCleanupPending(normal, "delete"); err != nil {
		t.Fatal(err)
	}
	if got := findTopicSession(dir, topicID); got != "" {
		t.Fatalf("lookup with only cleanup-pending sessions = %q, want empty", got)
	}
}

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

	projectRoot := t.TempDir()
	app := NewApp()
	topic, err := app.CreateTopic("project", projectRoot, "Pending topic")
	if err != nil {
		t.Fatalf("create topic: %v", err)
	}
	dir := desktopSessionDir(projectRoot)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatal(err)
	}
	pending := writeTopicSessionWithPrompt(t, dir, "pending-topic.jsonl", topic.ID, "Pending topic", projectRoot, "pending topic prompt", time.Now())
	if err := agent.MarkCleanupPending(pending, "delete"); err != nil {
		t.Fatal(err)
	}
	if got := findTopicSession(dir, topic.ID); got != "" {
		t.Fatalf("topic lookup with only cleanup-pending session = %q, want empty", got)
	}
	if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != "" {
		t.Fatalf("target topic lookup with only cleanup-pending session = %q, want empty", got)
	}

	meta, err := app.OpenProjectTab(projectRoot, topic.ID)
	if err != nil {
		t.Fatalf("open project tab: %v", err)
	}
	tab := waitForTabReady(t, app, meta.ID)
	if got := filepath.Clean(tab.Ctrl.SessionPath()); got == filepath.Clean(pending) {
		t.Fatalf("opened cleanup-pending topic session path %q", got)
	}
	for _, msg := range tab.Ctrl.History() {
		if msg.Content == "pending topic prompt" {
			t.Fatalf("opened cleanup-pending topic history at path %q: %+v", tab.Ctrl.SessionPath(), tab.Ctrl.History())
		}
	}
}

func TestUpdateTopicSessionTitlesUsesTopicIndex(t *testing.T) {
	isolateDesktopUserDirs(t)
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatal(err)
	}
	topicID := "topic_title_index"
	now := time.Now().UTC()
	valid := writeTopicSessionWithPrompt(t, dir, "valid.jsonl", topicID, "Old", "", "hello", now)
	unpreviewable := filepath.Join(dir, "unpreviewable.jsonl")
	if err := os.WriteFile(unpreviewable, []byte("not-json\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := agent.SaveBranchMetaPreserveUpdated(unpreviewable, agent.BranchMeta{
		CreatedAt:  now.Add(-time.Minute),
		UpdatedAt:  now,
		Scope:      "global",
		TopicID:    topicID,
		TopicTitle: "Old",
	}); err != nil {
		t.Fatal(err)
	}

	NewApp().updateTopicSessionTitles(topicID, "Renamed")

	for _, path := range []string{valid, unpreviewable} {
		meta, ok, err := agent.LoadBranchMeta(path)
		if err != nil || !ok {
			t.Fatalf("load meta for %s: ok=%v err=%v", path, ok, err)
		}
		if meta.TopicTitle != "Renamed" {
			t.Fatalf("topic title for %s = %q, want Renamed", path, meta.TopicTitle)
		}
	}
}

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

	projectRoot := t.TempDir()
	const n = 12
	start := make(chan struct{})
	var wg sync.WaitGroup
	for i := range n {

		wg.Go(func() {
			<-start
			topicID := fmt.Sprintf("topic_recovered_%02d", i)
			if err := ensureTopicIndexed("project", projectRoot, topicID, fmt.Sprintf("Recovered %02d", i), topicTitleSourceManual); err != nil {
				t.Errorf("ensure topic indexed: %v", err)
			}
		})
	}
	close(start)
	wg.Wait()

	nodes := NewApp().ListProjectTree()
	if len(nodes) != 1 {
		t.Fatalf("project tree len = %d, want 1: %#v", len(nodes), nodes)
	}
	got := map[string]bool{}
	for _, child := range nodes[0].Children {
		got[child.TopicID] = true
	}
	for i := range n {
		topicID := fmt.Sprintf("topic_recovered_%02d", i)
		if !got[topicID] {
			t.Fatalf("concurrent topic index recovery lost %q; children=%#v", topicID, nodes[0].Children)
		}
		if title := loadTopicTitle(projectRoot, topicID); title == "" {
			t.Fatalf("title index missing %q", topicID)
		}
	}
}

// A freshly created empty session must not hijack the topic from the
// conversation the user actually had: content-bearing sessions outrank
// content-free ones regardless of updatedAt (#7305).
func TestFindTopicSessionPrefersContentOverNewerEmpty(t *testing.T) {
	isolateDesktopUserDirs(t)

	projectRoot := robustTempDir(t)
	app := NewApp()
	topic, err := app.CreateTopic("project", projectRoot, "")
	if err != nil {
		t.Fatalf("CreateTopic: %v", err)
	}
	dir := desktopSessionDir(projectRoot)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}

	contentPath := writeTopicSessionWithPrompt(t, dir, "content.jsonl", topic.ID, defaultTopicTitle, projectRoot, "real conversation", time.Now().Add(-time.Hour))

	emptyPath := filepath.Join(dir, "empty.jsonl")
	if err := os.WriteFile(emptyPath, nil, 0o644); err != nil {
		t.Fatalf("write empty session: %v", err)
	}
	if err := agent.SaveBranchMetaPreserveUpdated(emptyPath, agent.BranchMeta{
		CreatedAt:     time.Now(),
		UpdatedAt:     time.Now(),
		Scope:         "project",
		WorkspaceRoot: projectRoot,
		TopicID:       topic.ID,
		TopicTitle:    defaultTopicTitle,
	}); err != nil {
		t.Fatalf("save empty branch meta: %v", err)
	}

	if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != contentPath {
		t.Fatalf("topic session = %q, want content-bearing %q to outrank newer empty %q", got, contentPath, emptyPath)
	}
	if got, _ := app.findTopicContentSessionForTarget("project", projectRoot, topic.ID); got != contentPath {
		t.Fatalf("content topic session = %q, want %q", got, contentPath)
	}
}
