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