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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion desktop/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1222,7 +1222,11 @@ export default function App() {
if (!topicId) return;
const nextTitle = topicTitleDraft.trim();
if (!nextTitle) return;
await renameTopic(topicId, nextTitle);
try {
await renameTopic(topicId, nextTitle);
} catch {
Comment on lines +1225 to +1227

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 Badge Surface non-stale rename failures

This catches every RenameTopic failure, not just the stale-topic case mentioned in the comment. If the backend fails to write the title or projects file because of permissions, disk space, or a config I/O error, the edit mode is dismissed and the draft is discarded without refreshing or telling the user that the rename did not persist.

Useful? React with 👍 / 👎.

/* keep the app usable if a stale topic cannot be renamed */
}
}, [renameTopic, renamingTopicId, topicTitleDraft]);

const onRemember = useCallback(
Expand Down
109 changes: 105 additions & 4 deletions desktop/tabs.go
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,11 @@ func (a *App) buildTabController(tab *WorkspaceTab) {
// Write/update scope/session meta.
if path != "" {
a.persistTabSessionPath(tab, path)
if strings.TrimSpace(tab.TopicID) != "" {
if err := ensureTopicIndexed(tab.Scope, tab.WorkspaceRoot, tab.TopicID, tab.TopicTitle, loadTopicTitleSource(topicTitleRoot(tab.Scope, tab.WorkspaceRoot), tab.TopicID)); err == nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Recover title from session meta before writing defaults

When a persisted tab is restored after its topic title index was deleted, createTabEntryWithID synthesizes tab.TopicTitle as defaultTopicTitle; this new recovery path then indexes that synthetic title. Because persistTabSessionPath immediately above also saves tab.TopicTitle into the session metadata, the original BranchMeta.TopicTitle that could have repaired the index is overwritten before recovery, so users with an open saved tab lose the real topic title and the sidebar is restored as “新的会话” instead of the session's title.

Useful? React with 👍 / 👎.

a.emitProjectTreeChanged()
Comment thread
SivanCola marked this conversation as resolved.
}
}
// Restore existing telemetry if resuming a session.
telemetryPath := path + ".telemetry.json"
if records := loadTelemetry(telemetryPath); len(records) > 0 {
Expand Down Expand Up @@ -1282,10 +1287,7 @@ func loadTopicTitleSource(workspaceRoot, topicID string) string {
}

func topicTitleForTab(scope, workspaceRoot, topicID string) string {
titleRoot := workspaceRoot
if scope == "global" {
titleRoot = ""
}
titleRoot := topicTitleRoot(scope, workspaceRoot)
if title := strings.TrimSpace(loadTopicTitle(titleRoot, topicID)); title != "" {
return title
}
Expand All @@ -1295,6 +1297,13 @@ func topicTitleForTab(scope, workspaceRoot, topicID string) string {
return defaultTopicTitle
}

func topicTitleRoot(scope, workspaceRoot string) string {
if scope == "global" {
return ""
}
return workspaceRoot
}

func forkTopicTitle(title string) string {
base := strings.TrimSpace(title)
if base == "" || base == defaultTopicTitle || base == "Global" {
Expand Down Expand Up @@ -1340,6 +1349,49 @@ func setTopicTitleSource(workspaceRoot, topicID, source string) error {
return saveTopicTitleSources(workspaceRoot, sources)
}

// topicIndexMu serializes recovery writes to desktop-projects.json and topic
// title indexes. Startup builds restored tabs concurrently, and each tab may
// repair its missing index.
var topicIndexMu sync.Mutex

func ensureTopicIndexed(scope, workspaceRoot, topicID, title, source string) error {
topicID = strings.TrimSpace(topicID)
if topicID == "" {
return fmt.Errorf("topicID is required")
}
topicIndexMu.Lock()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Share the project-index lock with legacy migration

This lock only serializes ensureTopicIndexed calls, but migrateLegacySessionsIntoGlobalTopics uses a different mutex while doing the same load/modify/save of desktop-projects.json. During startup one restored tab can be repairing its topic index while another tab or ListProjectTree is migrating legacy sessions, so the later save can overwrite the earlier update and drop either recovered project topics or migrated global topics.

Useful? React with 👍 / 👎.

defer topicIndexMu.Unlock()
if strings.TrimSpace(scope) == "global" {
workspaceRoot = ""
} else {
workspaceRoot = normalizeProjectRoot(workspaceRoot)
}
title = strings.TrimSpace(title)
if title == "" {
title = defaultTopicTitle
}
source = strings.TrimSpace(source)
if source == "" {
source = topicTitleSourceManual
}
if err := setTopicTitleWithSource(workspaceRoot, topicID, title, source); err != nil {
return err
}
f := loadProjectsFile()
if workspaceRoot == "" {
f.GlobalTopics = prependUniqueString(f.GlobalTopics, topicID)
return saveProjectsFile(f)
}
for i, p := range f.Projects {
if p.Root == workspaceRoot {
f.Projects[i].Topics = prependUniqueString(p.Topics, topicID)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 Badge Avoid moving already-indexed topics during recovery

When this recovery path runs for an existing project topic, prependUniqueString rewrites the ordered topic list by moving that topic to the front. Because buildTabController now calls ensureTopicIndexed for every restored open tab, simply reopening the app can reorder the sidebar's saved topic order based on startup timing instead of only repairing genuinely missing entries.

Useful? React with 👍 / 👎.

return saveProjectsFile(f)
}
}
f.Projects = append(f.Projects, desktopProject{Root: workspaceRoot, Topics: []string{topicID}})
return saveProjectsFile(f)
}

// --- telemetry --------------------------------------------------------------

func (a *App) tabTelemetryPath(tabID string) string {
Expand Down Expand Up @@ -1734,9 +1786,58 @@ func (a *App) RenameTopic(topicID, title string) error {
a.emitProjectTreeChanged()
return nil
}
if scope, workspaceRoot, ok := a.findTopicLocation(topicID); ok {
if err := ensureTopicIndexed(scope, workspaceRoot, topicID, trimmed, topicTitleSourceManual); err != nil {
return err
}
a.updateOpenTopicTitle(topicID, trimmed)
a.updateTopicSessionTitles(topicID, trimmed)
a.emitProjectTreeChanged()
return nil
}
return fmt.Errorf("topic %q not found", topicID)
}

func (a *App) findTopicLocation(topicID string) (string, string, bool) {
topicID = strings.TrimSpace(topicID)
if topicID == "" {
return "", "", false
}
a.mu.RLock()
for _, tab := range a.tabs {
if tab == nil || tab.TopicID != topicID {
continue
}
scope := tab.Scope
workspaceRoot := tab.WorkspaceRoot
a.mu.RUnlock()
if scope == "global" {
return "global", "", true
}
return "project", normalizeProjectRoot(workspaceRoot), true
}
a.mu.RUnlock()

infos, err := agent.ListSessions(config.SessionDir())
if err != nil {
return "", "", false
}
for _, info := range infos {
if strings.TrimSpace(info.TopicID) != topicID {
continue
}
scope := strings.TrimSpace(info.Scope)
if scope == "" {
scope = "global"
}
if scope == "global" {
return "global", "", true
}
return "project", normalizeProjectRoot(info.WorkspaceRoot), true
}
return "", "", false
}

func (a *App) updateOpenTopicTitle(topicID, title string) {
if strings.TrimSpace(topicID) == "" || strings.TrimSpace(title) == "" {
return
Expand Down
157 changes: 157 additions & 0 deletions desktop/tabs_topic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,36 @@ import (
"reasonix/internal/config"
)

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 writeTopicSession(t *testing.T, dir, name, topicID, topicTitle, workspaceRoot string) string {
t.Helper()
path := filepath.Join(dir, name)
Expand Down Expand Up @@ -706,6 +736,7 @@ func TestRenameTopicUpdatesOpenTabMeta(t *testing.T) {
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)
}
Expand All @@ -722,6 +753,91 @@ func TestRenameTopicUpdatesOpenTabMeta(t *testing.T) {
}
}

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 := os.Remove(topicTitlesPath(projectRoot)); err != nil {
t.Fatalf("remove topic titles: %v", err)
}
if err := os.Remove(topicTitleSourcesPath(projectRoot)); err != nil {
t.Fatalf("remove 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 := os.Remove(topicTitlesPath(projectRoot)); err != nil {
t.Fatalf("remove topic titles: %v", err)
}
if err := os.Remove(topicTitleSourcesPath(projectRoot)); err != nil {
t.Fatalf("remove 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 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)

Expand Down Expand Up @@ -1048,3 +1164,44 @@ func TestLegacyMigrationConcurrentRunsHaveNoLostUpdates(t *testing.T) {
}
}
}

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

projectRoot := t.TempDir()
const n = 12
start := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < n; i++ {
i := i
wg.Add(1)
go func() {
defer wg.Done()
<-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 := 0; i < n; i++ {
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)
}
}
}
Loading