返回 DeepSeek-Reasonix
session_catalog_runtime.go
根目录 / desktop / session_catalog_runtime.go
1 package main
2
3 import (
4 "context"
5 "path/filepath"
6 "reasonix/internal/control"
7 "reasonix/internal/event"
8 "reasonix/internal/sessioncatalog"
9 "sort"
10 "strings"
11 "time"
12 )
13
14 // Sidebar reads are bound so a starved connection pool or a slow projection
15 // degrades into a stale page the next revision repairs, instead of a Wails call
16 // that never returns and a tree that never moves again.
17 const sessionCatalogReadTimeout = 10 * time.Second
18
19 func (a *App) catalogReadContext() (context.Context, context.CancelFunc) {
20 return context.WithTimeout(a.bootContext(), sessionCatalogReadTimeout)
21 }
22
23 type catalogRuntimeSnapshot struct {
24 tabID string
25 scope string
26 workspaceRoot string
27 topicID string
28 sessionPath string
29 sessionHeadID string
30 activity string
31 topicTitle string
32 topicTitleSource string
33 ctrl control.SessionAPI
34 state *event.RuntimeStateSnapshot
35 open bool
36 }
37
38 type catalogRuntimeOverlay struct {
39 open bool
40 running bool
41 status string
42 }
43
44 func catalogRuntimeStatus(activity string, runtimeStatus control.RuntimeStatus) string {
45 status := normalizeTopicStatus(activity)
46 if runtimeStatus.PendingPrompt {
47 return topicStatusWaitingConfirmation
48 }
49 if runtimeStatus.Running {
50 if status == "" || status == topicStatusError || status == topicStatusPaused || status == topicStatusAwaitingDelivery {
51 return topicStatusThinking
52 }
53 return status
54 }
55 if runtimeStatus.BackgroundJobs > 0 {
56 return topicStatusBackgroundJob
57 }
58 if status == topicStatusError || status == topicStatusPaused || status == topicStatusAwaitingDelivery {
59 return status
60 }
61 return status
62 }
63
64 func (a *App) catalogRuntimeOverlays() (map[string]catalogRuntimeOverlay, map[string]catalogRuntimeOverlay) {
65 topics := map[string]catalogRuntimeOverlay{}
66 sessions := map[string]catalogRuntimeOverlay{}
67 for _, snap := range a.catalogRuntimeSnapshots() {
68 path := strings.TrimSpace(snap.sessionPath)
69 if snap.ctrl != nil {
70 if path == "" {
71 path = snap.ctrl.SessionPath()
72 }
73 }
74 status, running := catalogControllerStatus(snap.ctrl, snap.activity)
75 overlay := catalogRuntimeOverlay{open: snap.open, running: running, status: status}
76 key := topicSummaryKey(snap.scope, snap.workspaceRoot, snap.topicID)
77 current := topics[key]
78 current.open = current.open || overlay.open
79 current.running = current.running || overlay.running
80 if current.status == "" {
81 current.status = overlay.status
82 }
83 topics[key] = current
84 if path != "" {
85 sessions[sessionRuntimeKey(path)] = overlay
86 }
87 }
88 return topics, sessions
89 }
90
91 func (a *App) metadataProjectTopics(scope, workspaceRoot string) []ProjectNode {
92 f := loadProjectsFile()
93 deleted := map[string]bool{}
94 for _, topicID := range f.DeletedTopics {
95 deleted[topicID] = true
96 }
97 if state, err := a.workspaceRegistry().Load(a.bootContext()); err == nil {
98 for topicID := range purgedCanonicalTopicIDs(state) {
99 deleted[topicID] = true
100 }
101 }
102 ids := f.GlobalTopics
103 pinnedIDs := f.GlobalPinnedTopics
104 manualOrder := f.GlobalManualTopicOrder
105 titleRoot := ""
106 projectColor := normalizeProjectColor(f.GlobalColor)
107 if scope == "project" {
108 ids = nil
109 pinnedIDs = nil
110 manualOrder = false
111 titleRoot = workspaceRoot
112 for _, project := range f.Projects {
113 if sameProjectRoot(project.Root, workspaceRoot) {
114 ids = project.Topics
115 pinnedIDs = project.PinnedTopics
116 manualOrder = project.ManualTopicOrder
117 projectColor = project.Color
118 break
119 }
120 }
121 }
122 titles := loadTopicTitles(titleRoot)
123 sources := loadTopicTitleSources(titleRoot)
124 created := loadTopicCreatedAts(titleRoot)
125 topicOverlays, _ := a.catalogRuntimeOverlays()
126 runtimeNodes := a.runtimeOnlyProjectTopics(scope, workspaceRoot)
127 runtimeByTopic := map[string][]ProjectNode{}
128 for _, node := range runtimeNodes {
129 runtimeByTopic[node.TopicID] = append(runtimeByTopic[node.TopicID], node)
130 }
131 out := []ProjectNode{}
132 seen := map[string]bool{}
133 for sortOrder, topicID := range pinnedTopicIDs(orderedTopicIDs(ids, titles), pinnedIDs) {
134 if !manualOrder {
135 sortOrder = -1
136 }
137 if deleted[topicID] {
138 continue
139 }
140 seen[topicID] = true
141 title := strings.TrimSpace(titles[topicID])
142 if title == "" {
143 title = defaultTopicTitle
144 }
145 kind := "topic"
146 if scope != "project" {
147 kind = "global_topic"
148 }
149 overlay := topicOverlays[topicSummaryKey(scope, workspaceRoot, topicID)]
150 node := ProjectNode{
151 Key: kind + "_" + topicID, Kind: kind,
152 Label: a.localizedTopicTitle(title, sources[topicID]), Root: workspaceRoot,
153 TopicID: topicID, ProjectColor: projectColor,
154 CreatedAt: topicCreatedAtForTree(created, topicID), Pinned: containsDesktopString(pinnedIDs, topicID), SortOrder: sortOrder,
155 Open: overlay.open, Running: overlay.running, Status: overlay.status,
156 TurnsState: string(sessioncatalog.TurnsUnknown), Health: string(sessioncatalog.HealthOK),
157 Children: []ProjectNode{},
158 }
159 if runtimeRows := runtimeByTopic[topicID]; len(runtimeRows) > 0 {
160 for _, runtimeNode := range runtimeRows {
161 runtimeNode.Pinned, runtimeNode.SortOrder = node.Pinned, node.SortOrder
162 runtimeNode.CreatedAt, runtimeNode.ProjectColor = node.CreatedAt, node.ProjectColor
163 if strings.TrimSpace(runtimeNode.Label) == "" {
164 runtimeNode.Label = node.Label
165 }
166 out = append(out, runtimeNode)
167 }
168 continue
169 }
170 out = append(out, node)
171 }
172 for _, runtimeNode := range runtimeNodes {
173 if seen[runtimeNode.TopicID] || deleted[runtimeNode.TopicID] {
174 continue
175 }
176 runtimeNode.RuntimeOnly = true
177 out = append(out, runtimeNode)
178 }
179 return out
180 }
181
182 func (a *App) runtimeOnlyProjectTopics(scope, workspaceRoot string) []ProjectNode {
183 nodes, _ := a.runtimeOnlyProjectTopicsWithSessions(scope, workspaceRoot)
184 return nodes
185 }
186
187 // runtimeOnlyProjectTopicsWithSessions also reports each runtime topic's known
188 // session paths so callers can resolve the topics those sessions project onto
189 // in the catalog (a restored tab may carry a legacy topic ID for a re-anchored
190 // recovery lineage).
191 func (a *App) runtimeOnlyProjectTopicsWithSessions(scope, workspaceRoot string) ([]ProjectNode, map[string][]string) {
192 snapshots := []catalogRuntimeSnapshot{}
193 for _, snapshot := range a.catalogRuntimeSnapshots() {
194 if scope == "project" {
195 if snapshot.scope != "project" || !sameProjectRoot(snapshot.workspaceRoot, workspaceRoot) {
196 continue
197 }
198 } else if snapshot.scope == "project" {
199 continue
200 }
201 snapshots = append(snapshots, snapshot)
202 }
203 return a.runtimeProjectTopicNodes(scope, workspaceRoot, snapshots, true)
204 }
205
206 func (a *App) runtimeProjectTopicNodes(scope, workspaceRoot string, snapshots []catalogRuntimeSnapshot, previews bool) ([]ProjectNode, map[string][]string) {
207 sessionsByTopic := map[string][]string{}
208 out := []ProjectNode{}
209 kind := "topic"
210 if scope != "project" {
211 kind = "global_topic"
212 }
213 for _, snapshot := range snapshots {
214 if snapshot.sessionPath == "" && snapshot.ctrl != nil {
215 snapshot.sessionPath = snapshot.ctrl.SessionPath()
216 }
217 path := strings.TrimSpace(snapshot.sessionPath)
218 if path != "" {
219 sessionsByTopic[snapshot.topicID] = append(sessionsByTopic[snapshot.topicID], path)
220 }
221 label := defaultTopicTitle
222 if strings.TrimSpace(snapshot.topicTitle) != "" {
223 label = snapshot.topicTitle
224 }
225 // A canonical route is an internal identity, never a display name. Only
226 // legacy file-backed sessions may use their filename as the last-resort
227 // runtime label while the catalog is catching up.
228 if _, canonical := parseSessionRoute(path); !canonical {
229 if pathLabel := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)); pathLabel != "" && pathLabel != "." {
230 label = pathLabel
231 }
232 }
233 var status string
234 var running bool
235 if snapshot.state != nil {
236 status, running = catalogStateStatus(*snapshot.state, snapshot.activity)
237 } else {
238 status, running = catalogControllerStatus(snapshot.ctrl, snapshot.activity)
239 }
240 preview := ""
241 if previews {
242 preview = sessionPreviewForPath(path)
243 }
244 key := kind + "_" + snapshot.topicID
245 if path != "" {
246 key = projectSessionNodeKey(scope, path)
247 }
248 out = append(out, ProjectNode{
249 Key: key, Kind: kind, Label: a.localizedTopicTitle(label, snapshot.topicTitleSource),
250 Root: workspaceRoot, TopicID: snapshot.topicID, SessionPath: path, Preview: preview,
251 Open: snapshot.open, Running: running, Status: status,
252 TurnsState: string(sessioncatalog.TurnsUnknown), Health: string(sessioncatalog.HealthOK),
253 Children: []ProjectNode{},
254 })
255 }
256 sort.SliceStable(out, func(i, j int) bool { return out[i].Key < out[j].Key })
257 return out, sessionsByTopic
258 }
259
260 func (a *App) projectNodeFromCatalogTopic(topic sessioncatalog.TopicRecord, topicOverlays, sessionOverlays map[string]catalogRuntimeOverlay, preferred map[string]struct{}) (ProjectNode, bool) {
261 kind := "topic"
262 if topic.Scope == "global" {
263 kind = "global_topic"
264 }
265 recoveryOnly := topic.RecoveryState == "recovery_only" && recoveryOnlyHasContent(topic.Sessions)
266 canonicalRecovery := topic.RecoveryState == "adopted" || topic.RecoveryState == "preferred"
267 recoveryState := ""
268 recoveryBranchCount := 0
269 recoveryUnresolvedCount := 0
270 recoveryCleanupEligibleCount := 0
271 if recoveryOnly {
272 recoveryState = topic.RecoveryState
273 recoveryBranchCount = topic.RecoveryBranchCount
274 recoveryUnresolvedCount = topic.RecoveryUnresolvedCount
275 recoveryCleanupEligibleCount = topic.RecoveryCleanupEligibleCount
276 } else if canonicalRecovery {
277 recoveryState = topic.RecoveryState
278 }
279 overlay := topicOverlays[topicSummaryKey(topic.Scope, topic.WorkspaceRoot, topic.TopicID)]
280 node := ProjectNode{
281 Key: kind + "_" + topic.TopicID, Kind: kind, Label: a.localizedTopicTitle(topic.Title, topic.TitleSource),
282 Root: topic.WorkspaceRoot, TopicID: topic.TopicID, Turns: topic.Turns,
283 Preview: topicSessionPreview(topic.Sessions, topic.RepresentativePath),
284 TurnsState: string(topic.TurnsState), Health: string(topic.Health),
285 CreatedAt: topic.CreatedAt, LastActivityAt: topic.LastActivityAt,
286 Pinned: topic.Pinned, SortOrder: topic.SortOrder,
287 Recovered: recoveryOnly || canonicalRecovery, RecoveryState: recoveryState,
288 RecoveryBranchCount: recoveryBranchCount, RecoveryUnresolvedCount: recoveryUnresolvedCount,
289 RecoveryCleanupEligibleCount: recoveryCleanupEligibleCount,
290 Open: overlay.open, Running: overlay.running, Status: overlay.status,
291 // Ordinary tree is zero-config: never surface recovery counts, badges,
292 // or forced-handling status. History "other saved versions" owns that.
293 Children: []ProjectNode{},
294 }
295 // Fall back to topic-local preference when the workspace map is unavailable
296 // so multi-fork topics still collapse instead of listing every replica.
297 localPreferred := preferred
298 if localPreferred == nil {
299 localPreferred = sessioncatalog.PreferredOrdinarySessionPaths(topic.Sessions)
300 }
301 visible := make([]sessioncatalog.SessionRecord, 0, len(topic.Sessions))
302 runtimeSessions := make([]runtimeSessionStatus, 0, len(topic.Sessions))
303 for _, session := range topic.Sessions {
304 sessionOverlay := sessionOverlays[sessionRuntimeKey(session.Path)]
305 // Aggregate open/running state from every physical member onto the
306 // single logical row — never expand recovery runtimes as children.
307 if sessionOverlay.open {
308 node.Open = true
309 }
310 if sessionOverlay.running {
311 node.Running = true
312 if node.Status == "" {
313 node.Status = sessionOverlay.status
314 }
315 }
316 // 1.23 ordinary-list contract: hide idle covered copies and non-
317 // preferred conflict forks. Open/running recovery is still not a
318 // second row — status is already aggregated above.
319 if !sessioncatalog.OrdinaryTreeSession(session, false, false, localPreferred) {
320 continue
321 }
322 visible = append(visible, session)
323 runtimeSessions = append(runtimeSessions, runtimeSessionStatus{
324 open: sessionOverlay.open, running: sessionOverlay.running,
325 })
326 }
327 summary := topicSummaryFromCatalogTopic(topic, visible)
328 if !recoveryOnly && topicHiddenAsRecoveryOnly(summary, topic.Pinned, append(runtimeSessions, runtimeSessionStatus{
329 open: overlay.open || node.Open, running: overlay.running || node.Running,
330 })) {
331 return ProjectNode{Children: []ProjectNode{}}, false
332 }
333 if a.ordinaryTreeHidesUnindexedBlank(topic) {
334 return ProjectNode{Children: []ProjectNode{}}, false
335 }
336 // After filtering non-preferred recovery forks, a topic may have nothing
337 // left. A recovery-only topic still gets one logical row so the user can
338 // reach its saved content; the physical copies remain history-only.
339 if len(visible) == 0 {
340 if recoveryOnly {
341 representative := recoveryOnlyRepresentative(topic.Sessions)
342 node.Recovered = true
343 node.Turns = representative.Turns
344 node.Preview = strings.TrimSpace(representative.Preview)
345 node.SessionPath = representative.Path
346 return node, true
347 }
348 if topic.Pinned || overlay.open || overlay.running || node.Open || node.Running {
349 return node, true
350 }
351 return ProjectNode{Children: []ProjectNode{}}, false
352 }
353 // Ordinary list is always one logical row. Multiple normal non-recovery
354 // sessions under one topic also collapse: open/running already aggregated.
355 // History "other saved versions" is the only place physical forks appear.
356 if live := a.liveSessionPathForTopic(topic.Scope, topic.WorkspaceRoot, topic.TopicID); live != "" {
357 node.SessionPath = live
358 } else if rep := strings.TrimSpace(topic.RepresentativePath); rep != "" {
359 node.SessionPath = rep
360 } else if path := sessioncatalog.CanonicalSessionPathForTopic(visible, ""); path != "" {
361 node.SessionPath = path
362 } else if len(visible) == 1 {
363 node.SessionPath = visible[0].Path
364 }
365 return node, true
366 }
367
368 func (a *App) ordinaryTreeHidesUnindexedBlank(topic sessioncatalog.TopicRecord) bool {
369 if topic.Pinned || topic.Turns > 0 {
370 return false
371 }
372 if !isDefaultTopicTitle(topic.Title) && strings.TrimSpace(topic.Title) != "" {
373 return false
374 }
375 for _, session := range topic.Sessions {
376 if session.Turns > 0 || strings.TrimSpace(session.Preview) != "" {
377 return false
378 }
379 }
380 return !topicIndexedInRegistry(topic.Scope, topic.WorkspaceRoot, topic.TopicID)
381 }
382
383 func topicSummaryFromCatalogTopic(topic sessioncatalog.TopicRecord, visible []sessioncatalog.SessionRecord) topicSummary {
384 summary := topicSummary{turns: topic.Turns, lastActivityAt: topic.LastActivityAt}
385 if len(visible) == 0 {
386 // Catalog still has only covered recovery copies for this topic.
387 if topic.RecoveryState == "recovery_only" {
388 summary.hasRecoveryOnly = true
389 }
390 return summary
391 }
392 for _, session := range visible {
393 if session.RecoveryCopy {
394 summary.hasRecoveryOnly = true
395 continue
396 }
397 if session.Recovered || strings.TrimSpace(session.RecoveryDigest) != "" {
398 summary.hasAdoptedRecovery = true
399 if session.Turns > summary.adoptedRecoveryTurns {
400 summary.adoptedRecoveryTurns = session.Turns
401 }
402 continue
403 }
404 summary.hasNormalSession = true
405 }
406 if topic.RecoveryState == "recovery_only" && !summary.hasNormalSession && !summary.hasAdoptedRecovery {
407 summary.hasRecoveryOnly = true
408 }
409 return summary
410 }
411
412 func (a *App) listProjectTopics(req ProjectTopicPageRequest) (ProjectTopicPage, error) {
413 catalog := a.sessionCatalog.Load()
414 if catalog == nil {
415 return a.metadataTopicPage(req)
416 }
417 var availability catalogWorkspaceAvailability
418 if req.readAvailability != nil {
419 availability = *req.readAvailability
420 } else {
421 availability = a.catalogWorkspaceAvailability(catalog, req.Scope, req.WorkspaceRoot, req.readContext)
422 }
423 if !availability.usable {
424 // A freshly opened catalog cache is live but empty until the first directory
425 // scan. Treat that the same as "catalog unavailable" so upgrade does
426 // not blank the sidebar that desktop-projects.json still knows about.
427 page, err := a.metadataTopicPage(req)
428 if err != nil {
429 return page, err
430 }
431 page = availability.decorate(page, catalog.Status().Revision)
432 return a.withLiveTopics(catalog, req, page), nil
433 }
434 page, err := a.catalogTopicPage(catalog, req)
435 if err != nil {
436 return page, err
437 }
438 // Metadata is a continuity source while some directories are pending or
439 // degraded. Once every target has completed, the catalog is authoritative:
440 // retaining metadata-only shells would resurrect recovery copies or deleted
441 // sessions that the completed scan deliberately folded/removed.
442 if !availability.complete {
443 page, err = a.mergeMetadataTopics(req, page)
444 if err != nil {
445 return page, err
446 }
447 }
448 page = availability.decorate(page, max(page.Revision, catalog.Status().Revision))
449 return a.withLiveTopics(catalog, req, page), nil
450 }
451
452 func normalizeDesktopTopicScope(scope, workspaceRoot string) (string, string) {
453 if strings.TrimSpace(scope) != "project" {
454 return "global", ""
455 }
456 return "project", strings.TrimSpace(workspaceRoot)
457 }
458
459 // withLiveTopics restores topics the catalog does not (yet) carry. A tab is
460 // authoritative for its own existence, while the catalog is a projection that
461 // can lag a fresh session, fall behind a stalled writer, or run degraded — and
462 // the sidebar must never hide a conversation this app is running. Only an
463 // uncursored page merges, so keyset pagination past it stays the catalog's.
464 func (a *App) withLiveTopics(catalog *sessioncatalog.Catalog, req ProjectTopicPageRequest, page ProjectTopicPage) ProjectTopicPage {
465 if strings.TrimSpace(req.Cursor) != "" {
466 return page
467 }
468 indexed := make(map[string]bool, len(page.Items))
469 projectedPaths := make(map[string]bool, len(page.Items))
470 for _, item := range page.Items {
471 indexed[item.TopicID] = true
472 if path := strings.TrimSpace(item.SessionPath); path != "" {
473 projectedPaths[sessionRuntimeKey(path)] = true
474 }
475 }
476 query := strings.ToLower(strings.TrimSpace(req.Query))
477 live := []ProjectNode{}
478 runtimeNodes, sessionsByTopic := a.runtimeOnlyProjectTopicsWithSessions(req.Scope, req.WorkspaceRoot)
479 ctx, cancel := a.catalogReadContext()
480 defer cancel()
481 if req.readContext != nil {
482 ctx = req.readContext
483 }
484 for _, node := range runtimeNodes {
485 if indexed[node.TopicID] {
486 continue
487 }
488 if !projectTopicRequestAllows(req, node.TopicID, node.Pinned) || !projectNodeRequestAllows(req, node) {
489 continue
490 }
491 // A restored tab may still carry a legacy topic ID for a recovery
492 // session the catalog re-anchored onto the root logical topic. That
493 // logical row already represents the conversation, so a second
494 // runtime-only row would break the one-row ordinary-list contract.
495 if liveTopicProjectedOnPage(ctx, catalog, sessionsByTopic[node.TopicID], projectedPaths, indexed) {
496 continue
497 }
498 if query != "" && !strings.Contains(strings.ToLower(node.Label), query) {
499 continue
500 }
501 live = append(live, node)
502 }
503 if len(live) == 0 {
504 return page
505 }
506 f := loadProjectsFile()
507 deleted := map[string]bool{}
508 for _, topicID := range f.DeletedTopics {
509 deleted[topicID] = true
510 }
511 created := loadTopicCreatedAts(topicTitleRoot(req.Scope, req.WorkspaceRoot))
512 kept := page.Items[:0:0]
513 for _, node := range live {
514 if deleted[node.TopicID] {
515 continue
516 }
517 node.RuntimeOnly = true
518 node.CreatedAt = topicCreatedAtForTree(created, node.TopicID)
519 node.LastActivityAt = node.CreatedAt
520 kept = append(kept, node)
521 }
522 page.Items = append(kept, page.Items...)
523 return page
524 }
525
526 // liveTopicProjectedOnPage reports whether every catalog-known session of a
527 // runtime-only topic already projects onto a topic on this page. Any session
528 // the catalog has not indexed yet keeps the live row (that is the lag case
529 // withLiveTopics exists for), and an off-page projection also keeps it so an
530 // open conversation never disappears from the first page.
531 func liveTopicProjectedOnPage(ctx context.Context, catalog *sessioncatalog.Catalog, paths []string, projectedPaths, projectedTopicIDs map[string]bool) bool {
532 if catalog == nil || len(paths) == 0 {
533 return false
534 }
535 for _, path := range paths {
536 record, ok, err := catalog.GetSession(ctx, path)
537 if err != nil || !ok {
538 return false
539 }
540 logicalTopicID := strings.TrimSpace(record.LogicalTopicID)
541 if logicalTopicID == "" {
542 logicalTopicID = strings.TrimSpace(record.TopicID)
543 }
544 if !projectedPaths[sessionRuntimeKey(path)] && !projectedTopicIDs[logicalTopicID] {
545 return false
546 }
547 }
548 return true
549 }
550
551 func (a *App) catalogTopicPage(catalog *sessioncatalog.Catalog, req ProjectTopicPageRequest) (ProjectTopicPage, error) {
552 if !req.readAllSources && manualSessionOrderFor(req.Scope, req.WorkspaceRoot) {
553 return a.catalogSessionOrderedPage(catalog, req)
554 }
555 out := ProjectTopicPage{Items: []ProjectNode{}}
556 manualOrder := manualTopicOrderFor(req.Scope, req.WorkspaceRoot)
557 if req.readAllSources {
558 manualOrder = false
559 }
560 limit := req.Limit
561 if limit <= 0 {
562 limit = sessioncatalog.DefaultLimit
563 }
564 if limit > sessioncatalog.MaxLimit {
565 limit = sessioncatalog.MaxLimit
566 }
567 topicOverlays, sessionOverlays := a.catalogRuntimeOverlays()
568 ctx, cancel := a.catalogReadContext()
569 defer cancel()
570 if req.readContext != nil {
571 ctx = req.readContext
572 }
573 // Workspace-wide preference collapses cross-topic recovery replicas that
574 // share a lineage but were indexed as separate topic rows.
575 preferred, prefErr := catalog.PreferredOrdinarySessionPaths(ctx, req.Scope, req.WorkspaceRoot)
576 if prefErr != nil {
577 preferred = nil
578 }
579 cursor := req.Cursor
580 // Keep scanning past pages that are entirely idle recovery copies so the
581 // sidebar never shows an empty "no sessions" state when later pages still
582 // have ordinary topics.
583 for {
584 page, err := catalog.ListTopics(ctx, sessioncatalog.TopicPageRequest{
585 Scope: req.Scope, WorkspaceRoot: req.WorkspaceRoot, Cursor: cursor,
586 Limit: limit, Query: req.Query, TimeFilter: req.TimeFilter, SortMode: req.SortMode,
587 ManualOrder: manualOrder, IncludeTopicIDsJSON: req.groupIncludeJSON,
588 ExcludeTopicIDsJSON: req.groupExcludeJSON, ExcludePinned: req.ExcludePinned,
589 PinnedOnly: req.pinnedOnly,
590 CursorBinding: req.groupCursorBind,
591 })
592 if err != nil {
593 return out, err
594 }
595 out.Revision = page.Revision
596 for i, topic := range page.Items {
597 nodes := a.projectNodesFromCatalogTopic(topic, topicOverlays, sessionOverlays, preferred)
598 filtered := nodes[:0]
599 for _, node := range nodes {
600 if projectNodeRequestAllows(req, node) {
601 filtered = append(filtered, node)
602 }
603 }
604 nodes = filtered
605 if len(nodes) == 0 {
606 continue
607 }
608 out.Items = append(out.Items, nodes...)
609 // Keep every session belonging to one historical topic on the same
610 // page. The page may exceed limit by that topic's expansion, and the
611 // cursor advances past the whole topic so no sibling is skipped.
612 if len(out.Items) >= limit {
613 if i+1 < len(page.Items) || page.NextCursor != "" {
614 out.NextCursor = encodeProjectTopicCursor(topic, req.SortMode, manualOrder, req.groupCursorBind)
615 }
616 return out, nil
617 }
618 }
619 if page.NextCursor == "" {
620 out.NextCursor = ""
621 return out, nil
622 }
623 cursor = page.NextCursor
624 }
625 }
626
627 func projectTopicSortValue(createdAt, lastActivityAt int64, sortMode string) int64 {
628 if strings.TrimSpace(sortMode) == "created" {
629 if createdAt > 0 {
630 return createdAt
631 }
632 return lastActivityAt
633 }
634 if lastActivityAt > 0 {
635 return lastActivityAt
636 }
637 return createdAt
638 }
639
640 func (a *App) GetSessionCatalogStatus() SessionCatalogStatus {
641 return a.currentSessionCatalogStatus()
642 }
643
644 // ListProjectTree is the one-release compatibility wrapper. It composes only
645 // catalog pages and project shells; it never migrates, scans, or decodes a
646 // session synchronously.
647 func (a *App) ListProjectTree() ([]ProjectNode, error) {
648 snapshot, err := a.GetProjectTreeSnapshot()
649 if err != nil {
650 return []ProjectNode{}, err
651 }
652 hasGlobal := false
653 for _, project := range snapshot.Projects {
654 if project.Kind == "global_folder" {
655 hasGlobal = true
656 break
657 }
658 }
659 if !hasGlobal && len(a.metadataProjectTopics("global", "")) > 0 {
660 f := loadProjectsFile()
661 label := strings.TrimSpace(f.GlobalTitle)
662 if label == "" {
663 label = "Global"
664 }
665 snapshot.Projects = append(snapshot.Projects, ProjectNode{
666 Key: "global_folder", Kind: "global_folder", Label: label,
667 Root: globalWorkspaceRoot(), ProjectColor: normalizeProjectColor(f.GlobalColor), Children: []ProjectNode{},
668 })
669 snapshot.Projects = applyPinnedProjectOrder(applyProjectTreeOrder(snapshot.Projects, f.SidebarOrder), f.PinnedProjects)
670 }
671 for index := range snapshot.Projects {
672 project := &snapshot.Projects[index]
673 // The lightweight snapshot carries pinned topic shells for collapsed
674 // folders. This compatibility wrapper rebuilds the complete child list,
675 // so start clean to avoid duplicating those shells with catalog rows.
676 project.Children = []ProjectNode{}
677 if project.Remote != nil {
678 continue
679 }
680 scope := "project"
681 root := project.Root
682 if project.Kind == "global_folder" {
683 scope = "global"
684 root = ""
685 }
686 cursor := ""
687 for {
688 page, err := a.ListProjectTopics(ProjectTopicPageRequest{Scope: scope, WorkspaceRoot: root, Cursor: cursor, Limit: sessioncatalog.MaxLimit})
689 if err != nil {
690 return nil, err
691 }
692 if cursor == "" {
693 defer a.ReleaseReadSnapshot(page.SnapshotID)
694 }
695 project.Children = append(project.Children, page.Items...)
696 if page.NextCursor == "" {
697 break
698 }
699 cursor = page.NextCursor
700 }
701 }
702 return snapshot.Projects, nil
703 }
704
705 func (a *App) catalogSessionPathForTopic(scope, workspaceRoot, topicID string) string {
706 if strings.TrimSpace(topicID) == "" {
707 return ""
708 }
709 catalog := a.sessionCatalog.Load()
710 if catalog == nil {
711 return ""
712 }
713 topic, ok, err := catalog.GetTopic(a.bootContext(), sessioncatalog.TopicKey{Scope: scope, WorkspaceRoot: workspaceRoot, TopicID: topicID})
714 if err != nil || !ok || len(topic.Sessions) == 0 {
715 return ""
716 }
717 if representative := strings.TrimSpace(topic.RepresentativePath); representative != "" {
718 return representative
719 }
720 if canonical := sessioncatalog.CanonicalSessionPathForTopic(topic.Sessions, ""); canonical != "" {
721 return canonical
722 }
723 preferred := sessioncatalog.PreferredOrdinarySessionPaths(topic.Sessions)
724 sort.SliceStable(topic.Sessions, func(i, j int) bool {
725 // Prefer ordinary-tree survivors, then real conversations over copies.
726 iPref := sessioncatalog.OrdinaryTreeSession(topic.Sessions[i], false, false, preferred)
727 jPref := sessioncatalog.OrdinaryTreeSession(topic.Sessions[j], false, false, preferred)
728 if iPref != jPref {
729 return iPref
730 }
731 if topic.Sessions[i].RecoveryCopy != topic.Sessions[j].RecoveryCopy {
732 return !topic.Sessions[i].RecoveryCopy
733 }
734 return topic.Sessions[i].LastActivityAt > topic.Sessions[j].LastActivityAt
735 })
736 return topic.Sessions[0].Path
737 }
738
738 lines GO