返回 DeepSeek-Reasonix
session_title_projection.go
根目录 / desktop / session_title_projection.go
1 package main
2
3 import (
4 "context"
5 "fmt"
6 "log/slog"
7 "path/filepath"
8 "strings"
9
10 "reasonix/desktop/internal/workspacestate"
11 "reasonix/internal/agent"
12 "reasonix/internal/session"
13 )
14
15 // sessionDisplayTitle is the one name projection every local surface uses for
16 // a canonical session, so the sidebar row and the topicbar cannot disagree.
17 // The session log owns the name once any rename has committed, including an
18 // explicitly cleared one; before that, a presentation title migrated from a
19 // legacy topic still counts; an untitled session shows its first user turn.
20 // The creation default is never a real name and only closes the chain.
21 func sessionDisplayTitle(info session.SessionInfo, presentation workspacestate.Presentation) (string, string) {
22 if title := strings.TrimSpace(info.Title); title != "" {
23 return title, topicTitleSourceManual
24 }
25 if info.TitleSequence == 0 {
26 if title := strings.TrimSpace(presentation.Title); title != "" && !isDefaultTopicTitle(title) {
27 return title, topicTitleSourceManual
28 }
29 }
30 if preview := strings.TrimSpace(info.Preview); preview != "" {
31 return preview, topicTitleSourceAuto
32 }
33 return defaultTopicTitle, topicTitleSourceAuto
34 }
35
36 // canonicalTabTitle resolves the projected name for ref. Callers run it before
37 // taking App.mu: the catalog read may touch the filesystem.
38 func (a *App) canonicalTabTitle(ctx context.Context, state workspacestate.State, ref session.SessionRef) (string, string) {
39 info, err := a.desktopSessionService("").Query().Stat(ctx, ref)
40 if err != nil {
41 info = session.SessionInfo{}
42 }
43 return sessionDisplayTitle(info, state.Presentation[ref.SessionID])
44 }
45
46 // publishCanonicalSessionTitle is the single exit of every canonical rename.
47 // It mirrors the committed log title into each local reader that still
48 // caches a name: the presentation row (cold binds, trash rows, fork labels),
49 // the open tabs, the prompt-history cache, and the project tree.
50 func (a *App) publishCanonicalSessionTitle(ref session.SessionRef, title string) {
51 title = strings.TrimSpace(title)
52 ctx := a.bootContext()
53 if err := a.workspaceRegistry().UpdatePresentation(ctx, []string{ref.SessionID}, &title, nil); err != nil {
54 slog.Warn("desktop: session title presentation write-through failed", "session", ref.SessionID, "err", err)
55 }
56 display, source := title, topicTitleSourceManual
57 if display == "" {
58 // A cleared name falls back through the shared chain instead of
59 // leaving the tab blank while the sidebar shows the preview.
60 state, err := a.workspaceRegistry().Load(ctx)
61 if err != nil {
62 state = workspacestate.State{}
63 }
64 display, source = a.canonicalTabTitle(ctx, state, ref)
65 }
66 a.updateCanonicalSessionTitle(ref, display, source)
67 a.invalidatePromptHistoryCache()
68 a.emitProjectTreeChanged()
69 }
70
71 // projectLegacySessionTitleToTabs mirrors a legacy session's branch-meta
72 // custom title into the open tabs bound to that file. The topic title remains
73 // the fallback when the custom title is cleared.
74 func (a *App) projectLegacySessionTitleToTabs(sessionPath string) {
75 meta, ok, err := agent.LoadBranchMeta(sessionPath)
76 if err != nil || !ok {
77 return
78 }
79 title := strings.TrimSpace(meta.CustomTitle)
80 a.mu.Lock()
81 defer a.mu.Unlock()
82 changed := false
83 for _, tab := range a.runtimeTabsLocked() {
84 if tab == nil || tab.SessionID != "" || !sessionRuntimeKeysOverlap(tab, sessionPath) {
85 continue
86 }
87 next, source := title, topicTitleSourceManual
88 if next == "" {
89 next = topicTitleForTab(tab.Scope, tab.WorkspaceRoot, tab.TopicID)
90 source = loadTopicTitleSource(topicTitleRoot(tab.Scope, tab.WorkspaceRoot), tab.TopicID)
91 }
92 if tab.TopicTitle == next && tab.topicTitleSource == source {
93 continue
94 }
95 tab.TopicTitle, tab.topicTitleSource = next, source
96 changed = true
97 }
98 if changed {
99 a.saveTabsLocked()
100 }
101 }
102
103 // syncSessionTitleFromBranchMeta projects the current canonical custom title
104 // while holding the legacy map lock, so a delayed callback observes a newer
105 // rename instead of publishing the stale title value it originally received.
106 func syncSessionTitleFromBranchMeta(dir, sessionPath string) error {
107 sessionPath, _, err := validateSessionPath(dir, sessionPath)
108 if err != nil {
109 return err
110 }
111 key := filepath.Base(sessionPath)
112 var loadErr error
113 err = updateSessionTitles(dir, func(m map[string]string) bool {
114 meta, ok, err := agent.LoadBranchMeta(sessionPath)
115 if err != nil {
116 loadErr = err
117 return false
118 }
119 title := ""
120 if ok {
121 title = strings.TrimSpace(meta.CustomTitle)
122 }
123 if title == "" {
124 if _, exists := m[key]; !exists {
125 return false
126 }
127 delete(m, key)
128 return true
129 }
130 if m[key] == title {
131 return false
132 }
133 m[key] = title
134 return true
135 })
136 if err != nil {
137 return err
138 }
139 if loadErr != nil {
140 return fmt.Errorf("load canonical session title: %w", loadErr)
141 }
142 return nil
143 }
144
144 lines GO