返回 DeepSeek-Reasonix
session_workspace_sidebar.go
根目录 / desktop / session_workspace_sidebar.go
1 package main
2
3 import (
4 "crypto/sha256"
5 "encoding/json"
6 "fmt"
7 "sort"
8 "strconv"
9 "strings"
10 "time"
11
12 "reasonix/desktop/internal/workspacestate"
13 "reasonix/internal/session"
14 )
15
16 // Both recovery and the sidebar resolve durable registry members. Legacy
17 // catalog rows remain available only until their exact source is adopted.
18 func (a *App) unifiedProjectTopics(req ProjectTopicPageRequest) (ProjectTopicPage, error) {
19 scope, root, err := normalizeOrganizationTarget(req.Scope, req.WorkspaceRoot)
20 if err != nil {
21 return ProjectTopicPage{Items: []ProjectNode{}}, err
22 }
23 req.Scope, req.WorkspaceRoot = scope, root
24 workspaceID, org, err := a.ensureSessionOrganization(scope, root)
25 if err != nil {
26 return ProjectTopicPage{Items: []ProjectNode{}}, err
27 }
28 state, err := a.workspaceRegistry().Load(a.bootContext())
29 if err != nil {
30 return ProjectTopicPage{Items: []ProjectNode{}}, err
31 }
32 catalogRevision := a.currentSessionCatalogStatus().Revision
33 workspace := state.Workspaces[workspaceID]
34 reader := a.desktopSessionService("").Query()
35 infos, _ := listWorkspaceSessionInfo(a.bootContext(), reader, workspace.SessionIDs)
36 groups := organizationSnapshot(org, true).Groups
37 req.groupInclude = nil
38 req.groupExclude = nil
39 req.groupIncludeJSON = ""
40 req.groupExcludeJSON = ""
41 req.groupAll = groups
42 req.groupSelected = nil
43 if req.GroupFilter == "group" {
44 for i := range groups {
45 if groups[i].ID == req.GroupID {
46 req.groupSelected = &groups[i]
47 break
48 }
49 }
50 if req.groupSelected == nil {
51 return ProjectTopicPage{Items: []ProjectNode{}}, fmt.Errorf("session group no longer exists")
52 }
53 }
54 adopted := map[string]bool{}
55 adoptedTopics := map[string]bool{}
56 for _, m := range state.SourceMappings {
57 if m.WorkspaceID == workspaceID {
58 adopted["source\x00local\x00"+m.SourceKey] = true
59 if sourceMappingHasPathAlias(m) {
60 adopted[sessionRuntimeKey(m.Path)] = true
61 }
62 }
63 }
64 for _, id := range workspace.SessionIDs {
65 adoptedTopics[state.Presentation[id].TopicID] = true
66 }
67 all := req
68 all.Cursor = ""
69 all.Query = ""
70 all.TimeFilter = ""
71 all.GroupFilter = "all"
72 all.ExcludePinned = false
73 all.groupSelected = nil
74 all.groupAll = nil
75 legacy, err := a.unadoptedLegacyTopics(all, adopted, adoptedTopics)
76 if err != nil {
77 return legacy, err
78 }
79 sources := append(legacy.Items, a.historicalCanonicalTopics(scope, root, state)...)
80 if saved, err := readHistoricalSidecar(); err == nil {
81 applyHistoricalPresentations(sources, saved)
82 }
83 nodes := a.canonicalTopicNodes(all, state, workspace, infos, sources)
84 filtered := filterWorkspaceSessionNodes(req, org, state, workspaceID, nodes)
85 sort.SliceStable(filtered, func(i, j int) bool {
86 return projectTopicLess(filtered[i], filtered[j], req.SortMode, org.ManualOrderEnabled)
87 })
88 // Bind to the exact materialized order and metadata, plus owner revisions.
89 // Runtime decoration is deliberately excluded: opening a tab is not a reorder.
90 identity := []any{state.Generation, org.Revision, legacy.Revision, projectTopicCursorBinding(req, req.GroupFilter, req.GroupID, org.Revision)}
91 for _, n := range filtered {
92 identity = append(identity, []any{projectNodeSessionKey(n), n.Label, n.Preview, n.Pinned, n.CreatedAt, n.LastActivityAt, n.ResultSequence, n.SortOrder, n.LifecycleGeneration})
93 }
94 encoded, _ := json.Marshal(identity)
95 digest := sha256.Sum256(encoded)
96 prefix := fmt.Sprintf("sessions:%x:", digest[:])
97 offset := 0
98 if req.Cursor != "" {
99 if !strings.HasPrefix(req.Cursor, prefix) {
100 return ProjectTopicPage{Items: []ProjectNode{}}, newSessionOperationError("stale_cursor", "The session list changed. Reload it.")
101 }
102 offset, err = strconv.Atoi(strings.TrimPrefix(req.Cursor, prefix))
103 if err != nil || offset < 0 || offset > len(filtered) {
104 return ProjectTopicPage{Items: []ProjectNode{}}, newSessionOperationError("stale_cursor", "The session list changed. Reload it.")
105 }
106 }
107 after, err := a.workspaceRegistry().Load(a.bootContext())
108 if err != nil {
109 return ProjectTopicPage{Items: []ProjectNode{}}, err
110 }
111 if after.Generation != state.Generation || a.currentSessionCatalogStatus().Revision != catalogRevision || !workspaceSessionInfoUnchanged(a.bootContext(), reader, workspace.SessionIDs, infos) {
112 return ProjectTopicPage{Items: []ProjectNode{}}, newSessionOperationError("stale_cursor", "The session list changed. Reload it.")
113 }
114 limit := req.Limit
115 if limit <= 0 {
116 limit = 50
117 }
118 limit = min(limit, 200)
119 end := min(offset+limit, len(filtered))
120 legacy.Items = append([]ProjectNode{}, filtered[offset:end]...)
121 legacy.Revision += state.Generation
122 legacy.NextCursor = ""
123 if end < len(filtered) {
124 legacy.NextCursor = prefix + strconv.Itoa(end)
125 }
126 return legacy, nil
127 }
128
129 func desktopSessionTimeCutoff(filter string) int64 {
130 value := strings.ToLower(strings.TrimSpace(filter))
131 switch value {
132 case "day":
133 value = "24h"
134 case "week", "7d":
135 value = "168h"
136 case "month", "30d":
137 value = "720h"
138 }
139 duration, err := time.ParseDuration(value)
140 if err != nil || duration <= 0 {
141 return 0
142 }
143 return time.Now().Add(-duration).UnixMilli()
144 }
145
146 func (a *App) unifiedProjectRevision(catalogRevision uint64) uint64 {
147 state, err := a.workspaceRegistry().Load(a.bootContext())
148 if err != nil {
149 return catalogRevision
150 }
151 return catalogRevision + state.Generation
152 }
153
154 func (a *App) updateCanonicalTopicPresentation(topicID string, title *string, pinned *bool) (bool, error) {
155 state, err := a.workspaceRegistry().Load(a.bootContext())
156 if err != nil {
157 return false, err
158 }
159 ids := []string{}
160 for _, workspace := range state.Workspaces {
161 for _, id := range workspace.SessionIDs {
162 if state.Presentation[id].TopicID == topicID || "canonical-"+id == topicID {
163 ids = append(ids, id)
164 }
165 }
166 }
167 if len(ids) == 0 {
168 return false, nil
169 }
170 if title != nil {
171 for _, id := range ids {
172 ref := session.SessionRef{HostID: localDesktopHostID, SessionID: id}
173 if err := a.desktopSessionService("").SetTitle(a.bootContext(), ref, *title); err != nil {
174 return true, err
175 }
176 a.publishCanonicalSessionTitle(ref, *title)
177 }
178 }
179 if pinned != nil {
180 if err := a.workspaceRegistry().UpdatePresentation(a.bootContext(), ids, nil, pinned); err != nil {
181 return true, err
182 }
183 a.emitProjectTreeMetadataChanged()
184 }
185 return true, nil
186 }
187
188 func (a *App) mergeCanonicalWorkspaceShells(projects []ProjectNode) []ProjectNode {
189 state, err := a.workspaceRegistry().Load(a.bootContext())
190 if err != nil {
191 return projects
192 }
193 present := map[string]bool{}
194 visible := make([]ProjectNode, 0, len(projects))
195 for _, project := range projects {
196 if project.Remote != nil {
197 visible = append(visible, project)
198 continue
199 }
200 scope := "project"
201 if project.Kind == "global_folder" {
202 scope = "global"
203 }
204 id := desktopWorkspaceOwnerID(state, scope, project.Root)
205 if workspace, ok := state.Workspaces[id]; ok && !workspace.Visible {
206 continue
207 }
208 present[id] = true
209 visible = append(visible, project)
210 }
211 projects = visible
212 for _, id := range state.WorkspaceIDs {
213 workspace := state.Workspaces[id]
214 if !workspace.Visible || present[id] {
215 continue
216 }
217 kind, key := "project", "project_"+workspace.Root
218 if id == workspacestate.GlobalWorkspaceID {
219 kind, key = "global_folder", "global_folder"
220 }
221 projects = append(projects, ProjectNode{Key: key, Kind: kind, Root: workspace.Root, Label: workspace.Title, Children: []ProjectNode{}})
222 }
223 // Pinned shells must use the same canonical rows as ordinary pages. Legacy
224 // shells otherwise overwrite the title/key on refresh and resurrect pins
225 // for sessions whose registry lifecycle is already archived.
226 for index := range projects {
227 project := &projects[index]
228 if project.Remote != nil {
229 continue
230 }
231 scope, root := "project", project.Root
232 if project.Kind == "global_folder" {
233 scope, root = "global", ""
234 }
235 req := ProjectTopicPageRequest{Scope: scope, WorkspaceRoot: root, Limit: 200}
236 workspace := state.Workspaces[desktopWorkspaceOwnerID(state, scope, root)]
237 if len(workspace.SessionIDs) == 0 {
238 pins, err := a.historicalPinnedShells(req, state)
239 if err != nil {
240 project.Health = "metadata_failed"
241 } else {
242 project.Children = pins
243 }
244 continue
245 }
246 pins := []ProjectNode{}
247 for {
248 page, err := a.unifiedProjectTopics(req)
249 if err != nil {
250 project.Health = "metadata_failed"
251 break
252 }
253 unpinned := false
254 for _, node := range page.Items {
255 if node.Pinned {
256 pins = append(pins, node)
257 } else {
258 unpinned = true
259 }
260 }
261 if unpinned || page.NextCursor == "" {
262 project.Children = pins
263 break
264 }
265 req.Cursor = page.NextCursor
266 }
267 }
268 return projects
269 }
270
271 func (a *App) unadoptedLegacyTopics(req ProjectTopicPageRequest, adopted, adoptedTopics map[string]bool) (ProjectTopicPage, error) {
272 legacyReq := req
273 legacyReq.Cursor, legacyReq.Limit = "", 200
274 legacy := ProjectTopicPage{Items: []ProjectNode{}}
275 for {
276 page, err := a.listProjectTopics(legacyReq)
277 if err != nil {
278 return page, err
279 }
280 legacy.Complete, legacy.ReadyDirectories, legacy.PendingDirectories, legacy.FailedDirectories = page.Complete, page.ReadyDirectories, page.PendingDirectories, page.FailedDirectories
281 legacy.Revision = max(legacy.Revision, page.Revision)
282 expanded := []ProjectNode{}
283 for _, node := range page.Items {
284 expanded = append(expanded, expandSessionSourceRows(node)...)
285 }
286 for _, node := range expanded {
287 if node.Source != nil {
288 node.PreparationStatus = a.historicalPreparationStatus(node.Source.SourceKey)
289 if !adopted[projectNodeSessionKey(node)] {
290 legacy.Items = append(legacy.Items, node)
291 }
292 continue
293 }
294 if adopted[sessionRuntimeKey(node.SessionPath)] || (node.SessionPath == "" && adoptedTopics[node.TopicID]) {
295 remaining := []ProjectNode{}
296 for _, child := range node.Children {
297 if !adopted[sessionRuntimeKey(child.SessionPath)] {
298 remaining = append(remaining, child)
299 }
300 }
301 if len(remaining) > 0 {
302 node.Children = remaining
303 node.SessionPath = remaining[0].SessionPath
304 legacy.Items = append(legacy.Items, node)
305 }
306 continue
307 }
308 legacy.Items = append(legacy.Items, node)
309 }
310 if page.NextCursor == "" {
311 break
312 }
313 if page.NextCursor == legacyReq.Cursor {
314 return legacy, fmt.Errorf("legacy session cursor did not advance")
315 }
316 legacyReq.Cursor = page.NextCursor
317 }
318 return legacy, nil
319 }
320
321 func (a *App) canonicalTopicNodes(req ProjectTopicPageRequest, state workspacestate.State, workspace workspacestate.Workspace, infos map[string]session.SessionInfo, initial []ProjectNode) []ProjectNode {
322 workspaceID := workspace.ID
323 service := a.desktopSessionService("")
324 nodes := initial
325 query := strings.ToLower(strings.TrimSpace(req.Query))
326 cutoff := desktopSessionTimeCutoff(req.TimeFilter)
327 createdTopics := loadTopicCreatedAts(topicTitleRoot(req.Scope, req.WorkspaceRoot))
328 for index, id := range workspace.SessionIDs {
329 if state.SessionStates[id].Lifecycle != workspacestate.Active {
330 continue
331 }
332 info, found := infos[id]
333 row := workspaceSessionRow(workspaceID, id, info, found, false, service)
334 if found && cutoff > 0 && max(row.CreatedAt, row.UpdatedAt) < cutoff {
335 continue
336 }
337 if query != "" && !strings.Contains(strings.ToLower(row.Title+"\n"+row.Preview+"\n"+id), query) {
338 continue
339 }
340 ref := session.SessionRef{HostID: localDesktopHostID, SessionID: id}
341 presentation := state.Presentation[id]
342 label := a.localizedTopicTitle(sessionDisplayTitle(info, presentation))
343 kind := "topic"
344 if req.Scope != "project" {
345 kind = "global_topic"
346 }
347 topicID := presentation.TopicID
348 if topicID == "" {
349 topicID = "canonical-" + id
350 }
351 sortOrder := index
352 createdAt := row.CreatedAt
353 if previous := createdTopics[topicID]; previous > 0 {
354 createdAt = previous
355 }
356 node := ProjectNode{
357 Key: "canonical_" + id, Kind: kind, Label: label, Root: workspace.Root,
358 TopicID: topicID, Session: &ref, SessionPath: sessionRoute(id), CanArchive: row.Health != "missing",
359 Preview: row.Preview, Turns: row.Turns, TurnsState: row.MetadataStatus, Health: row.Health,
360 CreatedAt: createdAt, LastActivityAt: row.UpdatedAt, ResultSequence: row.ResultSequence, Open: row.Running,
361 Pinned: presentation.Pinned, SortOrder: sortOrder, Children: []ProjectNode{},
362 }
363 if row.ParentSessionID != "" {
364 node.ParentSession = &session.SessionRef{HostID: localDesktopHostID, SessionID: row.ParentSessionID}
365 }
366 node.SessionOrigin = row.Origin
367 if projectNodeRequestAllows(req, node) {
368 nodes = append(nodes, node)
369 }
370 }
371 return nodes
372 }
373
374 func filterWorkspaceSessionNodes(req ProjectTopicPageRequest, org workspacestate.Organization, state workspacestate.State, workspaceID string, nodes []ProjectNode) []ProjectNode {
375 ranks := map[string]int{}
376 for i, key := range org.Order {
377 ranks[key] = i
378 }
379 filtered := []ProjectNode{}
380 seen := map[string]bool{}
381 cutoff := desktopSessionTimeCutoff(req.TimeFilter)
382 query := strings.ToLower(strings.TrimSpace(req.Query))
383 for _, n := range nodes {
384 key := projectNodeSessionKey(n)
385 if seen[key] {
386 continue
387 }
388 seen[key] = true
389 n.SortOrder = -1
390 if org.ManualOrderEnabled {
391 if rank, ok := ranks[key]; ok {
392 n.SortOrder = rank
393 }
394 }
395 if n.Session != nil {
396 n.IdentityAliases = sourceAliases(state, workspaceID, n.Session.SessionID)
397 n.LifecycleGeneration = state.SessionStates[n.Session.SessionID].Generation
398 }
399 if !projectNodeRequestAllows(req, n) || cutoff > 0 && max(n.CreatedAt, n.LastActivityAt) < cutoff {
400 continue
401 }
402 if query != "" && !strings.Contains(strings.ToLower(n.Label+"\n"+n.Preview+"\n"+key), query) {
403 continue
404 }
405 filtered = append(filtered, n)
406 }
407 return filtered
408 }
409
409 lines GO