| 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 |