返回 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 return a.canonicalTabTitleWithPresentation(ctx, state.Presentation[ref.SessionID], ref)
40 }
41
42 func (a *App) canonicalTabTitleWithPresentation(ctx context.Context, presentation workspacestate.Presentation, ref session.SessionRef) (string, string) {
43 info, err := a.desktopSessionService("").Query().Stat(ctx, ref)
44 if err != nil {
45 info = session.SessionInfo{}
46 }
47 return sessionDisplayTitle(info, presentation)
48 }
49
50 // publishCanonicalSessionTitle is the single exit of every canonical rename.
51 // It mirrors the committed log title into each local reader that still
52 // caches a name: the presentation row (cold binds, trash rows, fork labels),
53 // the open tabs, the prompt-history cache, and the project tree.
54 func (a *App) publishCanonicalSessionTitle(ref session.SessionRef, title string) {
55 title = strings.TrimSpace(title)
56 ctx := a.bootContext()
57 if err := a.workspaceRegistry().UpdatePresentation(ctx, []string{ref.SessionID}, &title, nil); err != nil {
58 slog.Warn("desktop: session title presentation write-through failed", "session", ref.SessionID, "err", err)
59 }
60 display, source := title, topicTitleSourceManual
61 if display == "" {
62 // A cleared name falls back through the shared chain instead of
63 // leaving the tab blank while the sidebar shows the preview.
64 state, err := a.workspaceRegistry().Load(ctx)
65 if err != nil {
66 state = workspacestate.State{}
67 }
68 display, source = a.canonicalTabTitle(ctx, state, ref)
69 }
70 a.updateCanonicalSessionTitle(ref, display, source)
71 a.invalidatePromptHistoryCache()
72 a.emitProjectTreeChanged()
73 }
74
75 // projectLegacySessionTitleToTabs mirrors a legacy session's branch-meta
76 // custom title into the open tabs bound to that file. The topic title remains
77 // the fallback when the custom title is cleared.
78 func (a *App) projectLegacySessionTitleToTabs(sessionPath string) {
79 meta, ok, err := agent.LoadBranchMeta(sessionPath)
80 if err != nil || !ok {
81 return
82 }
83 title := strings.TrimSpace(meta.CustomTitle)
84 a.mu.Lock()
85 defer a.mu.Unlock()
86 changed := false
87 for _, tab := range a.runtimeTabsLocked() {
88 if tab == nil || tab.SessionID != "" || !sessionRuntimeKeysOverlap(tab, sessionPath) {
89 continue
90 }
91 next, source := title, topicTitleSourceManual
92 if next == "" {
93 next = topicTitleForTab(tab.Scope, tab.WorkspaceRoot, tab.TopicID)
94 source = loadTopicTitleSource(topicTitleRoot(tab.Scope, tab.WorkspaceRoot), tab.TopicID)
95 }
96 if tab.TopicTitle == next && tab.topicTitleSource == source {
97 continue
98 }
99 tab.TopicTitle, tab.topicTitleSource = next, source
100 changed = true
101 }
102 if changed {
103 a.saveTabsLocked()
104 }
105 }
106
107 // syncSessionTitleFromBranchMeta projects the current canonical custom title
108 // while holding the legacy map lock, so a delayed callback observes a newer
109 // rename instead of publishing the stale title value it originally received.
110 func syncSessionTitleFromBranchMeta(dir, sessionPath string) error {
111 sessionPath, _, err := validateSessionPath(dir, sessionPath)
112 if err != nil {
113 return err
114 }
115 key := filepath.Base(sessionPath)
116 var loadErr error
117 err = updateSessionTitles(dir, func(m map[string]string) bool {
118 meta, ok, err := agent.LoadBranchMeta(sessionPath)
119 if err != nil {
120 loadErr = err
121 return false
122 }
123 title := ""
124 if ok {
125 title = strings.TrimSpace(meta.CustomTitle)
126 }
127 if title == "" {
128 if _, exists := m[key]; !exists {
129 return false
130 }
131 delete(m, key)
132 return true
133 }
134 if m[key] == title {
135 return false
136 }
137 m[key] = title
138 return true
139 })
140 if err != nil {
141 return err
142 }
143 if loadErr != nil {
144 return fmt.Errorf("load canonical session title: %w", loadErr)
145 }
146 return nil
147 }
148
148 lines GO