返回 DeepSeek-Reasonix
tabs.go
根目录 / desktop / tabs.go
1 package main
2
3 import (
4 "context"
5 "crypto/rand"
6 "crypto/sha256"
7 "encoding/hex"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "log/slog"
12 "maps"
13 "os"
14 "path/filepath"
15 "reasonix/internal/agent"
16 "reasonix/internal/billing"
17 "reasonix/internal/boot"
18 "reasonix/internal/config"
19 "reasonix/internal/control"
20 "reasonix/internal/event"
21 "reasonix/internal/eventwire"
22 "reasonix/internal/extension/providerext"
23 "reasonix/internal/fileutil"
24 "reasonix/internal/notify"
25 "reasonix/internal/provider"
26 "reasonix/internal/session"
27 "reasonix/internal/sessiontitle"
28 "reasonix/internal/store"
29 "reasonix/internal/turnevent"
30 "slices"
31 "sort"
32 "strings"
33 "sync"
34 "sync/atomic"
35 "time"
36 "unicode"
37 )
38
39 // WorkspaceTab
40
41 // tabDisplayState follows one live runtime across visible, detached, and
42 // reattached WorkspaceTab wrappers. Keeping one shared state pointer closes the
43 // handoff window where an event already routed to the old wrapper could append
44 // after a clone copied its buffers.
45 type tabDisplayState struct {
46 mu sync.Mutex
47 planner displayTurnBuffer
48 executor displayTurnBuffer
49 pendingWrites []*pendingDisplayWrite
50 persistRunning bool
51 }
52
53 const displayPersistRetryLimit = 4
54
55 var errNoDesktopChatModel = errors.New("no desktop chat model is available; add a chat-capable provider in Settings > Model > Access")
56
57 func resolveDraftCreateModelStrict(cfg *config.Config, model string) (string, error) {
58 if providerext.PluginRefOwner(model) != "" {
59 return model, nil
60 }
61 resolved, ok := cfg.ResolveModel(model)
62 if !ok {
63 return "", fmt.Errorf("%w: %q", boot.ErrUnknownModel, model)
64 }
65 return resolved.Name + "/" + resolved.Model, nil
66 }
67
68 type pendingDisplayWrite struct {
69 dir string
70 sessionPath string
71 userContent string
72 messages []HistoryMessage
73 persist func(string, string, string, []HistoryMessage) error
74 onPersisted func()
75 onRetry func()
76 }
77
78 // WorkspaceTab is one open conversation tab in the desktop. Each tab owns an
79 // independent controller (its own agent, session, tool registry, plugin host,
80 // memory, permissions) scoped to a workspace root, so multiple projects and
81 // topics can be active concurrently without interfering.
82 type WorkspaceTab struct {
83 ID string // stable random id
84 Scope string // "project" | "global"
85 WorkspaceRoot string // project root dir (empty for global)
86 SessionWorkspace desktopTabWorkspace // stable Workspace registry identity
87 SharedHostKey string // opaque key for the shared plugin host (set by buildTabController)
88 TopicID string // topic within the project
89 TopicTitle string // display title
90 topicTitleSource string // auto or manual; controls localization at API boundaries
91 SessionPath string // exact .jsonl file this tab continues
92 SessionID string // immutable v3 identity; empty for legacy/read-only tabs
93 PendingCreateOperationID string // durable create reservation used before the first turn
94 draftAdmission *draftAdmissionProfile
95 persistenceExtra map[string]json.RawMessage // unknown desktop-tabs.json fields retained across rewrites
96 SessionGeneration uint64 // bumps on session rotation (clear/new); frontend hydrate identity
97 ReadOnly bool // true for external channel transcripts opened for browsing
98 Takeover struct{ Spectator bool } // handoff state grouped by its cross-runtime lifetime
99 Ctrl control.SessionAPI // nil while booting / on error
100 Label string // model label (for the tab badge)
101 Ready bool // true once boot.Build completes
102 StartupErr string // build error, surfaced to the frontend
103 HistoricalSource *SessionSourceRef // immutable, pending explicit preparation after restore
104 StartupErrLeaseHeld bool // true when StartupErr can be retried after a session lease releases
105 modelApplication tabModelApplicationState // guarded by App.mu; never persisted
106 runtimeID string // process-local SessionRuntime registry identity
107 sessionLease *agent.SessionLease
108 sessionLeaseMu sync.Mutex
109 sessionLeaseKey atomic.Pointer[string] // lock-free mirror; updated with sessionLease under sessionLeaseMu
110 sink *tabEventSink // routes events with this tab's ID
111 buildCancel context.CancelFunc // cancels in-flight boot for tabs removed before Ready
112 buildGeneration uint64 // identifies the current in-flight build
113 // buildDone is closed exactly once when the build that owns buildDoneGen
114 // terminates (success, failure, or superseded abandon). Topic-activation
115 // completions wait on it to learn that the controller build finished
116 // without polling. Guarded by App.mu alongside buildGeneration; always
117 // nil-ed after close so a replacement build can install a fresh channel.
118 buildDone chan struct{}
119 buildDoneGen uint64
120 removed bool // set when the visible tab is pruned/closed before build completes
121 reconcileMu sync.Mutex // serializes stale controller workspace repair for this tab
122 turnStartMu sync.Mutex // serializes foreground turn admission for this tab
123
124 ActivityStatus string // transient project-tree status for the in-flight turn
125
126 saveMu sync.Mutex
127 saving bool
128 saveAgain bool
129 saveFailures int
130 // lastAutosaveWarnAt debounces the user-facing autosave-failure notice:
131 // a persistently failing disk (AV hold, full volume) otherwise emits a
132 // chat warning for every completed turn. Logs are never debounced.
133 lastAutosaveWarnAt time.Time
134
135 // closing is set under saveMu when the tab is being torn down. Once set,
136 // tabSnapshotLoop stops taking new snapshot work and CloseTab waits on
137 // saveCond until any in-flight snapshot finishes - so no background
138 // snapshot can write a session file back to disk after CloseTab returns.
139 // Without this, deleting a just-closed session races that write and the
140 // session "resurrects" (#4384).
141 closing bool
142 saveCond *sync.Cond
143
144 // readTelemetry tracks files read during this tab's session.
145 readTelemetry []readFileRecord
146 usageTelemetry sessionUsageStats
147 // runtimeCostQuote is an automatic wallet-currency hint for the live tab.
148 // It is deliberately outside usageTelemetry so it cannot be persisted into
149 // telemetry/history or become configuration. Guarded by telemMu.
150 runtimeCostDisplayCurrency string
151 runtimeCostQuote *billing.CostQuote
152 runtimeCostGeneration uint64 // invalidates stale wallet responses
153 // telemetrySessionKey is the sessionRuntimeKey the telemetry above belongs
154 // to. Controller-side session rotations (typed /new, bot /reset) bypass the
155 // App bindings, so telemetry writers and readers re-key through
156 // syncTelemetryToSession before trusting the in-memory totals — otherwise a
157 // previous session's cost keeps accumulating under the new session and gets
158 // persisted into its sidecar (#5850).
159 telemetrySessionKey string
160 telemMu sync.Mutex
161
162 // Display-only output belongs to the live runtime, not a particular visible
163 // tab wrapper. detach/reattach paths share this state before rebinding the
164 // event sink so output cannot fall into a discarded wrapper.
165 displayStateMu sync.Mutex
166 displayState *tabDisplayState
167
168 model string // active model ref (for meta)
169 effort *string
170 qualityFloor string // fixed standard compatibility value
171 mode string // "normal" | "plan" | "yolo" | "plan-yolo"; yolo/full access is runtime-only
172 goal string
173 toolApprovalMode string
174 disabledMCP map[string]ServerView
175 mcpOrder []string
176 lastBuildResult *boot.BuildResult // incremental extension reload
177
178 PinnedFiles []string
179 pendingLegacyPinnedFiles []string // round-tripped until the session sidecar publishes
180 pinnedFilesMu sync.RWMutex
181
182 // metaExtras caches the expensive MetaForTab fields (git branch, image
183 // input capability) computed off the request path by
184 // refreshTabMetaExtras. Lock-free reads keep MetaForTab synchronous and
185 // cheap; refresh dedup goes through metaExtrasRefreshing.
186 metaExtras atomic.Pointer[tabMetaExtras]
187 metaExtrasRefreshing atomic.Bool
188 }
189
190 const (
191 topicStatusThinking = "thinking"
192 topicStatusStreaming = "streaming"
193 topicStatusWaitingConfirmation = "waiting_confirmation"
194 topicStatusBackgroundJob = "background_job"
195 topicStatusPaused = "paused"
196 topicStatusError = "error"
197 // topicStatusDivergedRecovery marks a topic holding two or more independent
198 // recovery branches. It is informational: the user picks which to keep, so
199 // it must not gate archiving the way live runtime states do.
200 topicStatusDivergedRecovery = "diverged_recovery"
201 )
202
203 type readFileRecord struct {
204 Path string `json:"path"`
205 Turn int `json:"turn"`
206 Time int64 `json:"time"`
207 Offset int `json:"offset,omitempty"`
208 Limit int `json:"limit,omitempty"`
209 Truncated bool `json:"truncated,omitempty"`
210 }
211
212 type sessionUsageStats struct {
213 PromptTokens int `json:"promptTokens"`
214 CompletionTokens int `json:"completionTokens"`
215 TotalTokens int `json:"totalTokens"`
216 ReasoningTokens int `json:"reasoningTokens"`
217 CacheHitTokens int `json:"cacheHitTokens"`
218 CacheMissTokens int `json:"cacheMissTokens"`
219 CacheWriteTokens int `json:"cacheWriteTokens,omitempty"`
220 // CacheWriteBilledTokens preserves provider-specific cache-write pricing
221 // across persisted telemetry repricing without changing hit-rate totals.
222 CacheWriteBilledTokens float64 `json:"cacheWriteBilledTokens,omitempty"`
223 Estimated bool `json:"estimated,omitempty"`
224 // LastUsedTokens is the executor-reported context fill (prompt+completion)
225 // from the most recent turn. It is persisted so the status bar / context
226 // panel can show a meaningful fill percentage after a session rebind
227 // rebuilds the controller (which resets the in-memory executor state).
228 LastUsedTokens int `json:"lastUsedTokens,omitempty"`
229 // Per-turn token breakdown from the most recent turn. Persisted separately
230 // from the cumulative totals above so the context-panel donut chart and
231 // type breakdown survive a session rebind (which resets executor.LastUsage).
232 LastPromptTokens int `json:"lastPromptTokens,omitempty"`
233 LastCompletionTokens int `json:"lastCompletionTokens,omitempty"`
234 LastReasoningTokens int `json:"lastReasoningTokens,omitempty"`
235 LastCacheHitTokens int `json:"lastCacheHitTokens,omitempty"`
236 LastCacheMissTokens int `json:"lastCacheMissTokens,omitempty"`
237 LastEstimated bool `json:"lastEstimated,omitempty"`
238 RequestCount int `json:"requestCount"`
239 ElapsedMs int64 `json:"elapsedMs"`
240 SessionCost float64 `json:"sessionCost,omitempty"`
241 SessionCurrency string `json:"sessionCurrency,omitempty"`
242 SessionCostUsd float64 `json:"sessionCostUsd,omitempty"`
243 // SessionCostComplete is false when any entry lacks a shared display valuation.
244 SessionCostComplete bool `json:"sessionCostComplete,omitempty"`
245 // CostLedger stores occurrence-time quotes keyed by model+source+fingerprint+rateDate.
246 CostLedger *billing.Ledger `json:"costLedger,omitempty"`
247 // SessionCostQuote is the aggregate quote for the current display currency.
248 SessionCostQuote *billing.CostQuote `json:"sessionCostQuote,omitempty"`
249 Sources map[string]usageSourceStats `json:"sources,omitempty"`
250
251 activeTurnStartedAt int64
252 sourceSessionCache map[string]sourceSessionCacheCounters
253 }
254
255 type usageSourceStats struct {
256 PromptTokens int `json:"promptTokens"`
257 CompletionTokens int `json:"completionTokens"`
258 TotalTokens int `json:"totalTokens"`
259 ReasoningTokens int `json:"reasoningTokens"`
260 CacheHitTokens int `json:"cacheHitTokens"`
261 CacheMissTokens int `json:"cacheMissTokens"`
262 CacheWriteTokens int `json:"cacheWriteTokens,omitempty"`
263 CacheWriteBilledTokens float64 `json:"cacheWriteBilledTokens,omitempty"`
264 Estimated bool `json:"estimated,omitempty"`
265 RequestCount int `json:"requestCount"`
266 SessionCost float64 `json:"sessionCost,omitempty"`
267 SessionCurrency string `json:"sessionCurrency,omitempty"`
268 SessionCostUsd float64 `json:"sessionCostUsd,omitempty"`
269 }
270
271 type sourceSessionCacheCounters struct {
272 Hit int
273 Miss int
274 }
275
276 func cloneSessionUsageStats(in sessionUsageStats) sessionUsageStats {
277 out := in
278 if len(in.Sources) > 0 {
279 out.Sources = make(map[string]usageSourceStats, len(in.Sources))
280 maps.Copy(out.Sources, in.Sources)
281 }
282 if len(in.sourceSessionCache) > 0 {
283 out.sourceSessionCache = make(map[string]sourceSessionCacheCounters, len(in.sourceSessionCache))
284 maps.Copy(out.sourceSessionCache, in.sourceSessionCache)
285 }
286 return out
287 }
288
289 func (s *sessionUsageStats) cacheTokenDelta(source string, u *provider.Usage, sessionHit, sessionMiss int) (hit, miss int) {
290 if u != nil {
291 hit = u.CacheHitTokens
292 miss = u.CacheMissTokens
293 }
294 if source != event.UsageSourceExecutor && source != event.UsageSourcePlanner {
295 return hit, miss
296 }
297 if sessionHit+sessionMiss <= 0 {
298 return hit, miss
299 }
300 if s.sourceSessionCache == nil {
301 s.sourceSessionCache = map[string]sourceSessionCacheCounters{}
302 }
303 prev, ok := s.sourceSessionCache[source]
304 s.sourceSessionCache[source] = sourceSessionCacheCounters{Hit: sessionHit, Miss: sessionMiss}
305 if !ok {
306 return sessionHit, sessionMiss
307 }
308 if sessionHit < prev.Hit || sessionMiss < prev.Miss {
309 if hit+miss > 0 {
310 return hit, miss
311 }
312 return sessionHit, sessionMiss
313 }
314 return sessionHit - prev.Hit, sessionMiss - prev.Miss
315 }
316
317 type tabTelemetrySnapshot struct {
318 Version int `json:"version"`
319 ReadFiles []readFileRecord `json:"readFiles"`
320 Usage sessionUsageStats `json:"usage"`
321 }
322
323 func cloneStringPtr(v *string) *string {
324 if v == nil {
325 return nil
326 }
327 cp := *v
328 return &cp
329 }
330
331 func cloneServerViewMap(in map[string]ServerView) map[string]ServerView {
332 out := make(map[string]ServerView, len(in))
333 for name, view := range in {
334 view.EnvKeys = append([]string(nil), view.EnvKeys...)
335 view.HeaderKeys = append([]string(nil), view.HeaderKeys...)
336 out[name] = view
337 }
338 return out
339 }
340
341 func (t *WorkspaceTab) currentSessionPath() string {
342 if t == nil {
343 return ""
344 }
345 tabPath := strings.TrimSpace(t.SessionPath)
346 // Recovery handoff is two-phase: the desktop callback acquires the new
347 // lease and updates SessionPath before Controller commits its own path. The
348 // lease-backed tab path is authoritative during that window; otherwise a
349 // concurrent, newer tab-layout save can overwrite the recovery anchor with
350 // the controller's old path. Outside a handoff, keep the controller-first
351 // behavior so an unleased/stale tab field cannot mask the live runtime.
352 if tabPath != "" && sessionRuntimeKey(tabPath) == t.sessionLeaseRuntimeKey() {
353 return tabPath
354 }
355 if t.Ctrl != nil {
356 if path := strings.TrimSpace(t.Ctrl.SessionPath()); path != "" {
357 return path
358 }
359 }
360 return tabPath
361 }
362
363 func (t *WorkspaceTab) currentSessionIdentity() string {
364 if t == nil {
365 return ""
366 }
367 if id := strings.TrimSpace(t.SessionID); id != "" {
368 return remoteSessionIDRoutePrefix + id
369 }
370 return t.currentSessionPath()
371 }
372
373 func (t *WorkspaceTab) hasActiveRuntimeWork() bool {
374 if t == nil || t.Ctrl == nil {
375 return false
376 }
377 status := t.Ctrl.RuntimeStatus()
378 return status.Running || status.PendingPrompt || status.BackgroundJobs > 0
379 }
380
381 // sessionRuntimeKey is the comparison/map key for "same session" checks. It
382 // layers agent.CanonicalSessionPath on top of the desktop path normalization
383 // so the key matches the form held by session leases (lowercased on Windows).
384 // Comparing a lease's Path() against a raw tab path without this fold made
385 // every rebuild on Windows look like a foreign holder (self-lock, #5999).
386 // Keys are identities only — never use them as display or file paths.
387 func sessionRuntimeKey(path string) string {
388 locator := classifySessionLocator(path)
389 switch locator.kind {
390 case sessionLocatorCanonical:
391 return sessionRoute(locator.ref.SessionID)
392 case sessionLocatorLegacy:
393 path, ok, err := legacySessionPathForFileAccess(string(locator.legacyPath))
394 if err != nil || !ok {
395 return ""
396 }
397 return agent.CanonicalSessionPath(string(path))
398 default:
399 return ""
400 }
401 }
402
403 var sessionLeaseAcquireHookForTest func()
404
405 func (t *WorkspaceTab) ensureSessionLease(path string) error {
406 if t == nil || t.ReadOnly {
407 return nil
408 }
409 legacyPath, ok, err := legacySessionPathForFileAccess(path)
410 if err != nil {
411 return err
412 }
413 if !ok {
414 return nil
415 }
416 key := sessionRuntimeKey(string(legacyPath))
417 t.sessionLeaseMu.Lock()
418 if t.sessionLease != nil && sessionRuntimeKey(t.sessionLease.Path()) == key {
419 t.storeSessionLeaseRuntimeKey(key)
420 t.sessionLeaseMu.Unlock()
421 return nil
422 }
423 lease, err := agent.TryAcquireSessionLease(string(legacyPath))
424 if err != nil {
425 t.sessionLeaseMu.Unlock()
426 return err
427 }
428 if hook := sessionLeaseAcquireHookForTest; hook != nil {
429 hook()
430 }
431 old := t.sessionLease
432 t.sessionLease = lease
433 t.storeSessionLeaseRuntimeKey(key)
434 t.sessionLeaseMu.Unlock()
435 if old != nil {
436 old.Release()
437 }
438 return nil
439 }
440
441 func (t *WorkspaceTab) releaseSessionLease() {
442 if t == nil {
443 return
444 }
445 t.sessionLeaseMu.Lock()
446 lease := t.sessionLease
447 t.sessionLease = nil
448 t.storeSessionLeaseRuntimeKey("")
449 t.sessionLeaseMu.Unlock()
450 if lease != nil {
451 lease.Release()
452 }
453 }
454
455 // takeSessionLease removes and returns the tab's current lease WITHOUT
456 // releasing it, so ownership can transfer to another holder. All access to
457 // t.sessionLease must go through sessionLeaseMu; never read or assign the
458 // field directly outside these helpers.
459 func (t *WorkspaceTab) takeSessionLease() *agent.SessionLease {
460 if t == nil {
461 return nil
462 }
463 t.sessionLeaseMu.Lock()
464 lease := t.sessionLease
465 t.sessionLease = nil
466 t.storeSessionLeaseRuntimeKey("")
467 t.sessionLeaseMu.Unlock()
468 return lease
469 }
470
471 // adoptSessionLease installs lease as the tab's session lease, releasing any
472 // previously held lease unless it is the very same lease. A nil tab releases
473 // the lease immediately so ownership is never dropped on the floor.
474 func (t *WorkspaceTab) adoptSessionLease(lease *agent.SessionLease) {
475 if t == nil {
476 if lease != nil {
477 lease.Release()
478 }
479 return
480 }
481 t.sessionLeaseMu.Lock()
482 old := t.sessionLease
483 t.sessionLease = lease
484 key := ""
485 if lease != nil {
486 key = sessionRuntimeKey(lease.Path())
487 }
488 t.storeSessionLeaseRuntimeKey(key)
489 t.sessionLeaseMu.Unlock()
490 if old != nil && old != lease {
491 old.Release()
492 }
493 }
494
495 func (t *WorkspaceTab) storeSessionLeaseRuntimeKey(key string) {
496 if t == nil || key == "" {
497 if t != nil {
498 t.sessionLeaseKey.Store(nil)
499 }
500 return
501 }
502 stored := key
503 t.sessionLeaseKey.Store(&stored)
504 }
505
506 // sessionLeaseRuntimeKey reports the runtime key of the currently held lease,
507 // or "" when no lease is held. The mirror is lock-free so callers holding
508 // App.mu never wait on a concurrent lease acquisition (whose test hook and
509 // platform file operations run under sessionLeaseMu).
510 func (t *WorkspaceTab) sessionLeaseRuntimeKey() string {
511 if t == nil {
512 return ""
513 }
514 key := t.sessionLeaseKey.Load()
515 if key == nil {
516 return ""
517 }
518 return *key
519 }
520
521 // releaseSessionLeaseForKey releases the tab's lease only when it is bound to
522 // key. Superseded builds clean up with this instead of releaseSessionLease:
523 // on a removed tab the keys match and the lease is released as before, but
524 // when a session rebind superseded the build, the rebind's replacement build
525 // holds a lease for a *different* session key (rebind early-returns on equal
526 // keys), and releasing that here would strip the live session's protection.
527 func (t *WorkspaceTab) releaseSessionLeaseForKey(key string) {
528 if t == nil || key == "" {
529 return
530 }
531 t.sessionLeaseMu.Lock()
532 lease := t.sessionLease
533 if lease == nil || sessionRuntimeKey(lease.Path()) != key {
534 t.sessionLeaseMu.Unlock()
535 return
536 }
537 t.sessionLease = nil
538 t.storeSessionLeaseRuntimeKey("")
539 t.sessionLeaseMu.Unlock()
540 lease.Release()
541 }
542
543 func detachedRuntimeTabID(key string) string {
544 sum := sha256.Sum256([]byte(key))
545 return "detached_" + hex.EncodeToString(sum[:8])
546 }
547
548 func (a *App) ensureDetachedSessionsLocked() {
549 if a.detachedSessions == nil {
550 a.detachedSessions = map[string]*WorkspaceTab{}
551 }
552 }
553
554 func (a *App) runtimeTabsLocked() []*WorkspaceTab {
555 seen := map[*WorkspaceTab]bool{}
556 out := make([]*WorkspaceTab, 0, len(a.tabs)+len(a.detachedSessions))
557 for _, tab := range a.tabs {
558 if tab != nil && !seen[tab] {
559 seen[tab] = true
560 out = append(out, tab)
561 }
562 }
563 for _, tab := range a.detachedSessions {
564 if tab != nil && !seen[tab] {
565 seen[tab] = true
566 out = append(out, tab)
567 }
568 }
569 return out
570 }
571
572 func (a *App) tabByEventSinkIDLocked(tabID string) *WorkspaceTab {
573 if tab := a.tabs[tabID]; tab != nil {
574 return tab
575 }
576 for _, tab := range a.detachedSessions {
577 if tab != nil && tab.ID == tabID {
578 return tab
579 }
580 }
581 return nil
582 }
583
584 func (a *App) detachSessionRuntime(tab *WorkspaceTab) bool {
585 if tab == nil {
586 return false
587 }
588 a.mu.RLock()
589 ctrl := tab.Ctrl
590 fallbackIdentity := strings.TrimSpace(tab.currentSessionIdentity())
591 sink := tab.sink
592 a.mu.RUnlock()
593 identity := fallbackIdentity
594 if ctrl != nil && tab.SessionID == "" {
595 if p := strings.TrimSpace(ctrl.SessionPath()); p != "" {
596 identity = p
597 }
598 }
599 key := sessionRuntimeKey(identity)
600 if key == "" {
601 return false
602 }
603 if sink != nil {
604 sink.clearContext()
605 }
606 a.mu.Lock()
607 setTabSessionIdentity(tab, identity)
608 a.registerDetachedRuntimeLocked(tab)
609 a.mu.Unlock()
610 return true
611 }
612
613 func setTabSessionIdentity(tab *WorkspaceTab, identity string) {
614 if tab == nil {
615 return
616 }
617 tab.HistoricalSource = nil
618 locator := classifySessionLocator(identity)
619 if locator.kind == sessionLocatorCanonical {
620 tab.SessionID = locator.ref.SessionID
621 tab.SessionPath = ""
622 return
623 }
624 tab.SessionID = ""
625 if locator.kind == sessionLocatorLegacy {
626 tab.SessionPath = canonicalTabSessionPath(string(locator.legacyPath))
627 } else {
628 tab.SessionPath = ""
629 }
630 }
631
632 // cloneDetachedRuntimeTab copies a running tab's runtime state into a fresh
633 // detached tab. Callers must hold a.mu: the copied fields (Ctrl, Ready,
634 // ActivityStatus, disabledMCP, ...) are written under a.mu by bound methods
635 // and the event sink, and the disabledMCP map read would otherwise race those
636 // writers. The session lease is transferred separately by the caller through
637 // the sessionLeaseMu helpers. key is the runtime identity (map key / tab id
638 // hash); path is the real session path — keys are case-folded on Windows and
639 // must not leak into SessionPath, which is displayed and persisted.
640 func cloneDetachedRuntimeTab(tab *WorkspaceTab, key, path string) *WorkspaceTab {
641 if tab == nil {
642 return nil
643 }
644 tab.telemMu.Lock()
645 readTelemetry := append([]readFileRecord(nil), tab.readTelemetry...)
646 usageTelemetry := cloneSessionUsageStats(tab.usageTelemetry)
647 telemetrySessionKey := tab.telemetrySessionKey
648 tab.telemMu.Unlock()
649 pinnedFiles, pendingLegacyPinnedFiles := tab.pinnedFilesState()
650
651 detached := &WorkspaceTab{
652 ID: detachedRuntimeTabID(key),
653 Scope: tab.Scope,
654 WorkspaceRoot: tab.WorkspaceRoot,
655 SessionWorkspace: tab.SessionWorkspace,
656 SharedHostKey: tab.SharedHostKey,
657 TopicID: tab.TopicID,
658 TopicTitle: tab.TopicTitle,
659 topicTitleSource: tab.topicTitleSource,
660 Ctrl: tab.Ctrl,
661 Label: tab.Label,
662 Ready: tab.Ready,
663 StartupErr: tab.StartupErr,
664 StartupErrLeaseHeld: tab.StartupErrLeaseHeld,
665 modelApplication: tab.modelApplication,
666 lastBuildResult: tab.lastBuildResult,
667 runtimeID: tab.runtimeID,
668 sink: tab.sink,
669 ActivityStatus: tab.ActivityStatus,
670 readTelemetry: readTelemetry,
671 usageTelemetry: usageTelemetry,
672 telemetrySessionKey: telemetrySessionKey,
673 displayState: tab.displayBufferState(),
674 model: tab.model,
675 effort: cloneStringPtr(tab.effort),
676 qualityFloor: tab.qualityFloor,
677 mode: tab.mode,
678 goal: tab.goal,
679 toolApprovalMode: tab.toolApprovalMode,
680 disabledMCP: cloneServerViewMap(tab.disabledMCP),
681 mcpOrder: append([]string(nil), tab.mcpOrder...),
682 PinnedFiles: pinnedFiles,
683 pendingLegacyPinnedFiles: pendingLegacyPinnedFiles,
684 }
685 if tab.SessionID != "" {
686 setTabSessionIdentity(detached, sessionRoute(tab.SessionID))
687 } else {
688 setTabSessionIdentity(detached, path)
689 }
690 return detached
691 }
692
693 func (a *App) detachRuntimeForReplacement(tab *WorkspaceTab) bool {
694 if tab == nil {
695 return false
696 }
697 // One a.mu critical section covers the membership check, the field
698 // snapshot, the lease/sink handover, and the re-publication:
699 // - the clone reads fields that bound methods and the event sink write
700 // under a.mu (ActivityStatus every event, disabledMCP is a map);
701 // - inserting the clone without re-checking a.tabs would resurrect a
702 // runtime that DeleteSession/TrashTopic/RemoveWorkspace already
703 // unlinked and closed (the "session resurrects" class, #4384);
704 // - publishing before the lease/sink handover would let a concurrent
705 // attachExistingSessionRuntime claim a half-initialized clone.
706 // The lease transfer stays deadlock-safe here: neither side holds a lease
707 // to release, so no lease I/O runs under a.mu.
708 a.mu.Lock()
709 detached := a.detachRuntimeForReplacementLocked(tab)
710 a.mu.Unlock()
711 return detached
712 }
713
714 // detachRuntimeForReplacementLocked transfers a visible tab's live runtime to
715 // the detached registry without closing its controller or releasing its lease.
716 // Callers must hold App.mu. The transfer itself performs no file or host I/O.
717 func (a *App) detachRuntimeForReplacementLocked(tab *WorkspaceTab) bool {
718 if tab == nil {
719 return false
720 }
721 if tab.removed || a.tabs[tab.ID] != tab {
722 return false
723 }
724 sourceIdentity := tab.currentSessionIdentity()
725 key := sessionRuntimeKey(sourceIdentity)
726 if key == "" {
727 return false
728 }
729 detached := cloneDetachedRuntimeTab(tab, key, tab.currentSessionPath())
730 if detached == nil {
731 return false
732 }
733 // Transfer lease ownership through the locked helpers: a concurrent
734 // ensureSessionLease (blank-session boot, recovery callback) must never
735 // observe a torn pointer or have its freshly acquired lease clobbered.
736 detached.adoptSessionLease(tab.takeSessionLease())
737 if rt := a.runtimeForTabLocked(tab); rt != nil {
738 rt.Owner = detached
739 detached.runtimeID = rt.ID
740 tab.runtimeID = ""
741 }
742 if detached.sink != nil {
743 detached.sink.setBinding(detached.ID, nil)
744 // clearContext (locked nil + drain the queued emitter), not a bare
745 // ctx=nil: the latter both data-races s.ctx and leaves already-queued
746 // events to flush onto the rebound tab after this session is backgrounded
747 // (#5352 — stale "AI 不断输出" on the now-visible session).
748 detached.sink.clearContext()
749 }
750 a.registerDetachedRuntimeLocked(detached)
751 return true
752 }
753
754 // applyRuntimeTab moves source's runtime (controller, sink, lease, telemetry)
755 // onto target. path is the real session path for display/persistence; the
756 // case-folded runtime key must never be written into SessionPath.
757 func applyRuntimeTab(target, source *WorkspaceTab, path string, appCtx context.Context, app *App) {
758 if target == nil || source == nil {
759 return
760 }
761 source.telemMu.Lock()
762 readTelemetry := append([]readFileRecord(nil), source.readTelemetry...)
763 usageTelemetry := cloneSessionUsageStats(source.usageTelemetry)
764 telemetrySessionKey := source.telemetrySessionKey
765 source.telemMu.Unlock()
766 pinnedFiles, pendingLegacyPinnedFiles := source.pinnedFilesState()
767
768 // Share the runtime-owned display state before rebinding the sink. An event
769 // already routed to source and one arriving on target after setBinding then
770 // append under the same state lock instead of straddling two buffers.
771 target.adoptDisplayState(source.displayBufferState())
772 if source.sink != nil {
773 source.sink.setBinding(target.ID, app)
774 source.sink.setSessionGeneration(target.SessionGeneration)
775 source.sink.setContext(appCtx)
776 }
777
778 target.Ctrl = source.Ctrl
779 target.modelApplication.failure = source.modelApplication.failure
780 target.lastBuildResult = source.lastBuildResult
781 target.sink = source.sink
782 target.adoptSessionLease(source.takeSessionLease())
783 if source.SessionID != "" {
784 target.SessionID = source.SessionID
785 target.SessionPath = ""
786 } else {
787 setTabSessionIdentity(target, path)
788 }
789 target.SharedHostKey = source.SharedHostKey
790 target.Label = source.Label
791 target.Ready = source.Ready && source.Ctrl != nil
792 clearTabStartupError(target)
793 target.ActivityStatus = source.ActivityStatus
794 target.model = source.model
795 target.effort = cloneStringPtr(source.effort)
796 target.qualityFloor = control.QualityFloorStandard
797 target.mode = source.mode
798 target.goal = source.goal
799 target.toolApprovalMode = source.toolApprovalMode
800 target.disabledMCP = cloneServerViewMap(source.disabledMCP)
801 target.mcpOrder = append([]string(nil), source.mcpOrder...)
802 target.setPinnedFilesState(pinnedFiles, pendingLegacyPinnedFiles)
803 target.replaceTelemetry(tabTelemetrySnapshot{ReadFiles: readTelemetry, Usage: usageTelemetry}, telemetrySessionKey)
804 if app != nil {
805 key := sessionRuntimeKey(path)
806 rt := app.runtimeForTabLocked(source)
807 targetRuntime := app.runtimeForTabLocked(target)
808 if rt == nil {
809 rt = targetRuntime
810 }
811 if rt == nil {
812 rt = app.newSessionRuntimeLocked(source, key)
813 } else if targetRuntime != nil && targetRuntime != rt {
814 app.removeSessionRuntimeMappingsLocked(targetRuntime)
815 target.runtimeID = ""
816 }
817 if source.Ctrl != nil && source.Ready {
818 rt.Phase = sessionRuntimeReady
819 rt.Issue = nil
820 closeRuntimeReadyChannelLocked(rt)
821 }
822 rt.Owner = target
823 if rt.Key != "" && rt.Key != key && app.runtimeBySessionKey[rt.Key] == rt {
824 delete(app.runtimeBySessionKey, rt.Key)
825 }
826 rt.Key = key
827 app.runtimeBySessionKey[key] = rt
828 target.runtimeID = rt.ID
829 source.runtimeID = ""
830 if target.sink != nil {
831 target.sink.setRuntimeEpoch(rt.Epoch)
832 }
833 }
834 }
835
836 func (a *App) attachExistingSessionRuntimeCore(tab *WorkspaceTab, path string, appCtx context.Context) bool {
837 key := sessionRuntimeKey(path)
838 if tab == nil || key == "" {
839 return false
840 }
841
842 a.mu.Lock()
843 if tab.removed || a.tabs[tab.ID] != tab {
844 a.mu.Unlock()
845 return false
846 }
847 if rt := a.runtimeBySessionKey[key]; rt != nil && !a.runtimeOwnerLiveLocked(rt) {
848 a.removeSessionRuntimeMappingsLocked(rt)
849 }
850 registered := a.runtimeBySessionKey[key]
851 if registered != nil && registered.Phase == sessionRuntimeStarting && registered.Owner != tab {
852 // A starting runtime owns only an admission placeholder; its controller,
853 // lease, and sink have not been published yet. Moving that tab would
854 // supersede the owner build while the attaching build closes its own
855 // candidate, leaving the session permanently starting with no controller.
856 // claimSessionRuntime waits on readyCh and retries the attach after the
857 // owner publishes a terminal phase. The owner itself may still adopt a
858 // usable legacy runtime that predates the registry.
859 a.mu.Unlock()
860 return false
861 }
862 attachable := func(source *WorkspaceTab) bool {
863 if source == nil || source.Ctrl == nil {
864 return false
865 }
866 if rt := a.runtimeForTabLocked(source); rt != nil {
867 return rt.Phase == sessionRuntimeReady
868 }
869 // Compatibility for visible/detached runtimes constructed before the
870 // process-local registry existed.
871 return source.Ready
872 }
873 detached := a.liveRuntimeTabMatchingLocked(tab, path)
874 if detached != nil && a.tabs[detached.ID] == detached {
875 detached = nil
876 }
877 if detached == nil && key != "" {
878 if rt := a.runtimeBySessionKey[key]; rt != nil && rt.Owner != nil && rt.Owner != tab {
879 detached = rt.Owner
880 if a.tabs[detached.ID] == detached {
881 detached = nil
882 }
883 }
884 }
885 if detached != nil {
886 if !attachable(detached) {
887 a.mu.Unlock()
888 return false
889 }
890 a.unregisterDetachedRuntimeLocked(detached)
891 applyRuntimeTab(tab, detached, runtimeAttachIdentity(detached, path), appCtx, a)
892 if current := a.tabs[tab.ID]; current == tab {
893 a.saveTabsLocked()
894 }
895 attachedCtrl := tab.Ctrl
896 attachedSink := tab.sink
897 attachedEpoch := a.runtimeEpochForTabLocked(tab)
898 a.mu.Unlock()
899 a.replayPendingPromptsAfterRuntimeAttach(tab.ID, attachedSink, attachedCtrl, attachedEpoch)
900 return true
901 }
902
903 source := a.liveRuntimeTabMatchingLocked(tab, path)
904 if source == nil && key != "" {
905 if rt := a.runtimeBySessionKey[key]; rt != nil && rt.Owner != nil && rt.Owner != tab {
906 source = rt.Owner
907 }
908 }
909 if source == nil {
910 a.mu.Unlock()
911 return false
912 }
913 if !attachable(source) {
914 a.mu.Unlock()
915 return false
916 }
917 delete(a.tabs, source.ID)
918 a.removeTabOrderLocked(source.ID)
919 if a.activeTabID == source.ID {
920 a.activeTabID = tab.ID
921 }
922 applyRuntimeTab(tab, source, runtimeAttachIdentity(source, path), appCtx, a)
923 a.saveTabsLocked()
924 attachedCtrl := tab.Ctrl
925 attachedSink := tab.sink
926 attachedEpoch := a.runtimeEpochForTabLocked(tab)
927 a.mu.Unlock()
928 if path != "" && !tab.ReadOnly {
929 a.attachTakeoverMirror(tab.ID, path)
930 go a.adoptSessionFromLocalServe(tab.ID, path)
931 }
932
933 a.replayPendingPromptsAfterRuntimeAttach(tab.ID, attachedSink, attachedCtrl, attachedEpoch)
934 return true
935 }
936
937 func (t *WorkspaceTab) recordReadFile(rec readFileRecord) {
938 t.telemMu.Lock()
939 t.readTelemetry = append(t.readTelemetry, rec)
940 t.telemMu.Unlock()
941 }
942
943 func (t *WorkspaceTab) recordTurnDone(now int64) {
944 t.telemMu.Lock()
945 if started := t.usageTelemetry.activeTurnStartedAt; started > 0 && now >= started {
946 t.usageTelemetry.ElapsedMs += now - started
947 t.usageTelemetry.activeTurnStartedAt = 0
948 }
949 t.telemMu.Unlock()
950 }
951
952 // contextTelemetryFromUsage returns the latest-attempt context shape for
953 // rebind-surviving Last* telemetry fields. Prefer Context* when set (multi-
954 // attempt sampling recovery); otherwise fall back to billable totals / the
955 // per-event cache delta already computed for this Usage event.
956 //
957 // When a Context shape is present, ContextCacheHit/Miss are kept even if both
958 // are zero — many providers omit cache splits, and falling back to the
959 // event's aggregated cache would re-inflate multi-attempt totals.
960 func contextTelemetryFromUsage(u *provider.Usage, eventCacheHit, eventCacheMiss int) (prompt, completion, reasoning, hit, miss int) {
961 if u == nil {
962 return 0, 0, 0, eventCacheHit, eventCacheMiss
963 }
964 if u.ContextPromptTokens > 0 || u.ContextCompletionTokens > 0 {
965 return u.ContextPromptTokens, u.ContextCompletionTokens, u.ContextReasoningTokens,
966 u.ContextCacheHitTokens, u.ContextCacheMissTokens
967 }
968 return u.PromptTokens, u.CompletionTokens, u.ReasoningTokens, eventCacheHit, eventCacheMiss
969 }
970
971 func (t *WorkspaceTab) recordUsage(e event.Event) {
972 if e.Usage == nil {
973 return
974 }
975 u := e.Usage
976 source := strings.TrimSpace(e.UsageSource)
977 if source == "" {
978 source = event.UsageSourceExecutor
979 }
980 t.telemMu.Lock()
981 t.usageTelemetry.PromptTokens += u.PromptTokens
982 t.usageTelemetry.CompletionTokens += u.CompletionTokens
983 t.usageTelemetry.TotalTokens += u.TotalTokens
984 t.usageTelemetry.ReasoningTokens += u.ReasoningTokens
985 cacheHitTokens, cacheMissTokens := t.usageTelemetry.cacheTokenDelta(source, u, e.SessionHit, e.SessionMiss)
986 t.usageTelemetry.CacheHitTokens += cacheHitTokens
987 t.usageTelemetry.CacheMissTokens += cacheMissTokens
988 t.usageTelemetry.CacheWriteTokens += u.CacheWriteTokens
989 t.usageTelemetry.CacheWriteBilledTokens += u.CacheWriteBilledTokens
990 t.usageTelemetry.Estimated = t.usageTelemetry.Estimated || u.Estimated
991 requestCount := u.RequestCount
992 if requestCount <= 0 {
993 requestCount = 1
994 }
995 t.usageTelemetry.RequestCount += requestCount
996 if source == event.UsageSourceExecutor {
997 // Persist the latest-attempt context shape for rebind fallback — never
998 // the multi-attempt billable aggregate (PromptTokens/CompletionTokens
999 // after stream recovery). ContextSnapshot semantics are latest
1000 // prompt+completion; Context* fields carry that shape.
1001 prompt, completion, reasoning, hit, miss := contextTelemetryFromUsage(u, cacheHitTokens, cacheMissTokens)
1002 t.usageTelemetry.LastUsedTokens = prompt + completion
1003 t.usageTelemetry.LastPromptTokens = prompt
1004 t.usageTelemetry.LastCompletionTokens = completion
1005 t.usageTelemetry.LastReasoningTokens = reasoning
1006 t.usageTelemetry.LastCacheHitTokens = hit
1007 t.usageTelemetry.LastCacheMissTokens = miss
1008 t.usageTelemetry.LastEstimated = u.Estimated
1009 }
1010 if t.usageTelemetry.Sources == nil {
1011 t.usageTelemetry.Sources = map[string]usageSourceStats{}
1012 }
1013 src := t.usageTelemetry.Sources[source]
1014 src.PromptTokens += u.PromptTokens
1015 src.CompletionTokens += u.CompletionTokens
1016 src.TotalTokens += u.TotalTokens
1017 src.ReasoningTokens += u.ReasoningTokens
1018 src.CacheHitTokens += cacheHitTokens
1019 src.CacheMissTokens += cacheMissTokens
1020 src.CacheWriteTokens += u.CacheWriteTokens
1021 src.CacheWriteBilledTokens += u.CacheWriteBilledTokens
1022 src.Estimated = src.Estimated || u.Estimated
1023 src.RequestCount += requestCount
1024 // Prefer the middleware CostQuote; fall back only when older emitters omit it.
1025 q := e.CostQuote
1026 if q == nil && e.Pricing != nil {
1027 q = event.EnsureCostQuote(e, nil)
1028 }
1029 if q != nil {
1030 if t.usageTelemetry.CostLedger == nil {
1031 t.usageTelemetry.CostLedger = billing.NewLedger()
1032 }
1033 tokens := billing.UsageTokens{
1034 PromptTokens: u.PromptTokens,
1035 CompletionTokens: u.CompletionTokens,
1036 CacheHitTokens: cacheHitTokens,
1037 CacheMissTokens: cacheMissTokens,
1038 CacheWriteTokens: u.CacheWriteTokens,
1039 CacheWriteBilledTokens: u.CacheWriteBilledTokens,
1040 Estimated: u.Estimated,
1041 }
1042 t.usageTelemetry.CostLedger.Add(*q, tokens, time.Now().UTC())
1043 display := billing.NormalizeCurrency(t.runtimeCostDisplayCurrency)
1044 if display == "" {
1045 display = billing.NormalizeCurrency(t.usageTelemetry.SessionCurrency)
1046 }
1047 if display == "" && q.Selected != nil {
1048 display = billing.NormalizeCurrency(q.Selected.Currency)
1049 }
1050 if display == "" {
1051 display = billing.NormalizeCurrency(q.Original.Currency)
1052 }
1053 total := t.usageTelemetry.CostLedger.Total(display)
1054 if t.runtimeCostDisplayCurrency != "" {
1055 t.runtimeCostQuote = &total
1056 } else {
1057 t.usageTelemetry.SessionCostQuote = &total
1058 t.usageTelemetry.SessionCostComplete = total.Complete
1059 }
1060 if total.Selected != nil {
1061 if t.runtimeCostDisplayCurrency == "" {
1062 t.usageTelemetry.SessionCost = total.Selected.Float64()
1063 t.usageTelemetry.SessionCurrency = total.LegacyCurrencyCode()
1064 t.usageTelemetry.SessionCostUsd = t.usageTelemetry.SessionCost
1065 }
1066 src.SessionCost += q.LegacyCostFloat()
1067 src.SessionCostUsd = src.SessionCost
1068 src.SessionCurrency = total.LegacyCurrencySymbol()
1069 } else {
1070 // Incomplete: never invent a zero total by wiping prior costs.
1071 if t.runtimeCostDisplayCurrency == "" {
1072 t.usageTelemetry.SessionCostComplete = false
1073 t.usageTelemetry.SessionCost = 0
1074 t.usageTelemetry.SessionCurrency = ""
1075 t.usageTelemetry.SessionCostUsd = 0
1076 }
1077 if q.Selected == nil {
1078 src.SessionCurrency = billing.CurrencySymbol(q.Original.Currency)
1079 }
1080 }
1081 }
1082 t.usageTelemetry.Sources[source] = src
1083 t.telemMu.Unlock()
1084 }
1085
1086 func (a *App) repriceTabUsageForCurrentCurrency(tab *WorkspaceTab) {
1087 if a == nil || tab == nil {
1088 return
1089 }
1090 a.mu.RLock()
1091 root := tab.WorkspaceRoot
1092 a.mu.RUnlock()
1093 cfg, err := config.LoadForRoot(root)
1094 if err != nil {
1095 return
1096 }
1097 // Display preference only — automatic mode remains unresolved until a
1098 // wallet-aware surface supplies a session hint.
1099 display := cfg.ExplicitDisplayCurrency()
1100 if !tab.selectDisplayCurrency(display) {
1101 return
1102 }
1103 if path := tab.currentSessionPath(); path != "" {
1104 _ = saveTelemetry(path+".telemetry.json", tab.telemetrySnapshot())
1105 }
1106 }
1107
1108 func (t *WorkspaceTab) telemetrySnapshot() tabTelemetrySnapshot {
1109 t.telemMu.Lock()
1110 defer t.telemMu.Unlock()
1111 records := make([]readFileRecord, len(t.readTelemetry))
1112 copy(records, t.readTelemetry)
1113 usage := t.usageTelemetry
1114 if started := usage.activeTurnStartedAt; started > 0 {
1115 now := time.Now().UnixMilli()
1116 if now >= started {
1117 usage.ElapsedMs += now - started
1118 }
1119 }
1120 if len(t.usageTelemetry.Sources) > 0 {
1121 usage.Sources = make(map[string]usageSourceStats, len(t.usageTelemetry.Sources))
1122 maps.Copy(usage.Sources, t.usageTelemetry.Sources)
1123 }
1124 usage.activeTurnStartedAt = 0
1125 usage.sourceSessionCache = nil
1126 return tabTelemetrySnapshot{Version: 3, ReadFiles: records, Usage: usage}
1127 }
1128
1129 // displayTelemetrySnapshot overlays the live wallet hint onto a copy used by
1130 // UI reads. The persisted snapshot remains the occurrence-time/original view.
1131 func (t *WorkspaceTab) displayTelemetrySnapshot() tabTelemetrySnapshot {
1132 snapshot := t.telemetrySnapshot()
1133 t.telemMu.Lock()
1134 quote := t.runtimeCostQuote
1135 t.telemMu.Unlock()
1136 if quote == nil {
1137 return snapshot
1138 }
1139 snapshot.Usage.SessionCostQuote = quote
1140 snapshot.Usage.SessionCostComplete = quote.Complete
1141 if quote.Selected != nil {
1142 snapshot.Usage.SessionCost = quote.Selected.Float64()
1143 snapshot.Usage.SessionCurrency = quote.LegacyCurrencyCode()
1144 snapshot.Usage.SessionCostUsd = snapshot.Usage.SessionCost
1145 } else {
1146 snapshot.Usage.SessionCostComplete = false
1147 snapshot.Usage.SessionCost = 0
1148 snapshot.Usage.SessionCurrency = ""
1149 snapshot.Usage.SessionCostUsd = 0
1150 }
1151 return snapshot
1152 }
1153
1154 func (t *WorkspaceTab) resetTelemetry(sessionPath string) {
1155 t.telemMu.Lock()
1156 t.readTelemetry = nil
1157 t.usageTelemetry = sessionUsageStats{}
1158 t.runtimeCostDisplayCurrency = ""
1159 t.runtimeCostQuote = nil
1160 t.runtimeCostGeneration++
1161 t.telemetrySessionKey = sessionRuntimeKey(sessionPath)
1162 t.telemMu.Unlock()
1163 }
1164
1165 // syncTelemetryToSession keys the in-memory telemetry to the runtime's current
1166 // session. When the runtime rotated to a different session underneath the tab
1167 // (typed /new routes through Controller.Submit and never reaches App.NewSession),
1168 // the previous session's totals must not bleed into the new one: swap in the
1169 // new session's persisted sidecar, or start from zero when none exists. The
1170 // sidecar is rewritten on every recorded event, so a reload never loses more
1171 // than the sub-second in-memory delta of an in-flight record.
1172 func (t *WorkspaceTab) syncTelemetryToSession(sessionPath string) {
1173 key := sessionRuntimeKey(sessionPath)
1174 if key == "" {
1175 return
1176 }
1177 t.telemMu.Lock()
1178 same := t.telemetrySessionKey == key
1179 t.telemMu.Unlock()
1180 if same {
1181 return
1182 }
1183 // File I/O stays outside telemMu; re-check the key after reacquiring in
1184 // case a concurrent sync or reset re-keyed the tab first.
1185 snapshot := loadTelemetry(sessionPath + ".telemetry.json")
1186 t.telemMu.Lock()
1187 if t.telemetrySessionKey != key {
1188 t.readTelemetry = snapshot.ReadFiles
1189 t.usageTelemetry = snapshot.Usage
1190 t.runtimeCostDisplayCurrency = ""
1191 t.runtimeCostQuote = nil
1192 t.runtimeCostGeneration++
1193 t.telemetrySessionKey = key
1194 }
1195 t.telemMu.Unlock()
1196 }
1197
1198 func (t *WorkspaceTab) resetDisplayTurn() {
1199 state := t.displayBufferState()
1200 state.mu.Lock()
1201 state.planner.ResetToolsIfEmpty()
1202 state.executor.ResetToolsIfEmpty()
1203 state.mu.Unlock()
1204 }
1205
1206 func (t *WorkspaceTab) recordDisplayEvent(e event.Event) {
1207 state := t.displayBufferState()
1208 state.mu.Lock()
1209 defer state.mu.Unlock()
1210 buffer := &state.executor
1211 if strings.TrimSpace(e.Source) == event.UsageSourcePlanner {
1212 buffer = &state.planner
1213 }
1214 recordHistoryDisplayEvent(buffer, e)
1215 }
1216
1217 func (t *WorkspaceTab) displayBufferState() *tabDisplayState {
1218 t.displayStateMu.Lock()
1219 defer t.displayStateMu.Unlock()
1220 if t.displayState == nil {
1221 t.displayState = &tabDisplayState{}
1222 }
1223 return t.displayState
1224 }
1225
1226 func (t *WorkspaceTab) adoptDisplayState(state *tabDisplayState) {
1227 if t == nil || state == nil {
1228 return
1229 }
1230 t.displayStateMu.Lock()
1231 t.displayState = state
1232 t.displayStateMu.Unlock()
1233 }
1234
1235 func recoverPendingTurnProjections(tab *WorkspaceTab, ctrl control.SessionAPI) {
1236 if tab == nil || ctrl == nil {
1237 return
1238 }
1239 projectionCtrl, ok := ctrl.(interface {
1240 PendingTurnProjections() []turnevent.PendingProjection
1241 AcknowledgeTurnProjection(string) error
1242 })
1243 if !ok {
1244 return
1245 }
1246 pending := projectionCtrl.PendingTurnProjections()
1247 if len(pending) == 0 {
1248 return
1249 }
1250 users := make([]string, 0)
1251 for _, message := range ctrl.History() {
1252 if agent.IsUserAuthoredTurnMessage(message) {
1253 if text := strings.TrimSpace(agent.UserMessageText(message)); text != "" {
1254 users = append(users, text)
1255 }
1256 }
1257 }
1258 firstUser := len(users) - len(pending)
1259 for i, projection := range pending {
1260 messages := displayMessagesFromProjection(projection)
1261 if len(messages) == 0 {
1262 if err := projectionCtrl.AcknowledgeTurnProjection(projection.TurnID); err != nil {
1263 slog.Warn("desktop: acknowledge empty recovered projection", "err", err)
1264 }
1265 continue
1266 }
1267 userIndex := firstUser + i
1268 if userIndex < 0 || userIndex >= len(users) {
1269 slog.Warn("desktop: retain unacknowledged projection without matching user turn")
1270 continue
1271 }
1272 turnID := projection.TurnID
1273 persistOrEnqueueDisplayWrite(tab.displayBufferState(), &pendingDisplayWrite{
1274 dir: controllerSessionDir(ctrl), sessionPath: ctrl.SessionPath(), userContent: users[userIndex], messages: messages,
1275 persist: func(dir, sessionPath, userContent string, messages []HistoryMessage) error {
1276 return recordSessionPlannerDisplayForTurn(dir, sessionPath, turnID, userContent, messages)
1277 },
1278 onPersisted: func() {
1279 if err := projectionCtrl.AcknowledgeTurnProjection(turnID); err != nil {
1280 slog.Warn("desktop: acknowledge recovered turn projection", "err", err)
1281 }
1282 },
1283 onRetry: func() {
1284 if observer, ok := ctrl.(interface{ ObserveTurnProjectionRetry() }); ok {
1285 observer.ObserveTurnProjectionRetry()
1286 }
1287 },
1288 })
1289 }
1290 }
1291
1292 func plannerToolResultDisplay(content string, failed bool) (display, errPreview string) {
1293 if strings.TrimSpace(content) == "" {
1294 return "", ""
1295 }
1296 if failed || historyToolResultFailed(content) {
1297 display = clipHistoryToolPreview(strings.TrimSpace(content))
1298 return display, display
1299 }
1300 return "", ""
1301 }
1302
1303 func (t *WorkspaceTab) takeDisplayTurn(cancelled bool) []HistoryMessage {
1304 state := t.displayBufferState()
1305 state.mu.Lock()
1306 defer state.mu.Unlock()
1307 out := state.planner.materialize()
1308 if !cancelled {
1309 out = append(out, state.executor.resultMessages()...)
1310 }
1311 if cancelled {
1312 out = append(out, state.executor.materialize()...)
1313 if len(out) > 0 {
1314 out = append(out, HistoryMessage{
1315 Role: "notice",
1316 Level: "info",
1317 Code: event.NoticeCodeCancelledTurn,
1318 Content: "This turn was interrupted. Partial output is kept for reference; only completed tool pairs and a bounded recovery summary enter the next model turn. Inspect the workspace before continuing or reverting changes.",
1319 })
1320 }
1321 }
1322 state.planner.reset()
1323 state.executor.reset()
1324 return out
1325 }
1326
1327 func enqueuePendingDisplayWrite(state *tabDisplayState, write *pendingDisplayWrite) {
1328 if state == nil || write == nil || write.persist == nil {
1329 return
1330 }
1331 state.mu.Lock()
1332 state.pendingWrites = append(state.pendingWrites, write)
1333 if state.persistRunning {
1334 state.mu.Unlock()
1335 return
1336 }
1337 state.persistRunning = true
1338 state.mu.Unlock()
1339 go retryPendingDisplayWrites(state)
1340 }
1341
1342 func persistOrEnqueueDisplayWrite(state *tabDisplayState, write *pendingDisplayWrite) bool {
1343 if state == nil || write == nil || write.persist == nil {
1344 return true
1345 }
1346 state.mu.Lock()
1347 hasPending := len(state.pendingWrites) > 0
1348 state.mu.Unlock()
1349 if hasPending {
1350 enqueuePendingDisplayWrite(state, write)
1351 return false
1352 }
1353 if err := write.persist(write.dir, write.sessionPath, write.userContent, write.messages); err != nil {
1354 slog.Warn("desktop: persist display-only turn history; queued for retry", "err", err)
1355 if write.onRetry != nil {
1356 write.onRetry()
1357 }
1358 enqueuePendingDisplayWrite(state, write)
1359 return false
1360 }
1361 if write.onPersisted != nil {
1362 write.onPersisted()
1363 }
1364 return true
1365 }
1366
1367 func retryPendingDisplayWrites(state *tabDisplayState) {
1368 failures := 0
1369 for {
1370 state.mu.Lock()
1371 if len(state.pendingWrites) == 0 {
1372 state.persistRunning = false
1373 state.mu.Unlock()
1374 return
1375 }
1376 write := state.pendingWrites[0]
1377 state.mu.Unlock()
1378
1379 if failures > 0 {
1380 time.Sleep(time.Duration(failures*failures) * 50 * time.Millisecond)
1381 }
1382 if err := write.persist(write.dir, write.sessionPath, write.userContent, write.messages); err != nil {
1383 if write.onRetry != nil {
1384 write.onRetry()
1385 }
1386 failures++
1387 if failures < displayPersistRetryLimit {
1388 continue
1389 }
1390 state.mu.Lock()
1391 state.persistRunning = false
1392 state.mu.Unlock()
1393 slog.Warn("desktop: display-only turn history remains pending after retries", "err", err)
1394 return
1395 }
1396
1397 state.mu.Lock()
1398 if len(state.pendingWrites) > 0 && state.pendingWrites[0] == write {
1399 state.pendingWrites[0] = nil
1400 state.pendingWrites = state.pendingWrites[1:]
1401 }
1402 state.mu.Unlock()
1403 if write.onPersisted != nil {
1404 write.onPersisted()
1405 }
1406 failures = 0
1407 }
1408 }
1409
1410 // tabEventSink wraps a parent event.Sink and prepends a tabId to every wire
1411 // event so the frontend can route it to the correct tab's reducer.
1412 //
1413 // tabID and app are rebound while the controller keeps emitting when a running
1414 // session is detached to the background or reattached to another tab, so they
1415 // live under mu like ctx does (a bare field write would data-race Emit). Read
1416 // them via binding(), write via setBinding().
1417 type tabEventSink struct {
1418 tabID string
1419 app *App
1420 mu sync.RWMutex
1421 ctx context.Context
1422 runtimeEpoch string
1423 sessionGeneration uint64 // source session binding generation
1424 runtimeEvents asyncRuntimeEmitter
1425 botSink event.Sink // optional: when set, events are also forwarded here
1426 botSinkGen uint64
1427 turn turnSubmissionState // stays reserved through the end of TurnDone fan-out
1428 // takeoverMirror, when set, forwards every event to the serve that used to
1429 // own this session so the remote tab keeps rendering after a local
1430 // takeover. Atomic so Emit reads it without the sink lock.
1431 takeoverMirror atomic.Pointer[takeoverMirror]
1432 }
1433
1434 // setTakeoverMirror installs (or clears) the session-takeover frame mirror.
1435 func (s *tabEventSink) setTakeoverMirror(m *takeoverMirror) {
1436 if s == nil {
1437 return
1438 }
1439 s.takeoverMirror.Store(m)
1440 }
1441
1442 type closeableEventSink interface {
1443 Close()
1444 }
1445
1446 // binding snapshots the sink's current tab routing under the sink lock.
1447 func (s *tabEventSink) binding() (string, *App) {
1448 if s == nil {
1449 return "", nil
1450 }
1451 s.mu.RLock()
1452 defer s.mu.RUnlock()
1453 return s.tabID, s.app
1454 }
1455
1456 func (s *tabEventSink) runtimeEpochSnapshot() string {
1457 if s == nil {
1458 return ""
1459 }
1460 s.mu.RLock()
1461 defer s.mu.RUnlock()
1462 return s.runtimeEpoch
1463 }
1464
1465 func (s *tabEventSink) Emit(e event.Event) {
1466 // Typed-nil sinks can appear as non-nil event.Sink interfaces when a tab
1467 // controller is built before the tab sink binding is installed.
1468 if s == nil {
1469 return
1470 }
1471 if e.Kind == event.TurnStarted {
1472 s.mu.Lock()
1473 s.turn.inFlight = true
1474 s.mu.Unlock()
1475 }
1476 tabID, app := s.binding()
1477 var turnStartedAt int64
1478 if app != nil {
1479 if e.Kind == event.TurnDone {
1480 // Keep the legacy completion as a cheap missed-event safety net. The
1481 // hub owns the actual resource invalidation and coalesces this probe.
1482 app.reconcileWorkspaceForTab(tabID)
1483 }
1484 switch e.Kind {
1485 case event.TurnStarted:
1486 s.resetDisplayTurn()
1487 turnStartedAt = s.recordTurnStarted()
1488 case event.Usage:
1489 s.recordUsageTelemetry(e)
1490 case event.TurnDone:
1491 s.recordTurnDone()
1492 }
1493 if e.Kind == event.TurnDone {
1494 s.recordDisplay(e)
1495 s.flushDisplay(e.TurnID, e.Cancelled)
1496 }
1497 if m := app.metrics.Load(); m != nil {
1498 m.observe(e)
1499 persistMetricsEvent(app, m, tabID, e)
1500 }
1501 }
1502 s.emitRuntimeEvent(eventChannel, toWireTabWithSubmission(e, tabID, s.runtimeEpochSnapshot(), s.submissionIDSnapshot(), turnStartedAt, s.sessionGenerationSnapshot()))
1503 if m := s.takeoverMirror.Load(); m != nil {
1504 m.forwardEvent(e)
1505 }
1506 if app != nil {
1507 if status, update := topicActivityStatusFromEvent(e); update {
1508 changed := app.setTabActivityStatus(tabID, status)
1509 if changed || isBackgroundJobLifecycleNotice(e) {
1510 // Runtime status is an in-memory projection, not catalog metadata.
1511 // Publish it directly so a turn never fans out into one catalog read
1512 // per expanded project folder.
1513 app.emitProjectTreeRuntimeChangedWithLegacy()
1514 }
1515 }
1516 }
1517 // Record read_file successes in the tab's telemetry.
1518 if e.Kind == event.ToolResult && e.Tool.Name == "read_file" && e.Tool.Err == "" {
1519 s.recordReadTelemetry(e)
1520 }
1521 if app != nil && e.Kind != event.TurnDone {
1522 s.recordDisplay(e)
1523 }
1524 // Persist after each turn so a force-kill loses at most the in-flight prompt.
1525 if e.Kind == event.TurnDone && app != nil {
1526 app.scheduleTabSnapshot(tabID)
1527 }
1528 // Forward event to bot channels when a bot forwarder is attached.
1529 // Read the sink under the read lock so SetBotSink can safely swap it
1530 // from another goroutine.
1531 bs, botSinkGen := s.botSinkSnapshot()
1532 if bs != nil {
1533 bs.Emit(e)
1534 // Detach the forwarder after TurnDone so subsequent turns on the
1535 // same tab do not keep pushing to bot channels.
1536 if e.Kind == event.TurnDone {
1537 s.clearBotSink(botSinkGen)
1538 }
1539 }
1540 // Unlike the transient botSink above, the bridge observes every tab for
1541 // its whole lifetime (god view: /desktop status, watch subscriptions,
1542 // remote approvals). observe only does in-memory bookkeeping and queueing.
1543 if app != nil && app.botBridge != nil {
1544 app.botBridge.observe(tabID, e)
1545 }
1546 if e.Kind == event.TurnDone {
1547 s.mu.Lock()
1548 s.turn = turnSubmissionState{}
1549 s.mu.Unlock()
1550 }
1551 }
1552
1553 // SetBotSink atomically sets or clears the bot event forwarder on this sink.
1554 // It is safe to call concurrently with Emit.
1555 func (s *tabEventSink) SetBotSink(sink event.Sink) uint64 {
1556 s.mu.Lock()
1557 old := s.botSink
1558 s.botSink = sink
1559 s.botSinkGen++
1560 generation := s.botSinkGen
1561 s.mu.Unlock()
1562 if old != nil && old != sink {
1563 if closer, ok := old.(closeableEventSink); ok {
1564 closer.Close()
1565 }
1566 }
1567 return generation
1568 }
1569
1570 func (s *tabEventSink) botSinkSnapshot() (event.Sink, uint64) {
1571 s.mu.RLock()
1572 defer s.mu.RUnlock()
1573 return s.botSink, s.botSinkGen
1574 }
1575
1576 // clearBotSink clears only the forwarder generation observed by the finishing
1577 // turn. A delayed TurnDone must not detach a replacement installed meanwhile.
1578 func (s *tabEventSink) clearBotSink(generation uint64) {
1579 s.mu.Lock()
1580 if s.botSinkGen != generation {
1581 s.mu.Unlock()
1582 return
1583 }
1584 old := s.botSink
1585 s.botSink = nil
1586 s.botSinkGen++
1587 s.mu.Unlock()
1588 if closer, ok := old.(closeableEventSink); ok {
1589 closer.Close()
1590 }
1591 }
1592
1593 // tryBeginTurn reserves the tab until its TurnDone has finished fan-out. The
1594 // controller clears RuntimeStatus().Running before it emits TurnDone, so the
1595 // controller status alone leaves a window where a new turn can inherit the old
1596 // turn's forwarder or have its replacement cleared by the old completion.
1597 func (s *tabEventSink) tryBeginTurn(submissionID ...string) bool {
1598 s.mu.Lock()
1599 defer s.mu.Unlock()
1600 if s.turn.inFlight {
1601 return false
1602 }
1603 s.turn = turnSubmissionState{inFlight: true, submissionID: firstSubmissionID(submissionID)}
1604 return true
1605 }
1606
1607 func (s *tabEventSink) cancelTurnStart() {
1608 s.mu.Lock()
1609 s.turn = turnSubmissionState{}
1610 s.mu.Unlock()
1611 }
1612
1613 func (s *tabEventSink) setContext(ctx context.Context) {
1614 s.mu.Lock()
1615 s.ctx = ctx
1616 s.mu.Unlock()
1617 }
1618
1619 func (s *tabEventSink) context() context.Context {
1620 s.mu.RLock()
1621 defer s.mu.RUnlock()
1622 return s.ctx
1623 }
1624
1625 func (s *tabEventSink) emitRuntimeEvent(name string, payload ...any) {
1626 if s == nil {
1627 return
1628 }
1629 ctx := s.context()
1630 if ctx == nil {
1631 return
1632 }
1633 s.runtimeEvents.Emit(ctx, name, payload...)
1634 }
1635
1636 type runtimeEventEmitFunc func(context.Context, string, ...any)
1637
1638 type runtimeEventEnvelope struct {
1639 ctx context.Context
1640 name string
1641 payload []any
1642 }
1643
1644 // asyncRuntimeEmitter decouples the host event bridge from agent emission.
1645 // Emit can block when the event channel backs up; callers enqueue in-order
1646 // work and return without holding the agent event lock.
1647 // runtimeEventsEmitFallback is the emit used when no per-instance override is
1648 // installed. Production replaces it with the host RPC server emit in
1649 // runHostRPC; the test binary swaps in a no-op via TestMain.
1650 var runtimeEventsEmitFallback runtimeEventEmitFunc = func(_ context.Context, name string, _ ...any) {
1651 slog.Debug("desktop: runtime event dropped without a host shell", "name", name)
1652 }
1653
1654 type asyncRuntimeEmitter struct {
1655 mu sync.Mutex
1656 emit runtimeEventEmitFunc
1657 queue []runtimeEventEnvelope
1658 head int
1659 running bool
1660 configWarningsRevision atomic.Uint64
1661 }
1662
1663 func (e *asyncRuntimeEmitter) Emit(ctx context.Context, name string, payload ...any) {
1664 if ctx == nil {
1665 return
1666 }
1667 item := runtimeEventEnvelope{
1668 ctx: ctx,
1669 name: name,
1670 payload: append([]any(nil), payload...),
1671 }
1672 e.mu.Lock()
1673 e.queue = append(e.queue, item)
1674 if !e.running {
1675 e.running = true
1676 go e.run()
1677 }
1678 e.mu.Unlock()
1679 }
1680
1681 func (e *asyncRuntimeEmitter) Clear() {
1682 e.mu.Lock()
1683 clear(e.queue)
1684 e.queue = nil
1685 e.head = 0
1686 e.mu.Unlock()
1687 }
1688
1689 func (e *asyncRuntimeEmitter) run() {
1690 for {
1691 e.mu.Lock()
1692 if e.head >= len(e.queue) {
1693 clear(e.queue)
1694 e.queue = nil
1695 e.head = 0
1696 e.running = false
1697 e.mu.Unlock()
1698 return
1699 }
1700 item := e.queue[e.head]
1701 var zero runtimeEventEnvelope
1702 e.queue[e.head] = zero
1703 e.head++
1704 if e.head > 64 && e.head*2 >= len(e.queue) {
1705 e.queue = append([]runtimeEventEnvelope(nil), e.queue[e.head:]...)
1706 e.head = 0
1707 }
1708 emit := e.emit
1709 if emit == nil {
1710 emit = runtimeEventsEmitFallback
1711 }
1712 e.mu.Unlock()
1713
1714 emit(item.ctx, item.name, item.payload...)
1715 }
1716 }
1717
1718 func topicActivityStatusFromEvent(e event.Event) (string, bool) {
1719 switch e.Kind {
1720 case event.TurnStarted, event.Reasoning, event.ToolDispatch, event.ToolProgress, event.ToolResultPreview, event.ToolResult, event.CompactionStarted, event.CompactionDone, event.Retrying:
1721 return topicStatusThinking, true
1722 case event.Text, event.Message:
1723 return topicStatusStreaming, true
1724 case event.ApprovalRequest, event.AskRequest:
1725 return topicStatusWaitingConfirmation, true
1726 case event.TurnDone:
1727 if status, ok := topicStatusFromTurnDone(e.Outcome); ok {
1728 return status, true
1729 }
1730 if e.Err != nil {
1731 return topicStatusError, true
1732 }
1733 return "", true
1734 case event.Notice:
1735 if isBackgroundJobLifecycleNotice(e) {
1736 return "", true
1737 }
1738 return "", false
1739 default:
1740 return "", false
1741 }
1742 }
1743
1744 func isBackgroundJobLifecycleNotice(e event.Event) bool {
1745 if e.Kind != event.Notice {
1746 return false
1747 }
1748 text := strings.TrimSpace(e.Text)
1749 return strings.HasPrefix(text, "background ") &&
1750 (strings.Contains(text, " started: ") ||
1751 strings.Contains(text, " finished: ") ||
1752 strings.Contains(text, " failed: ") ||
1753 strings.Contains(text, " killed: "))
1754 }
1755
1756 // notifyTabRuntimeRebuilt tells the frontend a tab's controller was replaced
1757 // in place (model/effort/token-mode switch, clear-while-running). A rebuilt
1758 // controller restarts its approval/ask id counter at "1", so tab-scoped
1759 // frontend state keyed by prompt id (the attention-chime dedupe) must reset —
1760 // unlike agent:ready, this event carries no reload semantics, so emitting it
1761 // on every swap adds no hydration churn.
1762 //
1763 // Ordering matters: the reset must reach the frontend BEFORE the rebuilt
1764 // controller's first approval/ask event, or the stale key still mutes it. The
1765 // tab's agent events ride the tab sink's own async queue, so the notice goes
1766 // through THAT queue — same lane, FIFO, guaranteed to arrive first. The
1767 // App-level queue is only the fallback when the sink cannot deliver (no sink,
1768 // or its webview context is cleared); it cannot order against sink traffic,
1769 // but an unordered notice still beats none.
1770 func (a *App) notifyTabRuntimeRebuilt(tab *WorkspaceTab) {
1771 if tab == nil {
1772 return
1773 }
1774 a.mu.Lock()
1775 epoch := a.advanceSessionRuntimeEpochLocked(tab)
1776 a.mu.Unlock()
1777 a.notifyTabRuntimeRebuiltAtEpoch(tab, epoch)
1778 }
1779
1780 // notifyTabRuntimeRebuiltAtEpoch emits the rebuild fence for a transaction
1781 // that advanced its epoch inside the controller/path/lease commit. Keeping the
1782 // chosen epoch avoids a second generation bump after publication.
1783 func (a *App) notifyTabRuntimeRebuiltAtEpoch(tab *WorkspaceTab, epoch string) {
1784 if tab == nil {
1785 return
1786 }
1787 a.mu.RLock()
1788 sink := tab.sink
1789 tabID := tab.ID
1790 ctrl, _ := tab.Ctrl.(*control.Controller)
1791 a.mu.RUnlock()
1792 if ctrl != nil {
1793 go ctrl.NotifyInboxRuntimeReady()
1794 }
1795 if sink != nil && sink.context() != nil {
1796 sink.emitRuntimeEvent("runtime:rebuilt", tabID, epoch)
1797 return
1798 }
1799 a.emitRuntimeEvent("runtime:rebuilt", tabID, epoch)
1800 }
1801
1802 // replayPendingPromptsAfterRuntimeAttach publishes the runtime generation on
1803 // the tab sink before asking the same controller to replay. Both events use the
1804 // sink's FIFO queue, so the frontend cannot reject a valid prompt as belonging
1805 // to the runtime that was just replaced.
1806 func (a *App) replayPendingPromptsAfterRuntimeAttach(tabID string, sink *tabEventSink, ctrl control.SessionAPI, epoch string) {
1807 if ctrl == nil {
1808 return
1809 }
1810 if sink != nil && sink.context() != nil {
1811 // Use the sink captured in the same App.mu commit as ctrl. Re-reading the
1812 // tab here would let a concurrent replacement put the fence on a newer
1813 // sink while this older controller replays on the transferred one.
1814 sink.emitRuntimeEvent("runtime:rebuilt", tabID, epoch)
1815 } else {
1816 a.emitRuntimeEvent("runtime:rebuilt", tabID, epoch)
1817 }
1818 ctrl.ReplayPendingPrompts()
1819 }
1820
1821 func (a *App) emitReady(ctx context.Context, tabID ...string) {
1822 a.mu.RLock()
1823 hook := a.readyHook
1824 a.mu.RUnlock()
1825 if hook != nil {
1826 hook()
1827 return
1828 }
1829 if ctx != nil {
1830 if len(tabID) > 0 && strings.TrimSpace(tabID[0]) != "" {
1831 a.runtimeEvents.Emit(ctx, "agent:ready", strings.TrimSpace(tabID[0]))
1832 return
1833 }
1834 a.runtimeEvents.Emit(ctx, "agent:ready")
1835 }
1836 }
1837
1838 func (s *tabEventSink) recordReadTelemetry(e event.Event) {
1839 tabID, app := s.binding()
1840 if app == nil {
1841 return
1842 }
1843 app.mu.RLock()
1844 tab := app.tabByEventSinkIDLocked(tabID)
1845 var ctrl control.SessionAPI
1846 if tab != nil {
1847 ctrl = tab.Ctrl
1848 }
1849 app.mu.RUnlock()
1850 if tab == nil {
1851 return
1852 }
1853 turn := 0
1854 if ctrl != nil {
1855 turn = ctrl.Turn()
1856 }
1857
1858 // Parse read_file args: {"path": "...", "offset": N, "limit": N}
1859 var args struct {
1860 Path string `json:"path"`
1861 Offset int `json:"offset"`
1862 Limit int `json:"limit"`
1863 }
1864 path := e.Tool.Args
1865 offset := 0
1866 limit := 0
1867 if err := json.Unmarshal([]byte(e.Tool.Args), &args); err == nil && args.Path != "" {
1868 path = args.Path
1869 offset = args.Offset
1870 limit = args.Limit
1871 }
1872
1873 truncated := e.Tool.Truncated || strings.Contains(e.Tool.Output, "truncated") ||
1874 strings.Contains(e.Tool.Output, "File truncated")
1875
1876 sp := ""
1877 if ctrl != nil {
1878 sp = ctrl.SessionPath()
1879 }
1880 if sp != "" {
1881 tab.syncTelemetryToSession(sp)
1882 }
1883 tab.recordReadFile(readFileRecord{
1884 Path: path,
1885 Turn: turn,
1886 Time: time.Now().UnixMilli(),
1887 Offset: offset,
1888 Limit: limit,
1889 Truncated: truncated,
1890 })
1891 if sp != "" {
1892 _ = saveTelemetry(sp+".telemetry.json", tab.telemetrySnapshot())
1893 }
1894 }
1895
1896 func (s *tabEventSink) recordTurnStarted() int64 {
1897 tab, sp := s.telemetryTab()
1898 if tab == nil {
1899 return 0
1900 }
1901 if sp != "" {
1902 tab.syncTelemetryToSession(sp)
1903 }
1904 startedAt := tab.recordTurnStarted(time.Now().UnixMilli())
1905 if sp != "" {
1906 _ = saveTelemetry(sp+".telemetry.json", tab.telemetrySnapshot())
1907 }
1908 return startedAt
1909 }
1910
1911 func (s *tabEventSink) recordTurnDone() {
1912 tab, sp := s.telemetryTab()
1913 if tab == nil {
1914 return
1915 }
1916 if sp != "" {
1917 tab.syncTelemetryToSession(sp)
1918 }
1919 tab.recordTurnDone(time.Now().UnixMilli())
1920 if sp != "" {
1921 _ = saveTelemetry(sp+".telemetry.json", tab.telemetrySnapshot())
1922 }
1923 }
1924
1925 func (s *tabEventSink) recordUsageTelemetry(e event.Event) {
1926 tab, sp := s.telemetryTab()
1927 if tab == nil {
1928 return
1929 }
1930 if sp != "" {
1931 tab.syncTelemetryToSession(sp)
1932 }
1933 tab.recordUsage(e)
1934 if sp != "" {
1935 _ = saveTelemetry(sp+".telemetry.json", tab.telemetrySnapshot())
1936 }
1937 }
1938
1939 func (s *tabEventSink) resetDisplayTurn() {
1940 tab, _ := s.eventTabAndController()
1941 if tab != nil {
1942 tab.resetDisplayTurn()
1943 }
1944 }
1945
1946 func (s *tabEventSink) recordDisplay(e event.Event) {
1947 tab, _ := s.eventTabAndController()
1948 if tab != nil {
1949 tab.recordDisplayEvent(e)
1950 }
1951 }
1952
1953 func (s *tabEventSink) flushDisplay(turnID string, cancelRequested bool) bool {
1954 tab, ctrl := s.eventTabAndController()
1955 if tab == nil || ctrl == nil {
1956 return false
1957 }
1958 history := ctrl.History()
1959 keepExecutorDisplay := cancelRequested && (lastHistoryMessageIsUser(history) || hasPendingInterruptedRecovery(history))
1960 messages := tab.takeDisplayTurn(keepExecutorDisplay)
1961 if len(messages) == 0 {
1962 acknowledgeProjectionForController(ctrl, turnID)
1963 return true
1964 }
1965 sessionPath := ctrl.SessionPath()
1966 if sessionPath == "" {
1967 return false
1968 }
1969 userContent := lastUserMessageContent(history)
1970 if strings.TrimSpace(userContent) == "" {
1971 return false
1972 }
1973 return persistOrEnqueueDisplayWrite(tab.displayBufferState(), &pendingDisplayWrite{
1974 dir: controllerSessionDir(ctrl),
1975 sessionPath: sessionPath,
1976 userContent: userContent,
1977 messages: messages,
1978 persist: func(dir, sessionPath, userContent string, messages []HistoryMessage) error {
1979 return recordSessionPlannerDisplayForTurn(dir, sessionPath, turnID, userContent, messages)
1980 },
1981 onPersisted: func() { acknowledgeProjectionForController(ctrl, turnID) },
1982 onRetry: func() { observeProjectionRetryForController(ctrl) },
1983 })
1984 }
1985
1986 func observeProjectionRetryForController(ctrl control.SessionAPI) {
1987 if ctrl == nil {
1988 return
1989 }
1990 if observer, ok := ctrl.(interface{ ObserveTurnProjectionRetry() }); ok {
1991 observer.ObserveTurnProjectionRetry()
1992 }
1993 }
1994
1995 func acknowledgeProjectionForController(ctrl control.SessionAPI, turnID string) {
1996 if ctrl == nil || strings.TrimSpace(turnID) == "" {
1997 return
1998 }
1999 if ack, ok := ctrl.(interface{ AcknowledgeTurnProjection(string) error }); ok {
2000 if err := ack.AcknowledgeTurnProjection(turnID); err != nil {
2001 slog.Warn("desktop: acknowledge turn display projection", "err", err)
2002 }
2003 }
2004 }
2005
2006 func lastHistoryMessageIsUser(history []provider.Message) bool {
2007 return len(history) > 0 && agent.IsUserAuthoredTurnMessage(history[len(history)-1])
2008 }
2009
2010 func hasPendingInterruptedRecovery(history []provider.Message) bool {
2011 for _, v := range slices.Backward(history) {
2012 m := v
2013 if m.LocalOnly && m.InterruptedTurn != nil {
2014 return m.InterruptedTurn.Pending
2015 }
2016 if agent.IsUserAuthoredTurnMessage(m) {
2017 return false
2018 }
2019 }
2020 return false
2021 }
2022
2023 func (s *tabEventSink) eventTabAndController() (*WorkspaceTab, control.SessionAPI) {
2024 tabID, app := s.binding()
2025 if app == nil {
2026 return nil, nil
2027 }
2028 app.mu.RLock()
2029 defer app.mu.RUnlock()
2030 tab := app.tabByEventSinkIDLocked(tabID)
2031 if tab == nil {
2032 return nil, nil
2033 }
2034 return tab, tab.Ctrl
2035 }
2036
2037 func lastUserMessageContent(msgs []provider.Message) string {
2038 for _, v := range slices.Backward(msgs) {
2039 if agent.IsUserAuthoredTurnMessage(v) {
2040 return agent.UserMessageText(v)
2041 }
2042 }
2043 return ""
2044 }
2045
2046 func (s *tabEventSink) telemetryTab() (*WorkspaceTab, string) {
2047 tabID, app := s.binding()
2048 if app == nil {
2049 return nil, ""
2050 }
2051 app.mu.RLock()
2052 tab := app.tabByEventSinkIDLocked(tabID)
2053 var ctrl control.SessionAPI
2054 if tab != nil {
2055 ctrl = tab.Ctrl
2056 }
2057 app.mu.RUnlock()
2058 if tab == nil {
2059 return nil, ""
2060 }
2061 if ctrl == nil {
2062 return tab, ""
2063 }
2064 sp := ctrl.SessionPath()
2065 if sp == "" {
2066 return tab, ""
2067 }
2068 return tab, sp
2069 }
2070
2071 // wire event with tab
2072
2073 func toWireTab(e event.Event, tabID string, runtimeEpoch ...string) wireEventTab {
2074 w := eventwire.ToWire(e)
2075 epoch := ""
2076 if len(runtimeEpoch) > 0 {
2077 epoch = runtimeEpoch[0]
2078 }
2079 return wireEventTab{
2080 Event: w,
2081 TabID: tabID,
2082 RuntimeEpoch: epoch,
2083 SessionHitTokens: e.SessionHit,
2084 SessionMissTokens: e.SessionMiss,
2085 SessionCost: 0, // filled by frontend accumulator per tab
2086 SessionCurrency: "",
2087 SessionCostUsd: 0, // deprecated compatibility alias
2088 }
2089 }
2090
2091 // wireEventTab extends the shared event wire with tab routing info. The frontend reducer
2092 // uses tabId to dispatch to the correct per-tab state.
2093 type wireEventTab struct {
2094 eventwire.Event
2095 TabID string `json:"tabId"`
2096 RuntimeEpoch string `json:"runtimeEpoch,omitempty"`
2097 SessionGeneration uint64 `json:"sessionGeneration,omitempty"`
2098 TurnStartedAt int64 `json:"turnStartedAt,omitempty"`
2099 // Session-cumulative tokens per tab.
2100 SessionHitTokens int `json:"sessionHitTokens,omitempty"`
2101 SessionMissTokens int `json:"sessionMissTokens,omitempty"`
2102 // SessionCost is filled by the frontend's per-tab accumulator.
2103 SessionCost float64 `json:"sessionCost,omitempty"`
2104 SessionCurrency string `json:"sessionCurrency,omitempty"`
2105 // SessionCostUsd is a deprecated compatibility alias. It mirrors
2106 // SessionCost and does not imply USD.
2107 SessionCostUsd float64 `json:"sessionCostUsd,omitempty"`
2108 }
2109
2110 // Tab management on App
2111
2112 func enrichTabMeta(meta TabMeta) TabMeta {
2113 if meta.Active {
2114 meta.GitBranch = workspaceGitBranchForMeta(meta.WorkspaceRoot)
2115 }
2116 return meta
2117 }
2118
2119 func enrichTabMetas(metas []TabMeta) []TabMeta {
2120 for i := range metas {
2121 if metas[i].Active {
2122 metas[i].GitBranch = workspaceGitBranchForMeta(metas[i].WorkspaceRoot)
2123 }
2124 }
2125 return metas
2126 }
2127
2128 func (a *App) tabMeta(tab *WorkspaceTab, active bool) TabMeta {
2129 runtimeView := a.sessionRuntimeViewLocked(tab)
2130 sessionPath := tab.currentSessionPath()
2131 sessionRevision, sessionDigest := a.tabHistoryFingerprint(tab, sessionPath)
2132 floor := derivedQualityFloor(tab)
2133 m := TabMeta{
2134 ID: tab.ID,
2135 Scope: tab.Scope,
2136 WorkspaceRoot: tab.WorkspaceRoot,
2137 WorkspaceID: tab.SessionWorkspace.ID,
2138 WorkspaceName: workspaceName(tab.WorkspaceRoot),
2139 WorkspacePath: tab.WorkspaceRoot,
2140 TopicID: tab.TopicID,
2141 TopicTitle: a.localizedTopicTitle(tab.TopicTitle, tab.topicTitleSource),
2142 SessionPath: sessionPath,
2143 SessionID: tab.SessionID,
2144 SessionRevision: sessionRevision,
2145 SessionDigest: sessionDigest,
2146 SessionGeneration: tab.SessionGeneration,
2147 ReadOnly: tab.ReadOnly,
2148 TakenOver: tab.Takeover.Spectator,
2149 Label: tab.Label,
2150 Ready: runtimeView.Phase == sessionRuntimeReady && tab.Ctrl != nil,
2151 Runtime: runtimeView,
2152 TurnStartedAt: tab.turnStartedAt(),
2153 Mode: currentTabMode(tab),
2154 CollaborationMode: currentTabCollaborationMode(tab),
2155 ToolApprovalMode: currentTabToolApprovalMode(tab),
2156 QualityFloor: floor.floor,
2157 FloorInferred: floor.inferred,
2158 AgentPreset: agentPresetForFloor(floor.floor),
2159 TokenMode: tokenModeForFloor(floor.floor),
2160 Goal: currentTabGoal(tab),
2161 GoalStatus: currentTabGoalStatus(tab),
2162 StartupErr: tab.StartupErr,
2163 HistoricalSource: tab.HistoricalSource,
2164 Active: active,
2165 Cwd: tab.WorkspaceRoot,
2166 IsolatedWorktree: floor.isolated,
2167 }
2168 if strings.TrimSpace(tab.SessionID) != "" {
2169 m.Session = &session.SessionRef{HostID: "local", SessionID: strings.TrimSpace(tab.SessionID)}
2170 }
2171 switch tab.Scope {
2172 case "global":
2173 m.ProjectColor = globalProjectColor()
2174 m.WorkspaceName = globalProjectTitle()
2175 case "project":
2176 m.ProjectColor = projectColor(tab.WorkspaceRoot)
2177 }
2178 if tab.Ctrl != nil {
2179 m.setAuthenticationMeta(tab)
2180 status := tab.Ctrl.RuntimeStatus()
2181 if reader, ok := tab.Ctrl.(control.RuntimeStateReader); ok {
2182 m.GoalView = reader.RuntimeStateSnapshot().Goal
2183 }
2184 m.Running = status.Running || status.PendingPrompt || status.BackgroundJobs > 0
2185 m.PendingPrompt = status.PendingPrompt
2186 m.BackgroundJobs = status.BackgroundJobs
2187 m.CancelRequested = status.CancelRequested
2188 m.Cancellable = status.Cancellable
2189 m.TurnID = status.TurnID
2190 m.TurnStatus = string(status.Status)
2191 m.TurnEventSeq = status.TurnEventSeq
2192 m.TurnReplayAfter = status.ReplayAfterSeq
2193 }
2194 if a.botBridge != nil {
2195 m.RemoteControlled = a.botBridge.remoteControlledTabs()[tab.ID]
2196 }
2197 legacyMetaPath, legacyMetaOK := validatedLegacySessionPathForRead(tab.currentSessionPath())
2198 if legacyMetaOK {
2199 if meta, ok, err := agent.LoadBranchMeta(string(legacyMetaPath)); err == nil && ok {
2200 m.VersionKind = string(meta.EffectiveVersionKind())
2201 m.VersionState = string(meta.EffectiveVersionState())
2202 m.ParentVersionID = meta.ParentVersionID
2203 if meta.Recovered {
2204 m.Recovered = true
2205 m.RecoveryReason = meta.RecoveryReason
2206 m.RecoveryDigest = meta.RecoveryDigest
2207 m.RecoveryParentID = string(meta.ParentID)
2208 }
2209 }
2210 }
2211 return m
2212 }
2213
2214 // ListTabs returns every open view container's metadata for the frontend chrome and sidebar.
2215 func (a *App) ListTabs() []TabMeta {
2216 a.mu.RLock()
2217 out := make([]TabMeta, 0, len(a.tabs))
2218 ordered, needsRepair := a.orderedTabIDsSnapshotLocked()
2219 for _, id := range ordered {
2220 if tab := a.tabs[id]; tab != nil {
2221 out = append(out, a.tabMeta(tab, tab.ID == a.activeTabID))
2222 }
2223 }
2224 a.mu.RUnlock()
2225 if !needsRepair {
2226 return a.listTabsWithRemote(out)
2227 }
2228
2229 a.mu.Lock()
2230 out = make([]TabMeta, 0, len(a.tabs))
2231 for _, id := range a.orderedTabIDsLocked() {
2232 if tab := a.tabs[id]; tab != nil {
2233 out = append(out, a.tabMeta(tab, tab.ID == a.activeTabID))
2234 }
2235 }
2236 a.mu.Unlock()
2237 return a.listTabsWithRemote(out)
2238 }
2239
2240 // syncTabWorkspaceRootSpellings repoints visible and detached project runtimes
2241 // at the registry spelling. Registry writes may adopt the caller's spelling,
2242 // while the frontend compares roots exactly. Callers must not hold a.mu.
2243 func (a *App) syncTabWorkspaceRootSpellings() {
2244 projects := loadProjectsFile().Projects
2245 a.mu.Lock()
2246 changed := false
2247 for _, tab := range a.tabs {
2248 changed = syncRuntimeWorkspaceRootSpelling(tab, projects) || changed
2249 }
2250 for _, tab := range a.detachedSessions {
2251 changed = syncRuntimeWorkspaceRootSpelling(tab, projects) || changed
2252 }
2253 if changed {
2254 a.saveTabsLocked()
2255 }
2256 a.mu.Unlock()
2257 if changed {
2258 a.emitProjectTreeMetadataChanged()
2259 }
2260 }
2261
2262 // OpenProjectTab builds a controller scoped to workspaceRoot and opens the
2263 // session selected by the given topic. Topic selection resolves to a concrete
2264 // session path first; the visible tab is then attached to that session runtime.
2265 func (a *App) OpenProjectTab(workspaceRoot, topicID string) (TabMeta, error) {
2266 return a.openProjectTab(workspaceRoot, topicID)
2267 }
2268
2269 func (a *App) openProjectTab(workspaceRoot, topicID string) (TabMeta, error) {
2270 if workspaceRoot == "" {
2271 return TabMeta{}, fmt.Errorf("workspaceRoot is required")
2272 }
2273 if abs, err := filepath.Abs(workspaceRoot); err == nil {
2274 workspaceRoot = abs
2275 }
2276
2277 sessionPath, err := a.resolveTopicOpenPath("project", workspaceRoot, topicID)
2278 if err != nil {
2279 return TabMeta{}, err
2280 }
2281 return a.openTopicTabWithActivation("project", workspaceRoot, topicID, sessionPath, true)
2282 }
2283
2284 func (a *App) openTopicTab(scope, workspaceRoot, topicID, sessionPath string) (TabMeta, error) {
2285 return a.openTopicTabPreferLiveActivation(scope, workspaceRoot, topicID, sessionPath, true)
2286 }
2287
2288 func (a *App) openProjectTabInactive(workspaceRoot, topicID string) (TabMeta, error) {
2289 if workspaceRoot == "" {
2290 return TabMeta{}, fmt.Errorf("workspaceRoot is required")
2291 }
2292 if abs, err := filepath.Abs(workspaceRoot); err == nil {
2293 workspaceRoot = abs
2294 }
2295
2296 sessionPath, err := a.resolveTopicOpenPath("project", workspaceRoot, topicID)
2297 if err != nil {
2298 return TabMeta{}, err
2299 }
2300 return a.openTopicTabWithActivation("project", workspaceRoot, topicID, sessionPath, false)
2301 }
2302
2303 func (a *App) openGlobalTabInactive(topicID string) (TabMeta, error) {
2304 globalRoot := globalWorkspaceRoot()
2305 if err := os.MkdirAll(globalRoot, 0o755); err != nil {
2306 return TabMeta{}, fmt.Errorf("create global workspace: %w", err)
2307 }
2308
2309 sessionPath, err := a.resolveTopicOpenPath("global", "", topicID)
2310 if err != nil {
2311 return TabMeta{}, err
2312 }
2313 return a.openTopicTabWithActivation("global", "", topicID, sessionPath, false)
2314 }
2315
2316 func (a *App) openTopicTabWithActivation(scope, workspaceRoot, topicID, sessionPath string, activate bool) (TabMeta, error) {
2317 actualRoot, sessionPath := a.resolveOpenTopicSessionPath(scope, workspaceRoot, sessionPath)
2318 releaseAdmission, err := a.beginProjectRuntimeAdmission(scope, actualRoot)
2319 if err != nil {
2320 return TabMeta{}, err
2321 }
2322 defer releaseAdmission()
2323 if strings.TrimSpace(scope) == "project" {
2324 saveWorkspace(actualRoot)
2325 a.registerProjectRoot(actualRoot)
2326 }
2327 targetKey := sessionRuntimeKey(sessionPath)
2328
2329 a.mu.Lock()
2330 if targetKey != "" {
2331 for _, tab := range a.tabs {
2332 if tab == nil {
2333 continue
2334 }
2335 if sessionRuntimeKeysOverlap(tab, sessionPath) {
2336 if activate {
2337 a.activeTabID = tab.ID
2338 }
2339 meta := a.tabMeta(tab, tab.ID == a.activeTabID)
2340 a.saveTabsLocked()
2341 a.mu.Unlock()
2342 return enrichTabMeta(meta), nil
2343 }
2344 }
2345 }
2346
2347 for _, tab := range a.tabs {
2348 if targetKey == "" && tabMatchesTopicTarget(tab, scope, workspaceRoot, topicID) {
2349 if activate {
2350 a.activeTabID = tab.ID
2351 }
2352 sameSession := targetKey == "" || sessionRuntimeKeysOverlap(tab, sessionPath)
2353 meta := a.tabMeta(tab, tab.ID == a.activeTabID)
2354 a.saveTabsLocked()
2355 a.mu.Unlock()
2356 if sameSession || a.skipContinuationRebind(tab, sessionPath) {
2357 return enrichTabMeta(meta), nil
2358 }
2359 if err := a.rebindTabToSessionPath(tab, sessionPath); err != nil {
2360 return TabMeta{}, err
2361 }
2362 a.mu.RLock()
2363 meta = a.tabMeta(tab, tab.ID == a.activeTabID)
2364 a.mu.RUnlock()
2365 return enrichTabMeta(meta), nil
2366 }
2367 }
2368 source := a.liveRuntimeTabMatchingLocked(nil, sessionPath)
2369 if source == nil && targetKey == "" {
2370 source = a.liveRuntimeTabMatchingTopicLocked(nil, scope, workspaceRoot, topicID)
2371 }
2372 if source != nil && a.tabs[source.ID] == source {
2373 source = nil
2374 }
2375
2376 tabID := a.newUniqueTabIDLocked()
2377 topicTitle := topicTitleForTab(scope, workspaceRoot, topicID)
2378 if t, source, ok := topicTitleFallbackForOpen(workspaceRoot, topicID, sessionPath); ok {
2379 topicTitle = t
2380 _ = setTopicTitleWithSource(workspaceRoot, topicID, t, source)
2381 }
2382
2383 // A new topic receives its immutable v3 identity when the controller build
2384 // publishes. Do not pre-create an empty legacy transcript as an identity
2385 // placeholder.
2386 profile := defaultTabSessionProfile()
2387 if sessionPath != "" {
2388 profile = loadTabSessionProfile(sessionPath)
2389 }
2390 tab := &WorkspaceTab{
2391 ID: tabID,
2392 Scope: scope,
2393 WorkspaceRoot: actualRoot,
2394 TopicID: topicID,
2395 TopicTitle: topicTitle,
2396 topicTitleSource: loadTopicTitleSource(topicTitleRoot(scope, workspaceRoot), topicID),
2397 SessionPath: sessionPath,
2398 disabledMCP: map[string]ServerView{},
2399 }
2400 applyTabSessionProfile(tab, profile)
2401 tab.sink = &tabEventSink{tabID: tabID, app: a}
2402
2403 a.tabs[tabID] = tab
2404 a.tabOrder = append(a.tabOrder, tabID)
2405 if activate {
2406 a.activeTabID = tabID
2407 }
2408 a.saveTabsLocked()
2409 meta := a.tabMeta(tab, tab.ID == a.activeTabID)
2410 a.mu.Unlock()
2411
2412 if source != nil {
2413 if a.attachExistingSessionRuntime(tab, runtimeAttachIdentity(source, sessionPath), a.ctx) {
2414 a.mu.RLock()
2415 meta = a.tabMeta(tab, tab.ID == a.activeTabID)
2416 a.mu.RUnlock()
2417 if scope == "project" {
2418 a.emitProjectTreeRuntimeChangedWithLegacy()
2419 }
2420 return enrichTabMeta(meta), nil
2421 }
2422 }
2423 a.startTabControllerBuild(tab)
2424 if scope == "project" {
2425 a.emitProjectTreeRuntimeChangedWithLegacy()
2426 }
2427 return enrichTabMeta(meta), nil
2428 }
2429
2430 // OpenGlobalTab opens a new global-scope tab (no project root). The global
2431 // workspace root is the reasonix user config directory.
2432 func (a *App) OpenGlobalTab(topicID string) (TabMeta, error) {
2433 return a.openGlobalTab(topicID)
2434 }
2435
2436 func (a *App) openGlobalTab(topicID string) (TabMeta, error) {
2437 globalRoot := globalWorkspaceRoot()
2438 if err := os.MkdirAll(globalRoot, 0o755); err != nil {
2439 return TabMeta{}, fmt.Errorf("create global workspace: %w", err)
2440 }
2441
2442 sessionPath, err := a.resolveTopicOpenPath("global", "", topicID)
2443 if err != nil {
2444 return TabMeta{}, err
2445 }
2446 return a.openTopicTabWithActivation("global", "", topicID, sessionPath, true)
2447 }
2448
2449 // OpenTopicSession opens a concrete saved session from the sidebar. Unlike
2450 // OpenProjectTab/OpenGlobalTab, it does not resolve the topic to the latest
2451 // session first; sessionPath is the runtime identity being selected.
2452 func (a *App) OpenTopicSession(scope, workspaceRoot, topicID, sessionPath string) (TabMeta, error) {
2453 return a.openTopicSession(scope, workspaceRoot, topicID, sessionPath)
2454 }
2455
2456 func (a *App) openTopicSession(scope, workspaceRoot, topicID, sessionPath string) (TabMeta, error) {
2457 return a.openTopicSessionWithNavigation(scope, workspaceRoot, topicID, sessionPath, a.desktopSessions.navigationSeq.Add(1))
2458 }
2459
2460 func (a *App) openTopicSessionWithNavigation(scope, workspaceRoot, topicID, sessionPath string, navigation uint64) (TabMeta, error) {
2461 if a.desktopSessions.navigationSeq.Load() != navigation {
2462 return TabMeta{}, errSessionNavigationSuperseded
2463 }
2464 if source, err := parseSessionSourceRoute(sessionPath); err != nil {
2465 return TabMeta{}, err
2466 } else if source != nil {
2467 target, err := a.resolveSessionMutationTarget(SessionSelector{Source: source})
2468 if err != nil {
2469 return TabMeta{}, err
2470 }
2471 sessionPath = sessionRoute(target.SessionRef.SessionID)
2472 }
2473 if id, ok := parseSessionRoute(sessionPath); ok {
2474 if _, err := a.openSessionWithNavigation(session.SessionRef{HostID: localDesktopHostID, SessionID: id}, navigation); err != nil {
2475 return TabMeta{}, err
2476 }
2477 a.mu.RLock()
2478 tab := a.tabs[a.activeTabID]
2479 if tab == nil {
2480 a.mu.RUnlock()
2481 return TabMeta{}, errSessionNavigationSuperseded
2482 }
2483 meta := a.tabMeta(tab, true)
2484 a.mu.RUnlock()
2485 return enrichTabMeta(meta), nil
2486 }
2487 scope = strings.TrimSpace(scope)
2488 if scope != "project" {
2489 scope = "global"
2490 workspaceRoot = ""
2491 }
2492 if scope == "project" {
2493 workspaceRoot = normalizeProjectRoot(workspaceRoot)
2494 if workspaceRoot == "" {
2495 return TabMeta{}, fmt.Errorf("workspaceRoot is required")
2496 }
2497 }
2498 _, validPath, err := a.sessionDirForPath(sessionPath)
2499 if err != nil {
2500 return TabMeta{}, err
2501 }
2502 return a.openTopicTab(scope, workspaceRoot, topicID, validPath)
2503 }
2504
2505 // ActivateTopic opens a topic into the single visible conversation surface used
2506 // by layouts without a tab strip. It delegates the actual open/reuse behavior to
2507 // the classic tab path, then prunes every non-active visible tab so historical
2508 // clicks do not accumulate hidden startup work.
2509 //
2510 // Interop with StartTopicActivation: a legacy ActivateTopic call supersedes any
2511 // pending ticketed activation (its background completion becomes a no-op and a
2512 // "cancelled" event is emitted for the old requestId), and ticketed
2513 // activations supersede each other the same way. The synchronous return
2514 // contract — TabMeta after the prune — is unchanged.
2515 func (a *App) ActivateTopic(scope, workspaceRoot, topicID, sessionPath string) (TabMeta, error) {
2516 navigation := a.desktopSessions.navigationSeq.Add(1)
2517 a.singleSurfaceMu.Lock()
2518 defer a.singleSurfaceMu.Unlock()
2519 if a.desktopSessions.navigationSeq.Load() != navigation {
2520 return TabMeta{}, errSessionNavigationSuperseded
2521 }
2522
2523 var meta TabMeta
2524 var err error
2525 if strings.TrimSpace(sessionPath) != "" {
2526 meta, err = a.openTopicSessionWithNavigation(scope, workspaceRoot, topicID, sessionPath, navigation)
2527 } else if strings.TrimSpace(scope) == "project" {
2528 meta, err = a.openProjectTab(workspaceRoot, topicID)
2529 } else {
2530 meta, err = a.openGlobalTab(topicID)
2531 }
2532 if err != nil {
2533 return TabMeta{}, err
2534 }
2535 // A legacy activation supersedes any pending ticketed activation: its
2536 // completion must not prune or publish after this call's own prune.
2537 if reqID, tabID := a.supersedePendingTopicActivation(meta.ID); reqID != "" {
2538 a.emitTopicActivation(TopicActivationEvent{RequestID: reqID, TabID: tabID, Phase: topicActivationPhaseCancelled})
2539 }
2540 return a.keepOnlyVisibleTab(meta.ID)
2541 }
2542
2543 // EnsureBlankSurface mirrors EnsureBlankTab for no-tab-strip layouts: after
2544 // creating or reusing a blank session, it removes other visible tabs while
2545 // preserving running runtimes as detached background sessions.
2546 func (a *App) EnsureBlankSurface(scope, workspaceRoot string) (TabMeta, error) {
2547 return a.ensureBlankSurface(scope, workspaceRoot)
2548 }
2549
2550 func (a *App) ensureBlankSurface(scope, workspaceRoot string) (TabMeta, error) {
2551 navigation := a.desktopSessions.navigationSeq.Add(1)
2552 a.singleSurfaceMu.Lock()
2553 defer a.singleSurfaceMu.Unlock()
2554 if a.desktopSessions.navigationSeq.Load() != navigation {
2555 return TabMeta{}, errSessionNavigationSuperseded
2556 }
2557
2558 meta, err := a.ensureBlankTab(scope, workspaceRoot)
2559 if err != nil {
2560 return TabMeta{}, err
2561 }
2562 // Same interop rule as ActivateTopic: this synchronous surface switch
2563 // supersedes any pending ticketed activation.
2564 if reqID, tabID := a.supersedePendingTopicActivation(meta.ID); reqID != "" {
2565 a.emitTopicActivation(TopicActivationEvent{RequestID: reqID, TabID: tabID, Phase: topicActivationPhaseCancelled})
2566 }
2567 return a.keepOnlyVisibleTab(meta.ID)
2568 }
2569
2570 func tabMatchesTopicTarget(tab *WorkspaceTab, scope, workspaceRoot, topicID string) bool {
2571 if tab == nil || tab.Scope != scope || tab.TopicID != topicID {
2572 return false
2573 }
2574 if scope == "global" {
2575 return true
2576 }
2577 return sameProjectRoot(tab.WorkspaceRoot, workspaceRoot)
2578 }
2579
2580 func tabInWorkspace(tab *WorkspaceTab, workspaceRoot string) bool {
2581 return tab != nil &&
2582 tab.Scope == "project" &&
2583 sameProjectRoot(tab.WorkspaceRoot, workspaceRoot)
2584 }
2585
2586 // EnsureBlankTab activates the existing blank tab for the target scope, or
2587 // creates one if none exists. Reusing a blank tab keeps repeated "new session"
2588 // clicks from piling up empty conversations.
2589 func (a *App) EnsureBlankTab(scope, workspaceRoot string) (TabMeta, error) {
2590 return a.ensureBlankTab(scope, workspaceRoot)
2591 }
2592
2593 func (a *App) ensureBlankTab(scope, workspaceRoot string) (TabMeta, error) {
2594 scope = strings.TrimSpace(scope)
2595 if scope != "project" {
2596 scope = "global"
2597 }
2598
2599 globalRoot := ""
2600 if scope == "project" {
2601 workspaceRoot = strings.TrimSpace(workspaceRoot)
2602 if workspaceRoot == "" {
2603 return TabMeta{}, fmt.Errorf("workspaceRoot is required")
2604 }
2605 if abs, err := filepath.Abs(workspaceRoot); err == nil {
2606 workspaceRoot = abs
2607 }
2608 } else {
2609 workspaceRoot = ""
2610 globalRoot = globalWorkspaceRoot()
2611 if err := os.MkdirAll(globalRoot, 0o755); err != nil {
2612 return TabMeta{}, fmt.Errorf("create global workspace: %w", err)
2613 }
2614 }
2615
2616 var created *WorkspaceTab
2617 // Compute actual root early — both the indexed-topic fallback and the
2618 // new-topic path need it when constructing the tab below.
2619 actualRoot := workspaceRoot
2620 if scope == "global" {
2621 actualRoot = globalRoot
2622 }
2623 releaseAdmission, err := a.beginProjectRuntimeAdmission(scope, actualRoot)
2624 if err != nil {
2625 return TabMeta{}, err
2626 }
2627 defer releaseAdmission()
2628 if scope == "project" {
2629 saveWorkspace(workspaceRoot)
2630 a.registerProjectRoot(workspaceRoot)
2631 }
2632 defaultModel, defaultToolApprovalMode := desktopNewSessionDefaults(scope, actualRoot)
2633
2634 a.mu.Lock()
2635 var reusable *WorkspaceTab
2636 for _, id := range a.orderedTabIDsLocked() {
2637 tab := a.tabs[id]
2638 if a.blankTabMatchesTargetLocked(tab, scope, workspaceRoot) {
2639 if err := resetReusableBlankTabTitle(tab, scope, workspaceRoot); err != nil {
2640 a.mu.Unlock()
2641 return TabMeta{}, err
2642 }
2643 reusable = tab
2644 break
2645 }
2646 }
2647 if reusable != nil {
2648 a.mu.Unlock()
2649 if err := a.alignReusableBlankTabModel(reusable, defaultModel); err != nil {
2650 return TabMeta{}, err
2651 }
2652 a.mu.Lock()
2653 if reusable.removed || a.tabs[reusable.ID] != reusable {
2654 a.mu.Unlock()
2655 return TabMeta{}, fmt.Errorf("blank session changed while applying the default model; retry")
2656 }
2657 a.activeTabID = reusable.ID
2658 meta := a.tabMeta(reusable, true)
2659 a.saveTabsLocked()
2660 a.mu.Unlock()
2661 return enrichTabMeta(meta), nil
2662 }
2663
2664 // New blank sessions start from global defaults for model and approval
2665 // posture, keeping execution-local settings (effort/floor/MCP) from the
2666 // active tab without letting it override global defaults (#4019).
2667 inheritedModel := defaultModel
2668 var inheritedEffort *string
2669 inheritedFloor := tabQualityFloor(workspaceRoot, a.activeTabLocked().qualityFloorSafe())
2670 inheritedMode := tabModeFromAxes(false, defaultToolApprovalMode == control.ToolApprovalDangerFullAccess)
2671 inheritedToolApprovalMode := defaultToolApprovalMode
2672 inheritedDisabledMCP := map[string]ServerView{}
2673 var inheritedMCPOrder []string
2674 if active := a.activeTabLocked(); active != nil {
2675 inheritedEffort = cloneStringPtr(active.effort)
2676 inheritedDisabledMCP = cloneServerViewMap(active.disabledMCP)
2677 inheritedMCPOrder = append([]string(nil), active.mcpOrder...)
2678 }
2679
2680 if topicID := a.indexedBlankTopicIDLocked(scope, workspaceRoot); topicID != "" {
2681 // Reuse a previously-indexed but unused blank topic instead of
2682 // creating a new one. Build it inline (not via OpenProjectTab /
2683 // OpenGlobalTab) so it inherits settings from the active tab.
2684 if loadTopicCreatedAt(topicTitleRoot(scope, workspaceRoot), topicID) <= 0 {
2685 createdAt := topicIDCreatedAt(topicID)
2686 if createdAt <= 0 {
2687 createdAt = time.Now().UnixMilli()
2688 }
2689 _ = setTopicCreatedAt(topicTitleRoot(scope, workspaceRoot), topicID, createdAt)
2690 }
2691 tabID := a.newUniqueTabIDLocked()
2692 topicTitle := topicTitleForTab(scope, workspaceRoot, topicID)
2693 created = &WorkspaceTab{
2694 ID: tabID,
2695 Scope: scope,
2696 WorkspaceRoot: actualRoot,
2697 TopicID: topicID,
2698 TopicTitle: topicTitle,
2699 topicTitleSource: loadTopicTitleSource(topicTitleRoot(scope, workspaceRoot), topicID),
2700 model: inheritedModel,
2701 effort: inheritedEffort,
2702 qualityFloor: inheritedFloor,
2703 mode: inheritedMode,
2704 toolApprovalMode: inheritedToolApprovalMode,
2705 disabledMCP: inheritedDisabledMCP,
2706 mcpOrder: inheritedMCPOrder,
2707 }
2708 created.sink = &tabEventSink{tabID: tabID, app: a}
2709 a.tabs[tabID] = created
2710 a.tabOrder = append(a.tabOrder, tabID)
2711 a.activeTabID = tabID
2712 a.saveTabsLocked()
2713 a.mu.Unlock()
2714
2715 // A new-session command returns an executable immutable identity. Build
2716 // and publish it before returning instead of exposing a pathless tab whose
2717 // eventual asynchronous startup could race a second create/delete action.
2718 return a.startCreatedSessionTab(created, actualRoot)
2719 }
2720
2721 topicID := newTopicID()
2722 topicTitle := defaultTopicTitle
2723 createdAt := time.Now().UnixMilli()
2724 if err := createTopicState(workspaceRoot, topicID, topicTitle, topicTitleSourceAuto, createdAt); err != nil {
2725 a.mu.Unlock()
2726 return TabMeta{}, err
2727 }
2728 _ = prependTopicInProjectsFile(workspaceRoot, topicID, false)
2729
2730 tabID := a.newUniqueTabIDLocked()
2731 created = &WorkspaceTab{
2732 ID: tabID,
2733 Scope: scope,
2734 WorkspaceRoot: actualRoot,
2735 TopicID: topicID,
2736 TopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),
2737 topicTitleSource: topicTitleSourceAuto,
2738 model: inheritedModel,
2739 effort: inheritedEffort,
2740 qualityFloor: inheritedFloor,
2741 mode: inheritedMode,
2742 toolApprovalMode: inheritedToolApprovalMode,
2743 disabledMCP: inheritedDisabledMCP,
2744 mcpOrder: inheritedMCPOrder,
2745 }
2746 created.sink = &tabEventSink{tabID: tabID, app: a}
2747 a.tabs[tabID] = created
2748 a.tabOrder = append(a.tabOrder, tabID)
2749 a.activeTabID = tabID
2750 a.saveTabsLocked()
2751 a.mu.Unlock()
2752
2753 return a.startCreatedSessionTab(created, actualRoot)
2754 }
2755
2756 func (a *App) startCreatedSessionTab(created *WorkspaceTab, actualRoot string) (TabMeta, error) {
2757 a.buildTabController(created)
2758 a.mu.RLock()
2759 meta := a.tabMeta(created, true)
2760 startupErr := created.StartupErr
2761 ready := created.Ctrl != nil && created.SessionID != ""
2762 a.mu.RUnlock()
2763 if !ready {
2764 return TabMeta{}, fmt.Errorf("create session runtime: %s", startupErr)
2765 }
2766 a.emitProjectTreeChangedForSessionDirs(desktopSessionDir(actualRoot))
2767 return enrichTabMeta(meta), nil
2768 }
2769
2770 // blankTabMatchesTargetLocked returns true if tab is a reusable blank tab
2771 // matching the given scope/project root — no running controller, no real history.
2772 func (a *App) blankTabMatchesTargetLocked(tab *WorkspaceTab, scope, workspaceRoot string) bool {
2773 if tab == nil || tab.Scope != scope {
2774 return false
2775 }
2776 if scope == "project" && !sameProjectRoot(tab.WorkspaceRoot, workspaceRoot) {
2777 return false
2778 }
2779 if tab.Ctrl == nil {
2780 return blankTabSessionPathHasNoContent(tab)
2781 }
2782 if tab.hasActiveRuntimeWork() {
2783 return false
2784 }
2785 return !messagesHaveConversationContent(tab.Ctrl.History())
2786 }
2787
2788 func createEmptySessionFile(dir, model string) (string, error) {
2789 dir = strings.TrimSpace(dir)
2790 if dir == "" {
2791 return "", fmt.Errorf("session dir is required")
2792 }
2793 if err := os.MkdirAll(dir, 0o755); err != nil {
2794 return "", err
2795 }
2796 for range 3 {
2797 path := agent.NewSessionPath(dir, model)
2798 f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o644)
2799 if err == nil {
2800 if closeErr := f.Close(); closeErr != nil {
2801 return "", closeErr
2802 }
2803 // Ensure branch meta exists for topic ownership; Auto Guard no longer
2804 // stores a per-session toggle (it is built into Auto).
2805 _, _ = agent.EnsureBranchMeta(path)
2806 return path, nil
2807 }
2808 if os.IsExist(err) {
2809 continue
2810 }
2811 return "", err
2812 }
2813 return "", fmt.Errorf("create empty session file: exhausted filename retries")
2814 }
2815
2816 func pinNewEmptySessionBranchMeta(path, scope, workspaceRoot, topicID, topicTitle string) error {
2817 if err := pinSessionBranchMeta(path, scope, workspaceRoot, topicID, topicTitle); err != nil {
2818 pinErr := fmt.Errorf("pin empty session metadata: %w", err)
2819 if cleanupErr := removeDesktopSessionArtifacts(path); cleanupErr != nil {
2820 return errors.Join(pinErr, fmt.Errorf("clean up unbound empty session: %w", cleanupErr))
2821 }
2822 return pinErr
2823 }
2824 return nil
2825 }
2826
2827 // pinSessionBranchMeta stores the workspace scope, root, and topic on a newly
2828 // created session before a controller can reconcile the tab against it.
2829 func pinSessionBranchMeta(sessionPath, scope, workspaceRoot, topicID, topicTitle string) error {
2830 unlock, err := agent.LockSessionMetaPath(sessionPath)
2831 if err != nil {
2832 return err
2833 }
2834 defer unlock()
2835 m, err := agent.EnsureBranchMetaLocked(sessionPath)
2836 if err != nil {
2837 return err
2838 }
2839 if strings.TrimSpace(scope) == "project" {
2840 workspaceRoot = normalizeProjectRoot(workspaceRoot)
2841 if workspaceRoot == "" {
2842 return fmt.Errorf("project workspace root is required")
2843 }
2844 scope = "project"
2845 } else {
2846 scope = "global"
2847 workspaceRoot = ""
2848 }
2849 m.Scope = scope
2850 m.WorkspaceRoot = workspaceRoot
2851 m.TopicID = topicID
2852 m.TopicTitle = topicTitle
2853 return agent.SaveBranchMetaPreserveUpdatedLocked(sessionPath, m)
2854 }
2855
2856 func blankTabSessionPathHasNoContent(tab *WorkspaceTab) bool {
2857 if tab == nil {
2858 return false
2859 }
2860 if strings.TrimSpace(tab.SessionPath) == "" {
2861 return true
2862 }
2863 return sessionPathHasNoContent(tabSessionDir(tab), tab.SessionPath)
2864 }
2865
2866 func sessionPathHasNoContent(sessionDir, sessionPath string) bool {
2867 if strings.TrimSpace(sessionPath) == "" {
2868 return true
2869 }
2870 path, ok := pinnedTabSessionPath(sessionDir, sessionPath)
2871 if !ok {
2872 return false
2873 }
2874 info, err := os.Stat(path)
2875 if err != nil {
2876 return false
2877 }
2878 if info.IsDir() {
2879 return false
2880 }
2881 if info.Size() == 0 {
2882 return true
2883 }
2884 session, err := agent.LoadSession(path)
2885 if err != nil {
2886 return false
2887 }
2888 return !session.HasContent()
2889 }
2890
2891 func resetReusableBlankTabTitle(tab *WorkspaceTab, scope, workspaceRoot string) error {
2892 if tab == nil {
2893 return nil
2894 }
2895 topicID := strings.TrimSpace(tab.TopicID)
2896 if topicID == "" {
2897 return nil
2898 }
2899 titleRoot := topicTitleRoot(scope, workspaceRoot)
2900 if source := loadTopicTitleSource(titleRoot, topicID); source != topicTitleSourceAuto {
2901 return nil
2902 }
2903 if err := setTopicTitleWithSource(titleRoot, topicID, defaultTopicTitle, topicTitleSourceAuto); err != nil {
2904 return err
2905 }
2906 _ = deleteTopicAutoTitleMeta(titleRoot, topicID)
2907 tab.TopicTitle = defaultTopicTitle
2908 tab.topicTitleSource = topicTitleSourceAuto
2909 return nil
2910 }
2911
2912 // indexedBlankTopicIDLocked finds a blank topic ID that is indexed on disk
2913 // but not open in any tab — for reusing without creating a new topic.
2914 func (a *App) indexedBlankTopicIDLocked(scope, workspaceRoot string) string {
2915 titleRoot := topicTitleRoot(scope, workspaceRoot)
2916 titles := loadTopicTitles(titleRoot)
2917 f := loadProjectsFile()
2918
2919 var topicIDs []string
2920 if scope == "global" {
2921 topicIDs = orderedTopicIDs(f.GlobalTopics, titles)
2922 } else if i := projectIndexByRoot(f.Projects, workspaceRoot); i >= 0 {
2923 topicIDs = orderedTopicIDs(f.Projects[i].Topics, titles)
2924 }
2925 if len(topicIDs) == 0 {
2926 return ""
2927 }
2928 // Blank-tab reuse is an automatic write path: the reused ID flows into
2929 // ensureTopicIndexed, whose intentional single-topic prepend clears delete
2930 // tombstones. Picking a tombstoned topic here (its default title can
2931 // linger title-only after a delete raced a scan save) would therefore
2932 // fully resurrect a topic the user removed — skip them.
2933 deletedTopics := make(map[string]bool, len(f.DeletedTopics))
2934 for _, id := range f.DeletedTopics {
2935 deletedTopics[id] = true
2936 }
2937
2938 openTopics := map[string]bool{}
2939 for _, tab := range a.tabs {
2940 if tab == nil || tab.Scope != scope || strings.TrimSpace(tab.TopicID) == "" {
2941 continue
2942 }
2943 if scope == "project" && !sameProjectRoot(tab.WorkspaceRoot, workspaceRoot) {
2944 continue
2945 }
2946 openTopics[tab.TopicID] = true
2947 }
2948 seenSessionDirs := map[string]bool{}
2949 sessionIndexes := []topicSessionDirIndex{}
2950 addSessionIndex := func(dir string) {
2951 dir = cleanDesktopPath(dir)
2952 if dir == "" {
2953 return
2954 }
2955 if seenSessionDirs[dir] {
2956 return
2957 }
2958 seenSessionDirs[dir] = true
2959 if index, err := topicSessionIndexForDir(dir); err == nil {
2960 sessionIndexes = append(sessionIndexes, index)
2961 }
2962 }
2963 if scope == "project" {
2964 addSessionIndex(desktopSessionDir(workspaceRoot))
2965 } else {
2966 addSessionIndex(config.SessionDir())
2967 addSessionIndex(desktopSessionDir(globalWorkspaceRoot()))
2968 }
2969 for _, topicID := range topicIDs {
2970 if deletedTopics[topicID] || openTopics[topicID] {
2971 continue
2972 }
2973 if topicTitleForTab(scope, workspaceRoot, topicID) != defaultTopicTitle {
2974 continue
2975 }
2976 hasSession := false
2977 leaseHeld := false
2978 for _, index := range sessionIndexes {
2979 if topicSessionIndexHasContentTopic(index, topicID) {
2980 hasSession = true
2981 break
2982 }
2983 if topicSessionIndexHasForeignLeaseTopic(index, topicID) {
2984 leaseHeld = true
2985 }
2986 }
2987 if hasSession || leaseHeld {
2988 continue
2989 }
2990 return topicID
2991 }
2992 return ""
2993 }
2994
2995 // ReorderTabs persists the full local+remote strip while keeping each
2996 // registry's internal order independent.
2997 func (a *App) ReorderTabs(tabIDs []string) error {
2998 a.remoteTabMu.Lock()
2999 remoteCount := len(a.remoteTabs)
3000 a.remoteTabMu.Unlock()
3001 a.mu.Lock()
3002 if len(tabIDs) != len(a.tabs)+remoteCount {
3003 a.mu.Unlock()
3004 return fmt.Errorf("tab order length mismatch")
3005 }
3006 seen := make(map[string]bool, len(tabIDs))
3007 next := make([]string, 0, len(a.tabs))
3008 nextRemote := make([]string, 0, remoteCount)
3009 for _, id := range tabIDs {
3010 if seen[id] {
3011 a.mu.Unlock()
3012 return fmt.Errorf("duplicate tab %q", id)
3013 }
3014 seen[id] = true
3015 if _, ok := a.tabs[id]; ok {
3016 next = append(next, id)
3017 } else {
3018 nextRemote = append(nextRemote, id)
3019 }
3020 }
3021 if len(next) != len(a.tabs) {
3022 a.mu.Unlock()
3023 return fmt.Errorf("tab order is missing local tabs")
3024 }
3025 a.remoteTabMu.Lock()
3026 remoteOK := len(nextRemote) == len(a.remoteTabs)
3027 if remoteOK {
3028 for _, id := range nextRemote {
3029 if a.remoteTabs[id] == nil {
3030 remoteOK = false
3031 break
3032 }
3033 }
3034 }
3035 if !remoteOK {
3036 a.remoteTabMu.Unlock()
3037 a.mu.Unlock()
3038 return fmt.Errorf("tab order is missing remote tabs")
3039 }
3040 a.remoteTabLayout.order = append([]string(nil), nextRemote...)
3041 a.remoteTabLayout.stripOrder = append([]string(nil), tabIDs...)
3042 a.remoteTabMu.Unlock()
3043 a.tabOrder = next
3044 dir, entries, activeID, version := a.saveTabsCollectLocked()
3045 a.mu.Unlock()
3046 a.saveTabsWrite(dir, entries, activeID, version)
3047 return nil
3048 }
3049
3050 // CloseTab removes a visible tab. If the tab's session still has foreground or
3051 // background work, the controller is detached so closing a view does not destroy
3052 // the session runtime.
3053 func (a *App) CloseTab(tabID string) error {
3054 return a.closeTab(tabID, true)
3055 }
3056
3057 func (a *App) closeTabRuntime(tabID string, allowDetach bool) error {
3058 defer a.lockRuntimeMutation("close-tab")()
3059 a.sessionRemovalMu.Lock()
3060 defer a.sessionRemovalMu.Unlock()
3061 // The runtime mutation barrier is acquired before sessionRemovalMu. This waits
3062 // for a turn whose admission is already in progress, blocks later turns/builds,
3063 // and leaves the tab visible until an earlier MCP Host-wide gate completes.
3064
3065 a.mu.Lock()
3066 tab, ok := a.tabs[tabID]
3067 if !ok {
3068 a.mu.Unlock()
3069 return fmt.Errorf("tab %q not found", tabID)
3070 }
3071 a.mu.Unlock()
3072
3073 // Snapshot while the tab binding is still present, but outside a.mu because
3074 // snapshot recovery can re-enter App and acquire a.mu. sessionRemovalMu keeps
3075 // DeleteSession/topic/workspace removal from trashing the same files while
3076 // this save is in flight.
3077 if err := a.snapshotTab(tab); err != nil {
3078 slog.Warn("desktop: snapshot before closing tab failed", "tab", tabID, "err", err)
3079 return fmt.Errorf("save current session before closing tab: %w", err)
3080 }
3081 if err := a.saveTabSessionMetaForCurrentSession(tab); err != nil {
3082 slog.Warn("desktop: session metadata before closing tab failed", "tab", tabID, "err", err)
3083 return fmt.Errorf("save current session metadata before closing tab: %w", err)
3084 }
3085 // A terminal belongs to the visible chat tab, even when another tab points
3086 // at the same project. Reap its PTY before removing the tab binding.
3087 if a.terminals != nil {
3088 a.terminals.closeForTab(tabID)
3089 }
3090
3091 // Claim the mirror's farewell while this tab still owns its writer; a close
3092 // that returns early keeps the writer and hands the claim back.
3093 closingMirror, releaseMirrorClaim := a.claimTakeoverMirrorFarewell(a.currentSessionPathFor(tab))
3094 defer releaseMirrorClaim()
3095
3096 a.mu.Lock()
3097 if current := a.tabs[tabID]; current != tab {
3098 a.mu.Unlock()
3099 if current == nil {
3100 return fmt.Errorf("tab %q not found", tabID)
3101 }
3102 return fmt.Errorf("tab %q changed while closing", tabID)
3103 }
3104 if !allowDetach && tab.hasActiveRuntimeWork() {
3105 a.mu.Unlock()
3106 return fmt.Errorf("task still has active work")
3107 }
3108 if tab.Ctrl == nil || !tab.hasActiveRuntimeWork() {
3109 a.markTabRemovedLocked(tab)
3110 }
3111
3112 ordered := a.orderedTabIDsLocked()
3113 closedIndex := -1
3114 for i, id := range ordered {
3115 if id == tabID {
3116 closedIndex = i
3117 break
3118 }
3119 }
3120 delete(a.tabs, tabID)
3121 a.removeTabOrderLocked(tabID)
3122 wasActive := a.activeTabID == tabID
3123 if wasActive {
3124 a.activeTabID = ""
3125 if len(a.tabOrder) > 0 {
3126 nextIndex := max(closedIndex, 0)
3127 if nextIndex >= len(a.tabOrder) {
3128 nextIndex = len(a.tabOrder) - 1
3129 }
3130 a.activeTabID = a.tabOrder[nextIndex]
3131 }
3132 }
3133 a.saveTabsLocked()
3134 // Snapshot the teardown targets while still holding the lock: the tab is
3135 // no longer reachable from a.tabs after this section, but locked writers
3136 // holding stale pointers (rememberTabSessionPath, applySessionBindingToTab)
3137 // can still write its fields under a.mu.
3138 closeCtrl := tab.Ctrl
3139 closeSink := tab.sink
3140 a.mu.Unlock()
3141 if a.workspaceHub != nil {
3142 a.workspaceHub.reconcileRoots()
3143 }
3144
3145 // Tear down outside App.mu while retaining the lifecycle barrier acquired
3146 // before the tab binding was removed.
3147 discardPath, discardTransientBlank := a.transientBlankSessionArtifactPath(tab)
3148 if closeCtrl != nil {
3149 if allowDetach && controllerHasActiveRuntimeWork(closeCtrl) && a.detachSessionRuntime(tab) {
3150 // Detached runtimes keep running and must keep saving: do not
3151 // clear the path or drain for them.
3152 return nil
3153 }
3154 closeCtrl.SetSessionPath("") // future snapshots become no-ops
3155 a.quiesceTabAutosave(tab) // wait for any in-flight snapshot to finish
3156 closeCtrl.Cancel()
3157 closeCtrl.Close()
3158 // Release the shared plugin host reference. The host stays alive as
3159 // long as any other tab for the same workspace root holds a reference;
3160 // on the last release the host is closed and its subprocesses exit.
3161 a.releaseTabSharedHost(tab)
3162 tab.releaseSessionLease()
3163 }
3164 // The writer is released: tell Serve now so it hands the session straight
3165 // back instead of waiting for the writer to drop.
3166 a.endTakeoverMirrorForClosedTab(closingMirror)
3167 if closeSink != nil {
3168 closeSink.clearContext() // stop further emissions (nil ctx -> Emit becomes no-op)
3169 }
3170 if discardTransientBlank {
3171 if discardTransientBlankSessionArtifacts(discardPath) {
3172 a.removeSessionCatalogPath(discardPath, "transient_blank_discarded")
3173 }
3174 }
3175 return nil
3176 }
3177
3178 func (a *App) keepOnlyVisibleTab(tabID string) (TabMeta, error) {
3179 type pruneCandidate struct {
3180 id string
3181 tab *WorkspaceTab
3182 }
3183
3184 // sessionRemovalMu covers snapshotting, pruning the hidden bindings, and
3185 // closing the removed runtimes (a detached runtime must finish its
3186 // in-flight autosave before DeleteSession can see the files). The
3187 // project-tree event stays outside so a listener can never re-enter a
3188 // removal path while the lock is held.
3189 meta, err := func() (TabMeta, error) {
3190 defer a.lockRuntimeMutation("prune-visible-tabs")()
3191 a.sessionRemovalMu.Lock()
3192 defer a.sessionRemovalMu.Unlock()
3193
3194 a.mu.Lock()
3195 active := a.tabs[tabID]
3196 if active == nil {
3197 a.mu.Unlock()
3198 return TabMeta{}, fmt.Errorf("tab %q not found", tabID)
3199 }
3200 candidates := make([]pruneCandidate, 0, len(a.tabs)-1)
3201 for id, tab := range a.tabs {
3202 if id == tabID {
3203 continue
3204 }
3205 candidates = append(candidates, pruneCandidate{id: id, tab: tab})
3206 }
3207 a.mu.Unlock()
3208
3209 // Keep tab bindings in a.tabs while saving so DeleteSession still sees
3210 // them, but do not hold a.mu: Snapshot can run recovery callbacks that
3211 // re-enter App and need the same lock.
3212 snapshotted := make(map[string]*WorkspaceTab, len(candidates))
3213 for _, candidate := range candidates {
3214 id, tab := candidate.id, candidate.tab
3215 snapshotted[id] = tab
3216 if err := a.persistHiddenTabBeforePrune(id, tab); err != nil {
3217 return TabMeta{}, err
3218 }
3219 }
3220
3221 a.mu.Lock()
3222 active = a.tabs[tabID]
3223 if active == nil {
3224 a.mu.Unlock()
3225 return TabMeta{}, fmt.Errorf("tab %q not found", tabID)
3226 }
3227 for id, tab := range a.tabs {
3228 if id != tabID && snapshotted[id] != tab {
3229 a.mu.Unlock()
3230 return TabMeta{}, fmt.Errorf("visible tabs changed while switching; retry")
3231 }
3232 }
3233 a.activeTabID = tabID
3234 removed := make([]*WorkspaceTab, 0, len(candidates))
3235 for _, candidate := range candidates {
3236 id, tab := candidate.id, candidate.tab
3237 if tab == nil || a.tabs[id] != tab {
3238 continue
3239 }
3240 if tab.Ctrl == nil || !tab.hasActiveRuntimeWork() {
3241 a.markTabRemovedLocked(tab)
3242 }
3243 removed = append(removed, tab)
3244 delete(a.tabs, id)
3245 a.removeTabOrderLocked(id)
3246 }
3247 a.tabOrder = []string{tabID}
3248 a.saveTabsLocked()
3249 meta := a.tabMeta(active, true)
3250 a.mu.Unlock()
3251
3252 for _, tab := range removed {
3253 a.removeVisibleTabRuntimeAdmissionHeld(tab)
3254 }
3255 return meta, nil
3256 }()
3257 if err != nil {
3258 return TabMeta{}, err
3259 }
3260 // Visibility and detached/open ownership are runtime state. Snapshot saves
3261 // above already enqueue exact-path catalog updates; a topic switch must not
3262 // rescan every session directory and expose partial catalog generations.
3263 a.emitProjectTreeRuntimeChangedWithLegacy()
3264 return enrichTabMeta(meta), nil
3265 }
3266
3267 func (a *App) applySingleSurfaceTabPolicy() error {
3268 a.singleSurfaceMu.Lock()
3269 defer a.singleSurfaceMu.Unlock()
3270
3271 a.mu.RLock()
3272 tabID := a.activeTabID
3273 if tabID == "" || a.tabs[tabID] == nil {
3274 for _, id := range a.tabOrder {
3275 if a.tabs[id] != nil {
3276 tabID = id
3277 break
3278 }
3279 }
3280 if tabID == "" {
3281 for id := range a.tabs {
3282 tabID = id
3283 break
3284 }
3285 }
3286 }
3287 a.mu.RUnlock()
3288 if tabID == "" {
3289 return nil
3290 }
3291 _, err := a.keepOnlyVisibleTab(tabID)
3292 return err
3293 }
3294
3295 func (a *App) removeVisibleTabRuntimeAdmissionHeld(tab *WorkspaceTab) {
3296 if tab == nil {
3297 return
3298 }
3299 a.mu.RLock()
3300 ctrl := tab.Ctrl
3301 a.mu.RUnlock()
3302 if ctrl != nil && controllerHasActiveRuntimeWork(ctrl) && a.detachSessionRuntime(tab) {
3303 return
3304 }
3305 if err := a.snapshotTab(tab); err != nil {
3306 slog.Warn("desktop: snapshot before removing visible tab runtime failed", "tab", tab.ID, "err", err)
3307 }
3308 discardPath, discardTransientBlank := a.transientBlankSessionArtifactPath(tab)
3309 a.markTabRemoved(tab)
3310 a.closeTabRuntimeAdmissionHeld(tab)
3311 if discardTransientBlank {
3312 if discardTransientBlankSessionArtifacts(discardPath) {
3313 a.removeSessionCatalogPath(discardPath, "transient_blank_discarded")
3314 }
3315 }
3316 }
3317
3318 // transientBlankSessionArtifactPath reports the artifact path to discard when
3319 // closing a still-blank tab. It snapshots the racy tab fields under a.mu and
3320 // keeps the file probe (sessionPathHasNoContent) outside the lock. Callers
3321 // must not hold a.mu.
3322 func (a *App) transientBlankSessionArtifactPath(tab *WorkspaceTab) (string, bool) {
3323 if tab == nil {
3324 return "", false
3325 }
3326 snap := a.tabRuntimeSnapshot(tab)
3327 if snap.readOnly || strings.TrimSpace(snap.topicID) != "" || controllerHasActiveRuntimeWork(snap.ctrl) {
3328 return "", false
3329 }
3330 if strings.TrimSpace(snap.sessionPath) == "" {
3331 return "", false
3332 }
3333 dir := sessionDirForSnapshot(snap)
3334 if !sessionPathHasNoContent(dir, snap.sessionPath) {
3335 return "", false
3336 }
3337 path, ok := pinnedTabSessionPath(dir, snap.sessionPath)
3338 if !ok {
3339 return "", false
3340 }
3341 return path, true
3342 }
3343
3344 func (a *App) markTabRemoved(tab *WorkspaceTab) {
3345 a.mu.Lock()
3346 a.markTabRemovedLocked(tab)
3347 a.mu.Unlock()
3348 }
3349
3350 func (a *App) markTabRemovedLocked(tab *WorkspaceTab) {
3351 if tab == nil {
3352 return
3353 }
3354 tab.removed = true
3355 if tab.buildCancel != nil {
3356 tab.buildCancel()
3357 tab.buildCancel = nil
3358 }
3359 }
3360
3361 // tabBuildSupersededLocked reports whether an in-flight build lost ownership
3362 // of its tab: the tab was removed/replaced, or a session rebind bumped
3363 // buildGeneration to invalidate it. Generation 0 marks the synchronous
3364 // rebuild paths, which serialize through runtimeRebuildMu instead and are
3365 // never superseded by generation bumps. Callers must hold a.mu.
3366 func (a *App) tabBuildSupersededLocked(tab *WorkspaceTab, generation uint64) bool {
3367 if tab == nil || tab.removed || a.shuttingDown.Load() || a.tabs[tab.ID] != tab {
3368 return true
3369 }
3370 return generation != 0 && tab.buildGeneration != generation
3371 }
3372
3373 func (a *App) tabBuildSuperseded(tab *WorkspaceTab, generation uint64) bool {
3374 if tab == nil {
3375 return true
3376 }
3377 a.mu.RLock()
3378 defer a.mu.RUnlock()
3379 return a.tabBuildSupersededLocked(tab, generation)
3380 }
3381
3382 // supersedeTabBuildLocked invalidates any in-flight startup build and cancels
3383 // its context. A synchronous rebuild (model/effort/token switch) that has
3384 // already installed its controller calls this so a slower blank-session build
3385 // cannot finish afterward, overwrite tab.Ctrl, and release or steal the
3386 // session lease the switch just bound. Callers must hold a.mu.
3387 func (a *App) supersedeTabBuildLocked(tab *WorkspaceTab) {
3388 if tab == nil {
3389 return
3390 }
3391 tab.buildGeneration++
3392 if tab.buildCancel != nil {
3393 tab.buildCancel()
3394 tab.buildCancel = nil
3395 }
3396 }
3397
3398 // abandonSupersededBuild cleans up after a build that lost tab ownership
3399 // mid-flight (removed tab, or a session rebind bumped the generation). It
3400 // releases only what THIS build acquired — its controller, its own
3401 // shared-host reference (rootKey), and the session lease bound to its own
3402 // path (leaseKey) — and never reads or clears the tab's SharedHostKey or
3403 // lease outright: on a live rebound tab the replacement build may already
3404 // have published its own key and lease there, and taking those would leak
3405 // the new runtime's host reference (or close a host still in use) and strip
3406 // the new session's lease. Callers must not hold a.mu.
3407 func (a *App) abandonSupersededBuild(tab *WorkspaceTab, ctrl control.SessionAPI, rootKey, leaseKey string) {
3408 if ctrl != nil {
3409 ctrl.Close()
3410 }
3411 if rootKey != "" {
3412 a.releaseSharedHost(rootKey)
3413 }
3414 tab.releaseSessionLeaseForKey(leaseKey)
3415 }
3416
3417 func (a *App) clearTabBuildCancel(tab *WorkspaceTab, generation uint64, cancel context.CancelFunc, keepContext bool) {
3418 if cancel == nil {
3419 return
3420 }
3421 if !keepContext {
3422 defer cancel()
3423 }
3424 if tab == nil {
3425 return
3426 }
3427 a.mu.Lock()
3428 if tab.buildGeneration == generation {
3429 tab.buildCancel = nil
3430 }
3431 a.mu.Unlock()
3432 }
3433
3434 func (a *App) closeTabRuntimeAdmissionHeld(tab *WorkspaceTab) {
3435 if tab == nil {
3436 return
3437 }
3438 a.mu.RLock()
3439 ctrl := tab.Ctrl
3440 sink := tab.sink
3441 a.mu.RUnlock()
3442 if ctrl != nil {
3443 ctrl.SetSessionPath("") // future snapshots become no-ops
3444 a.quiesceTabAutosave(tab)
3445 ctrl.Cancel()
3446 ctrl.Close()
3447 a.releaseTabSharedHost(tab)
3448 }
3449 if sink != nil {
3450 sink.clearContext()
3451 }
3452 tab.releaseSessionLease()
3453 a.mu.Lock()
3454 a.releaseSessionRuntimeLocked(tab)
3455 a.mu.Unlock()
3456 }
3457
3458 // buildTabController assembles a controller for a tab in the background, the
3459 // same way buildController works for the single-controller App. On success it
3460 // wires the controller and flips Ready; on failure it stores StartupErr.
3461 func (a *App) startTabControllerBuild(tab *WorkspaceTab) {
3462 buildCtx, cancel := context.WithCancel(a.bootContext())
3463 a.mu.Lock()
3464 // Historical shells are not ordinary dormant tabs. Only explicit
3465 // preparation may replace their source identity before a runtime starts.
3466 if tab == nil || tab.removed || tab.HistoricalSource != nil {
3467 a.mu.Unlock()
3468 cancel()
3469 return
3470 }
3471 tab.buildGeneration++
3472 generation := tab.buildGeneration
3473 tab.buildCancel = cancel
3474 if tab.buildDone != nil {
3475 // Defensive: the owning build's terminal defer nils buildDone after
3476 // closing it, so a non-nil channel here means that build never ran its
3477 // defer. Close it anyway so activation completions never wait forever.
3478 close(tab.buildDone)
3479 }
3480 tab.buildDone = make(chan struct{})
3481 tab.buildDoneGen = generation
3482 a.mu.Unlock()
3483 if a.ctx == nil {
3484 a.buildTabControllerWithContext(tab, loadedTabSession{}, buildCtx, generation, cancel)
3485 return
3486 }
3487 go a.buildTabControllerWithContext(tab, loadedTabSession{}, buildCtx, generation, cancel)
3488 }
3489
3490 func (a *App) buildTabController(tab *WorkspaceTab) {
3491 a.buildTabControllerWithLoadedSession(tab, loadedTabSession{})
3492 }
3493
3494 type loadedTabSession struct {
3495 Path string
3496 Session *agent.Session
3497 }
3498
3499 func (s loadedTabSession) matches(path string) bool {
3500 return s.Session != nil && sessionRuntimeKey(s.Path) != "" && sessionRuntimeKey(s.Path) == sessionRuntimeKey(path)
3501 }
3502
3503 func (a *App) buildTabControllerWithLoadedSession(tab *WorkspaceTab, loadedSession loadedTabSession) {
3504 a.buildTabControllerWithContext(tab, loadedSession, a.bootContext(), 0, nil)
3505 }
3506
3507 func (a *App) desktopNotificationSender() notify.Sender {
3508 if a == nil {
3509 return notify.NewPlatformSender()
3510 }
3511 a.notificationSenderOnce.Do(func() {
3512 if a.notificationSender == nil {
3513 a.notificationSender = notify.NewPlatformSender()
3514 }
3515 })
3516 return a.notificationSender
3517 }
3518
3519 func (a *App) desktopControllerSink(inner event.Sink, cfg config.NotificationsConfig) event.Sink {
3520 if !cfg.Enabled {
3521 return inner
3522 }
3523 sender := a.desktopNotificationSender()
3524 if sender == nil {
3525 return inner
3526 }
3527 return notify.NewSink(inner, sender, cfg)
3528 }
3529
3530 // closeTabBuildDone signals waiters (topic-activation completions) that the
3531 // build owning buildGeneration has terminated. Every build funnels through
3532 // buildTabControllerWithContextCore, whose deferred call guarantees
3533 // the channel startTabControllerBuild created is closed exactly once, on every
3534 // terminal path — success, failure, and superseded abandon alike. Synchronous
3535 // rebuild paths pass generation 0 and never created a channel.
3536 func (a *App) closeTabBuildDone(tab *WorkspaceTab, buildGeneration uint64) {
3537 if tab == nil || buildGeneration == 0 {
3538 return
3539 }
3540 a.mu.Lock()
3541 if tab.buildDoneGen == buildGeneration && tab.buildDone != nil {
3542 close(tab.buildDone)
3543 tab.buildDone = nil
3544 }
3545 a.mu.Unlock()
3546 }
3547
3548 func (a *App) buildTabControllerWithContext(tab *WorkspaceTab, loadedSession loadedTabSession, buildCtx context.Context, buildGeneration uint64, buildCancel context.CancelFunc) {
3549 a.buildTabControllerWithContextCore(tab, loadedSession, buildCtx, buildGeneration, buildCancel)
3550 }
3551
3552 // buildTabControllerWithContextCore performs configuration, session routing,
3553 // and extension boot outside runtimeAdmissionMu. Only publication enters the
3554 // lifecycle barrier.
3555 func (a *App) buildTabControllerWithContextCore(tab *WorkspaceTab, loadedSession loadedTabSession, buildCtx context.Context, buildGeneration uint64, buildCancel context.CancelFunc) {
3556 defer a.recoverToPending("buildTabController")
3557 keepBuildContext := false
3558 defer func() {
3559 a.clearTabBuildCancel(tab, buildGeneration, buildCancel, keepBuildContext)
3560 }()
3561 defer a.closeTabBuildDone(tab, buildGeneration)
3562 if hook := a.tabBuildStartHook; hook != nil && tab != nil {
3563 // Test-only gate: lets activation-ordering tests hold builds in flight
3564 // and release them out of order. Runs even for already-superseded
3565 // builds so the test can observe every build it started.
3566 hook(tab.ID)
3567 }
3568 appCtx := a.ctx
3569 if a.tabBuildSuperseded(tab, buildGeneration) {
3570 return
3571 }
3572 if !a.prepareTabControllerWorkspace(tab, buildCtx, buildGeneration, appCtx) {
3573 return
3574 }
3575
3576 // Snapshot the identity/profile fields under a.mu before the off-lock
3577 // stretch: session rebinding, recovery, and topic assignment write them
3578 // under the lock while this goroutine builds.
3579 a.mu.RLock()
3580 tabWorkspaceRoot := tab.WorkspaceRoot
3581 tabScope := tab.Scope
3582 tabTopicID := tab.TopicID
3583 tabSeedTitle := canonicalSeedTitle(tab.TopicTitle, tab.topicTitleSource)
3584 tabSessionPath := tab.SessionPath
3585 tabSessionID := tab.SessionID
3586 tabModel := tab.model
3587 tabSink := tab.sink
3588 tabCreateOperationID := tab.PendingCreateOperationID
3589 a.mu.RUnlock()
3590
3591 root := tabWorkspaceRoot
3592 if root == "" {
3593 if wd, err := os.Getwd(); err == nil {
3594 root = wd
3595 }
3596 }
3597
3598 // Load config for this tab's workspace root.
3599 _ = config.MigrateLegacyCredentialsForRoot(root)
3600 cfg, err := config.LoadForRoot(root)
3601 if err != nil {
3602 a.recordTabStartupFailure(tab, buildGeneration, appCtx, err)
3603 return
3604 }
3605
3606 if a.tabBuildSuperseded(tab, buildGeneration) {
3607 return
3608 }
3609 if tabSink != nil {
3610 tabSink.setContext(appCtx)
3611 }
3612
3613 sessionDir := desktopSessionDir(root)
3614 if tabScope == "global" {
3615 sessionDir = desktopSessionDir(globalWorkspaceRoot())
3616 }
3617 topicID := strings.TrimSpace(tabTopicID)
3618 pinnedPath, hasPinnedPath := pinnedTabSessionPathForBuild(tabScope, tabWorkspaceRoot, sessionDir, tabSessionPath)
3619 if hasPinnedPath && agent.IsCleanupPending(pinnedPath) {
3620 // Boot reconciliation may finish the pending deletion before the later
3621 // resume step. Clear the local candidate now so the disappeared path is
3622 // not mistaken for a deliberate empty placeholder afterward.
3623 hasPinnedPath = false
3624 pinnedPath = ""
3625 }
3626 catalogTopicPath := ""
3627 if hasPinnedPath {
3628 // A restored tab's exact path is already known state, not history
3629 // discovery. Keep legacy-directory and empty placeholder paths usable
3630 // while the catalog is still opening or rebuilding.
3631 sessionDir = filepath.Dir(pinnedPath)
3632 } else {
3633 catalogTopicPath = a.catalogSessionPathForTopic(tabScope, tabWorkspaceRoot, topicID)
3634 }
3635 if !hasPinnedPath && catalogTopicPath != "" {
3636 sessionDir = filepath.Dir(catalogTopicPath)
3637 }
3638 startupSessionPath := ""
3639 if hasPinnedPath {
3640 if !agent.IsCleanupPending(pinnedPath) {
3641 startupSessionPath = pinnedPath
3642 }
3643 } else if catalogTopicPath != "" {
3644 startupSessionPath = catalogTopicPath
3645 }
3646 prepareStartupPinnedContext(tab, startupSessionPath, tabSessionPath)
3647 model := strings.TrimSpace(tabModel)
3648 // The v3 event projection owns model selection. desktop-tabs.json only
3649 // remembers which immutable session to open, so stale UI state cannot select
3650 // a different provider when the process restarts.
3651 if strings.TrimSpace(tabSessionID) != "" && strings.TrimSpace(tabCreateOperationID) == "" {
3652 service := a.desktopSessionService(sessionDir)
3653 ref := session.SessionRef{HostID: service.HostID(), SessionID: strings.TrimSpace(tabSessionID)}
3654 if view, openErr := service.OpenSession(buildCtx, ref); openErr == nil && strings.TrimSpace(view.Recent.ModelRef) != "" {
3655 model = strings.TrimSpace(view.Recent.ModelRef)
3656 }
3657 } else if model == "" {
3658 // A legacy sidecar is an import hint only. An explicit tab selection wins,
3659 // and migration never writes the source metadata back.
3660 if sessionModel, ok := agent.LoadSessionModel(startupSessionPath); ok {
3661 config.NormalizeLegacyMimoCustomProvidersForRefs(cfg, sessionModel)
3662 if _, ok := cfg.ResolveModel(sessionModel); ok {
3663 model = sessionModel
3664 }
3665 }
3666 }
3667 if model == "" {
3668 if def := strings.TrimSpace(cfg.DefaultModel); providerext.PluginRefOwner(def) != "" {
3669 // A plugin-namespaced default_model belongs to an extension
3670 // sidecar: the config catalog can never resolve it, but boot's
3671 // merged resolver can. Pass it through untouched.
3672 model = def
3673 } else {
3674 resolved, _, ok := cfg.ResolveDesktopNewSessionModel()
3675 if !ok {
3676 a.recordTabStartupFailure(tab, buildGeneration, appCtx, errNoDesktopChatModel)
3677 return
3678 }
3679 model = resolved
3680 }
3681 }
3682 config.NormalizeLegacyMimoCustomProvidersForRefs(cfg, model)
3683 requestedModel := model
3684 if strings.TrimSpace(tabCreateOperationID) != "" {
3685 resolved, resolveErr := resolveDraftCreateModelStrict(cfg, model)
3686 if resolveErr != nil {
3687 a.recordTabStartupFailure(tab, buildGeneration, appCtx, resolveErr)
3688 return
3689 }
3690 model = resolved
3691 } else if providerext.PluginRefOwner(model) == "" {
3692 // Plugin refs skip the config fallback: rerouting an unavailable
3693 // extension model onto a config provider would silently change the
3694 // session; boot's unknown-model error is the honest failure.
3695 if resolved, fallback, ok := cfg.ResolveModelWithFallback(model); ok {
3696 if fallback && strings.TrimSpace(tabModel) != "" {
3697 a.noticeForTab(tab.ID, fmt.Sprintf("model %q is no longer available; switched to %s", requestedModel, resolved))
3698 }
3699 model = resolved
3700 }
3701 }
3702
3703 // Acquire a shared plugin host for this workspace root so MCP processes
3704 // are launched once per root, not once per tab. SharedHostKey is an a.mu-
3705 // guarded field (takeTabSharedHostKey reads it under the lock during
3706 // teardown), so publish it under the lock alongside the model. Capture the
3707 // tab-local runtime profile here too: bound methods (SetModeForTab,
3708 // SetGoalForTab, SetEffortForTab, ...) write these under a.mu, so the
3709 // off-lock boot.Build below must read a locked snapshot, not the live tab.
3710 rootKey := tabWorkspaceRoot
3711 if rootKey == "" {
3712 rootKey = "__global__" // stable key for global workspace tabs
3713 }
3714 a.mu.Lock()
3715 if a.tabBuildSupersededLocked(tab, buildGeneration) {
3716 a.mu.Unlock()
3717 return
3718 }
3719 tab.rebindEffortModel(cfg, model)
3720 tab.Label = model
3721 tab.SharedHostKey = rootKey
3722 buildEffort := cloneStringPtr(tab.effort)
3723 buildTokenMode := currentTabTokenMode(tab)
3724 buildMode := tab.mode
3725 buildToolApprovalMode := tab.toolApprovalMode
3726 buildDisabledMCP := cloneServerViewMap(tab.disabledMCP)
3727 buildGoal := tab.goal
3728 buildSink := tab.sink
3729 a.saveTabsLocked()
3730 a.mu.Unlock()
3731 buildRuntime := (tabRuntimeSnapshot{
3732 tokenMode: buildTokenMode,
3733 mode: buildMode,
3734 goal: buildGoal,
3735 toolApprovalMode: buildToolApprovalMode,
3736 }).normalizedRuntime()
3737 // Capture the extension generation before the shared host is mutated by
3738 // boot.Build. A concurrent plugin delete/update/reauth bumps the counter;
3739 // if it moves before publication we abandon this controller rather than
3740 // resurrecting removed tools on the shared host.
3741 extensionGen := a.currentExtensionGeneration()
3742 sharedHost := a.acquireSharedHost(rootKey)
3743 sink := a.desktopControllerSink(buildSink, cfg.Notifications)
3744 booted := a.bootTabControllerWithModelFallback(buildCtx, tab, cfg, sharedHost, boot.Options{
3745 Model: model,
3746 RequireKey: false,
3747 StatsSource: "desktop",
3748 TaskStore: a.taskStore(),
3749 OnConfigLoadWarnings: a.configLoadWarningsHandler(),
3750 Sink: sink,
3751 WorkspaceRoot: root,
3752 SessionDir: sessionDir,
3753 EffortOverride: cloneStringPtr(buildEffort),
3754 SharedHost: sharedHost, BrowserExecutor: a.browserExecutorForTab(tab),
3755 CleanupPendingReconciler: reconcileDesktopCleanupPending,
3756 SubagentParentLive: a.subagentParentProbeForBuild(tab),
3757 SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab),
3758 PinnedContextLoader: pinnedContextLoader(root),
3759 OnSessionRecovered: a.handleTabSessionRecovered(tab),
3760 OnSessionTransition: a.handleTabSessionTransition(tab),
3761 BeforeInboxDispatch: a.beforeInboxDispatch,
3762 OnSessionTitleChanged: a.onSessionTitleChanged,
3763 }, extensionGen, buildGeneration, tabSessionID, requestedModel)
3764 buildCtx, registration := booted.ctx, booted.registration
3765 defer func() { registration.rollback() }()
3766 ctrl, err := booted.controller, booted.err
3767 model, modelFallback := booted.model, booted.fallback
3768 if a.handleTabControllerBootError(tab, registration, rootKey, buildGeneration, appCtx, err) {
3769 return
3770 }
3771 if a.tabBuildSuperseded(tab, buildGeneration) {
3772 registration.rollback()
3773 a.abandonSupersededBuild(tab, ctrl, rootKey, "")
3774 return
3775 }
3776 if a.currentExtensionGeneration() != extensionGen {
3777 registration.rollback()
3778 a.abandonSupersededBuild(tab, ctrl, rootKey, "")
3779 a.scheduleDeferredStartupBuild(tab.ID)
3780 return
3781 }
3782 a.bindControllerDisplayRecorder(ctrl)
3783 configureControllerRuntime(ctrl, nil, buildRuntime)
3784 if strings.HasPrefix(tabCreateOperationID, "draft-op-") {
3785 for name := range buildDisabledMCP {
3786 ctrl.UnregisterMCPServerTools(name)
3787 }
3788 }
3789
3790 acquiredLeaseKey := ""
3791 restoredRuntime := buildRuntime
3792 identity, usesExclusiveV3 := ctrl.(control.IdentityLifecycle)
3793 if usesExclusiveV3 && identity.UsesExclusiveSession() {
3794 bound, bindErr := a.bindTabCanonicalSessionTopic(
3795 buildCtx, identity, cfg, tabScope, tabWorkspaceRoot, tabSessionID, startupSessionPath, model, modelFallback, tabTopicID, tabSeedTitle,
3796 )
3797 if bindErr != nil {
3798 a.recordTabStartupFailure(tab, buildGeneration, appCtx, friendlySessionLoadError(bindErr))
3799 ctrl.Close()
3800 a.releaseSharedHost(rootKey)
3801 return
3802 }
3803 a.mu.Lock()
3804 if a.tabBuildSupersededLocked(tab, buildGeneration) {
3805 a.mu.Unlock()
3806 a.abandonSupersededBuild(tab, ctrl, rootKey, "")
3807 return
3808 }
3809 bound.applyLocked(tab)
3810 a.mu.Unlock()
3811 tab.replaceTelemetry(tabTelemetrySnapshot{}, sessionRuntimeKey(remoteSessionIDRoutePrefix+bound.ref.SessionID))
3812 } else if dir := ctrl.SessionDir(); dir != "" {
3813 // Refresh the topic/session locals under the lock: a rebind or the
3814 // recovery callback may have rewritten them since the early snapshot.
3815 a.mu.RLock()
3816 tabTopicID = strings.TrimSpace(tab.TopicID)
3817 tabSessionPath = tab.SessionPath
3818 a.mu.RUnlock()
3819 var path string
3820 var resumeSession *agent.Session
3821 var resumeLoadErr error
3822 // Prefer the exact session file persisted for this tab. Topic lookup is a
3823 // compatibility fallback for older desktop-tabs.json files that only stored
3824 // topicId and could pick the wrong session when one topic had multiple files.
3825 if loaded, pinnedPath, ok, loadErr := loadPinnedTabSessionWithPreload(dir, tabSessionPath, loadedSession); loadErr != nil {
3826 resumeLoadErr = loadErr
3827 } else if ok {
3828 path = pinnedPath
3829 resumeSession = loaded
3830 }
3831 if resumeLoadErr == nil && path == "" && tabTopicID != "" {
3832 existingPath := a.catalogSessionPathForTopic(tabScope, tabWorkspaceRoot, tabTopicID)
3833 if existingPath != "" {
3834 if loaded, err := loadResumableSession(existingPath); err == nil {
3835 path = existingPath
3836 resumeSession = loaded
3837 } else {
3838 resumeLoadErr = err
3839 }
3840 }
3841 }
3842 if resumeLoadErr != nil {
3843 resumeLoadErr = friendlySessionLoadError(resumeLoadErr)
3844 a.mu.Lock()
3845 if a.tabBuildSupersededLocked(tab, buildGeneration) {
3846 a.mu.Unlock()
3847 a.abandonSupersededBuild(tab, ctrl, rootKey, "")
3848 return
3849 }
3850 leaseHeld, save := a.markTabStartupFailureLocked(tab, resumeLoadErr, suppressStartupRestore)
3851 hostKey := takeTabSharedHostKey(tab)
3852 tab.releaseSessionLease()
3853 a.mu.Unlock()
3854 a.writeTabsSaveRequest(save)
3855 ctrl.Close()
3856 if hostKey != "" {
3857 a.releaseSharedHost(hostKey)
3858 }
3859 if leaseHeld {
3860 a.scheduleDeferredStartupBuild(tab.ID)
3861 }
3862 a.emitReady(appCtx, tab.ID)
3863 return
3864 }
3865 if path == "" {
3866 path = agent.NewSessionPath(dir, ctrl.Label())
3867 }
3868 // Write/update scope/session meta.
3869 if path != "" {
3870 if a.claimSessionRuntime(tab, path, buildCtx) {
3871 ctrl.Close()
3872 a.releaseSharedHost(rootKey)
3873 a.emitReady(appCtx, tab.ID)
3874 return
3875 }
3876 preLeaseKey := tab.sessionLeaseRuntimeKey()
3877 if err := a.ensureTabSessionLeaseForRebuild(tab, path, ""); err != nil {
3878 a.mu.Lock()
3879 if a.tabBuildSupersededLocked(tab, buildGeneration) {
3880 a.mu.Unlock()
3881 a.abandonSupersededBuild(tab, ctrl, rootKey, "")
3882 return
3883 }
3884 leaseHeld, save := a.markTabStartupFailureLocked(tab, err, suppressStartupRestore)
3885 hostKey := takeTabSharedHostKey(tab)
3886 // Release only a lease bound to THIS build's session: a failed
3887 // ensure leaves any prior lease untouched, and that lease may
3888 // belong to a runtime a concurrent switch just installed.
3889 tab.releaseSessionLeaseForKey(sessionRuntimeKey(path))
3890 a.mu.Unlock()
3891 a.writeTabsSaveRequest(save)
3892 ctrl.Close()
3893 if hostKey != "" {
3894 a.releaseSharedHost(hostKey)
3895 }
3896 if leaseHeld {
3897 a.scheduleDeferredStartupBuild(tab.ID)
3898 }
3899 a.emitReady(appCtx, tab.ID)
3900 return
3901 }
3902 // Remember which lease THIS build bound: if the build is later
3903 // superseded, only a lease still carrying this key may be
3904 // released (see abandonSupersededBuild). A fast-path reuse means
3905 // the lease existed before this build (bound by a concurrent
3906 // switch or recovery) — it is not ours to release.
3907 if key := sessionRuntimeKey(path); key != preLeaseKey {
3908 acquiredLeaseKey = key
3909 }
3910 // Re-check ownership right after the (potentially slow) lease
3911 // bind: a rebind that superseded this build while ensure was in
3912 // flight has already retargeted the tab, and continuing into
3913 // Resume/persistTabSessionPath would write the stale session
3914 // path back onto the rebound tab.
3915 if a.tabBuildSuperseded(tab, buildGeneration) {
3916 a.abandonSupersededBuild(tab, ctrl, rootKey, acquiredLeaseKey)
3917 return
3918 }
3919 var restoreErr error
3920 restoredRuntime, restoreErr = resumeControllerRuntimeWithSession(ctrl, resumeSession, path, buildRuntime)
3921 if restoreErr != nil {
3922 a.mu.Lock()
3923 if a.tabBuildSupersededLocked(tab, buildGeneration) {
3924 a.mu.Unlock()
3925 a.abandonSupersededBuild(tab, ctrl, rootKey, acquiredLeaseKey)
3926 return
3927 }
3928 leaseHeld, save := a.markTabStartupFailureLocked(tab, restoreErr, suppressStartupRestore)
3929 hostKey := takeTabSharedHostKey(tab)
3930 tab.releaseSessionLeaseForKey(sessionRuntimeKey(path))
3931 a.mu.Unlock()
3932 a.writeTabsSaveRequest(save)
3933 ctrl.Close()
3934 if hostKey != "" {
3935 a.releaseSharedHost(hostKey)
3936 }
3937 if leaseHeld {
3938 a.scheduleDeferredStartupBuild(tab.ID)
3939 }
3940 a.emitReady(appCtx, tab.ID)
3941 return
3942 }
3943 a.persistTabSessionPath(tab, path)
3944 a.mu.RLock()
3945 indexScope := tab.Scope
3946 indexRoot := tab.WorkspaceRoot
3947 indexTopicID := strings.TrimSpace(tab.TopicID)
3948 indexTopicTitle := tab.TopicTitle
3949 a.mu.RUnlock()
3950 if indexTopicID != "" {
3951 if err := ensureTopicIndexed(indexScope, indexRoot, indexTopicID, indexTopicTitle, loadTopicTitleSource(topicTitleRoot(indexScope, indexRoot), indexTopicID)); err == nil {
3952 a.emitProjectTreeChangedForSessionDirs(ctrl.SessionDir())
3953 }
3954 }
3955 // Key telemetry to the session this build binds: restore its
3956 // persisted sidecar, or start from zero when none exists (fresh
3957 // session, CLI-created session, pre-telemetry session). Keeping
3958 // the previous session's totals here made 会话费用 accumulate
3959 // across sessions and persisted the stale totals into the new
3960 // session's sidecar on the next event (#5850).
3961 snapshot := loadTelemetry(path + ".telemetry.json")
3962 tab.replaceTelemetry(snapshot, sessionRuntimeKey(path))
3963 }
3964 }
3965
3966 // Lifecycle admission protects only the compare-and-publish boundary. Slow
3967 // config, history, lease, and extension work above remains cancellable and
3968 // cannot prevent shutdown from acquiring the write side.
3969 releaseDraftPublication, draftPublicationErr := a.lockDraftRuntimePublication(tabCreateOperationID)
3970 if draftPublicationErr != nil {
3971 registration.rollback()
3972 a.abandonSupersededBuild(tab, ctrl, rootKey, acquiredLeaseKey)
3973 a.recordTabStartupFailure(tab, buildGeneration, appCtx, draftPublicationErr)
3974 return
3975 }
3976 defer releaseDraftPublication()
3977 releasePublication, extensionsCurrent := a.lockTabControllerPublication(extensionGen, tabScope, tabWorkspaceRoot)
3978 if !extensionsCurrent {
3979 registration.rollback()
3980 a.abandonSupersededBuild(tab, ctrl, rootKey, acquiredLeaseKey)
3981 a.scheduleDeferredStartupBuild(tab.ID)
3982 return
3983 }
3984 defer releasePublication()
3985 if a.rejectStaleStartupModelSettings(tab, ctrl, buildGeneration, appCtx, func() {
3986 registration.rollback()
3987 a.abandonSupersededBuild(tab, ctrl, rootKey, acquiredLeaseKey)
3988 }) {
3989 return
3990 }
3991 a.mu.Lock()
3992 if a.tabBuildSupersededLocked(tab, buildGeneration) {
3993 a.mu.Unlock()
3994 a.abandonSupersededBuild(tab, ctrl, rootKey, acquiredLeaseKey)
3995 return
3996 }
3997 // Commit the scope while the final tab-generation check is still guarded.
3998 // It only takes the plugin Host leaf lock and cannot call back into App.
3999 if !a.commitStartupWriteAuthorityLocked(tab, ctrl, registration, rootKey, acquiredLeaseKey, appCtx) {
4000 return
4001 }
4002 tab.Ctrl = ctrl
4003 tab.Label = ctrl.Label()
4004 applyNormalizedRuntimeToTabLocked(tab, restoredRuntime)
4005 tab.Ready = true
4006 clearTabStartupError(tab)
4007 a.bindSessionRuntimeKeyLocked(tab, tab.currentSessionIdentity())
4008 a.advanceSessionRuntimeEpochLocked(tab)
4009 keepBuildContext = true
4010 a.mu.Unlock()
4011 a.finishStartupPublication(tab, ctrl, appCtx)
4012 }
4013
4014 type sessionBinding struct {
4015 path string
4016 scope string
4017 workspaceRoot string
4018 topicID string
4019 topicTitle string
4020 hasMeta bool
4021 meta agent.BranchMeta
4022 }
4023
4024 func (a *App) reconcileTabWithPinnedSessionMeta(tab *WorkspaceTab) (string, bool) {
4025 if tab == nil {
4026 return "", false
4027 }
4028 a.mu.RLock()
4029 current := a.tabs[tab.ID]
4030 path := strings.TrimSpace(tab.SessionPath)
4031 ctrl := tab.Ctrl
4032 scope := tab.Scope
4033 workspaceRoot := tab.WorkspaceRoot
4034 a.mu.RUnlock()
4035 if current != tab {
4036 return "", false
4037 }
4038 if path != "" {
4039 if resolved, ok := a.reconcileTabWithSessionPath(tab, path); ok {
4040 return resolved, true
4041 }
4042 }
4043 if ctrl == nil {
4044 return "", false
4045 }
4046 path = strings.TrimSpace(ctrl.SessionPath())
4047 if path == "" {
4048 return "", false
4049 }
4050 binding, ok := a.resolveSessionBinding(path)
4051 if !ok {
4052 return "", false
4053 }
4054 if scope == "project" && binding.scope != "project" && normalizeProjectRoot(workspaceRoot) != "" {
4055 if root, ok := safeControllerWorkspaceRoot(ctrl); ok && sameProjectRoot(root, workspaceRoot) {
4056 return "", false
4057 }
4058 }
4059 a.applySessionBindingToTab(tab, binding)
4060 return binding.path, true
4061 }
4062
4063 func (a *App) reconcileTabWithSessionPath(tab *WorkspaceTab, sessionPath string) (string, bool) {
4064 if tab == nil || strings.TrimSpace(sessionPath) == "" {
4065 return "", false
4066 }
4067 binding, ok := a.resolveSessionBinding(sessionPath)
4068 if !ok {
4069 return "", false
4070 }
4071 a.applySessionBindingToTab(tab, binding)
4072 return binding.path, true
4073 }
4074
4075 func (a *App) applySessionBindingToTab(tab *WorkspaceTab, binding sessionBinding) {
4076 if tab == nil || binding.path == "" {
4077 return
4078 }
4079 var terminalSessions []*terminalSession
4080 reopenTerminalGate := false
4081 scope := binding.scope
4082 workspaceRoot := binding.workspaceRoot
4083 if scope == "" {
4084 scope = "global"
4085 }
4086 if scope == "project" {
4087 workspaceRoot = normalizeProjectRoot(workspaceRoot)
4088 if workspaceRoot == "" {
4089 return
4090 }
4091 releaseAdmission, err := a.beginChangedProjectRuntimeAdmission(tab, scope, workspaceRoot)
4092 if err != nil {
4093 return
4094 }
4095 defer releaseAdmission()
4096 a.registerProjectRoot(workspaceRoot)
4097 } else {
4098 scope = "global"
4099 workspaceRoot = globalTabWorkspaceRoot()
4100 }
4101 topicID := strings.TrimSpace(binding.topicID)
4102 topicTitle := strings.TrimSpace(binding.topicTitle)
4103 if topicTitle == "" && topicID != "" {
4104 topicTitle = topicTitleForTab(scope, workspaceRoot, topicID)
4105 }
4106 topicSource := ""
4107 if topicID != "" {
4108 topicSource = loadTopicTitleSource(topicTitleRoot(scope, workspaceRoot), topicID)
4109 }
4110 pinnedState, preservePendingLegacy := pinnedContextStateForSessionBinding(tab, binding.path)
4111
4112 a.mu.Lock()
4113 current := a.tabs[tab.ID]
4114 if current != nil && current != tab {
4115 a.mu.Unlock()
4116 return
4117 }
4118 oldScope := tab.Scope
4119 oldWorkspaceRoot := tab.WorkspaceRoot
4120 changed := tab.Scope != scope ||
4121 tab.WorkspaceRoot != workspaceRoot ||
4122 canonicalTabSessionPath(tab.SessionPath) != canonicalTabSessionPath(binding.path)
4123 // Spelling-only root updates still persist above, but an equivalent root is
4124 // the same workspace — do not warn the user about a switch.
4125 workspaceChanged := tab.Scope != scope || !sameProjectRoot(tab.WorkspaceRoot, workspaceRoot)
4126 if workspaceChanged && current == tab && a.terminals != nil {
4127 // A session binding can move a visible tab to another project. Invalidate
4128 // the old terminal scope before publishing the new root so an in-flight
4129 // shell start cannot register against the old workspace after this
4130 // transition. Reopen only after the new binding is visible.
4131 terminalSessions = a.terminals.detachForTab(tab.ID)
4132 reopenTerminalGate = !tab.ReadOnly && !tab.removed
4133 }
4134 applyPinnedContextSessionBinding(tab, pinnedState, preservePendingLegacy)
4135 tab.Scope = scope
4136 tab.WorkspaceRoot = workspaceRoot
4137 tab.SessionPath = canonicalTabSessionPath(binding.path)
4138 if topicID != "" {
4139 changed = changed || tab.TopicID != topicID
4140 tab.TopicID = topicID
4141 tab.topicTitleSource = topicSource
4142 }
4143 if topicTitle != "" {
4144 changed = changed || tab.TopicTitle != topicTitle
4145 tab.TopicTitle = topicTitle
4146 }
4147 if changed && current == tab {
4148 a.saveTabsLocked()
4149 }
4150 sink := tab.sink
4151 a.mu.Unlock()
4152 if workspaceChanged && a.workspaceHub != nil {
4153 a.workspaceHub.reconcileRoots()
4154 }
4155 if reopenTerminalGate {
4156 a.terminals.reopenForTab(tab.ID)
4157 }
4158 if len(terminalSessions) > 0 {
4159 a.terminals.closeSessions(terminalSessions)
4160 }
4161 if workspaceChanged && sink != nil {
4162 sink.Emit(event.Event{
4163 Kind: event.Notice,
4164 Level: event.LevelWarn,
4165 Text: sessionBindingWorkspaceNotice(oldScope, oldWorkspaceRoot, scope, workspaceRoot),
4166 })
4167 }
4168 }
4169
4170 func sessionBindingWorkspaceNotice(oldScope, oldWorkspaceRoot, scope, workspaceRoot string) string {
4171 return "Session belongs to " + describeSessionBindingWorkspace(scope, workspaceRoot) +
4172 "; switched tab from " + describeSessionBindingWorkspace(oldScope, oldWorkspaceRoot) +
4173 " to match the saved session."
4174 }
4175
4176 func describeSessionBindingWorkspace(scope, workspaceRoot string) string {
4177 if strings.TrimSpace(scope) == "project" && strings.TrimSpace(workspaceRoot) != "" {
4178 // %q escapes Windows separators, which turns a user-facing path into
4179 // C:\\Users\\... in the notice. Preserve native separators while escaping
4180 // only the delimiters that can appear in a Unix path.
4181 root := strings.ReplaceAll(strings.TrimSpace(workspaceRoot), `"`, `\"`)
4182 return `project workspace "` + root + `"`
4183 }
4184 return "global workspace"
4185 }
4186
4187 func (a *App) resolveSessionBinding(sessionPath string) (sessionBinding, bool) {
4188 legacyPath, ok := validatedLegacySessionPathForRead(sessionPath)
4189 if !ok {
4190 return sessionBinding{}, false
4191 }
4192 sessionPath = string(legacyPath)
4193 for _, dir := range a.knownSessionDirs() {
4194 if binding, ok := sessionBindingInDir(dir, sessionPath); ok {
4195 return binding, true
4196 }
4197 }
4198 if !filepath.IsAbs(sessionPath) {
4199 return sessionBinding{}, false
4200 }
4201 path, err := filepath.Abs(sessionPath)
4202 if err != nil {
4203 return sessionBinding{}, false
4204 }
4205 meta, ok, err := agent.LoadBranchMeta(path)
4206 if err != nil || !ok {
4207 return sessionBinding{}, false
4208 }
4209 for _, dir := range sessionBindingCandidateDirs(meta) {
4210 if binding, ok := sessionBindingInDir(dir, path); ok {
4211 return binding, true
4212 }
4213 }
4214 return sessionBindingFromMeta(path, meta)
4215 }
4216
4217 func sessionBindingCandidateDirs(meta agent.BranchMeta) []string {
4218 if meta.DefaultScope() == "project" {
4219 if root := normalizeProjectRoot(meta.WorkspaceRoot); root != "" {
4220 return []string{desktopSessionDir(root)}
4221 }
4222 return nil
4223 }
4224 return []string{desktopSessionDir(globalWorkspaceRoot()), config.SessionDir()}
4225 }
4226
4227 func sessionBindingInDir(dir, sessionPath string) (sessionBinding, bool) {
4228 path, ok := pinnedTabSessionPath(dir, sessionPath)
4229 if !ok {
4230 return sessionBinding{}, false
4231 }
4232 meta, hasMeta, err := agent.LoadBranchMeta(path)
4233 if err != nil {
4234 return sessionBinding{}, false
4235 }
4236 scope, workspaceRoot, _, ownerOK := legacyMigrationTargetForDir(dir)
4237 if !ownerOK {
4238 if !hasMeta {
4239 return sessionBinding{}, false
4240 }
4241 return sessionBindingFromMeta(path, meta)
4242 }
4243 if scope == "global" {
4244 if !hasMeta {
4245 return sessionBinding{}, false
4246 }
4247 return sessionBindingFromMeta(path, meta)
4248 }
4249 binding := sessionBinding{
4250 path: path,
4251 scope: scope,
4252 workspaceRoot: workspaceRoot,
4253 hasMeta: hasMeta,
4254 meta: meta,
4255 }
4256 if hasMeta {
4257 binding.topicID = strings.TrimSpace(meta.TopicID)
4258 binding.topicTitle = strings.TrimSpace(meta.TopicTitle)
4259 }
4260 if binding.scope == "project" {
4261 binding.workspaceRoot = normalizeProjectRoot(binding.workspaceRoot)
4262 }
4263 return binding, true
4264 }
4265
4266 func sessionBindingFromMeta(path string, meta agent.BranchMeta) (sessionBinding, bool) {
4267 scope := meta.DefaultScope()
4268 workspaceRoot := ""
4269 if scope == "project" {
4270 workspaceRoot = normalizeProjectRoot(meta.WorkspaceRoot)
4271 if workspaceRoot == "" {
4272 return sessionBinding{}, false
4273 }
4274 } else {
4275 scope = "global"
4276 workspaceRoot = globalTabWorkspaceRoot()
4277 }
4278 return sessionBinding{
4279 path: path,
4280 scope: scope,
4281 workspaceRoot: workspaceRoot,
4282 topicID: strings.TrimSpace(meta.TopicID),
4283 topicTitle: strings.TrimSpace(meta.TopicTitle),
4284 hasMeta: true,
4285 meta: meta,
4286 }, true
4287 }
4288
4289 // active tab helpers
4290
4291 // activeTab returns the currently active tab (nil when there are no tabs).
4292 // Self-locking; safe to call from any goroutine without external lock.
4293 func (a *App) activeTab() *WorkspaceTab {
4294 a.mu.RLock()
4295 defer a.mu.RUnlock()
4296 if a.activeTabID == "" {
4297 return nil
4298 }
4299 return a.tabs[a.activeTabID]
4300 }
4301
4302 // activeTabLocked is like activeTab but assumes the caller already holds a.mu
4303 // (either RLock or Lock). Use this inside critical sections that already own
4304 // the lock to avoid double-locking a write-lock holder.
4305 func (a *App) activeTabLocked() *WorkspaceTab {
4306 if a.activeTabID == "" {
4307 return nil
4308 }
4309 return a.tabs[a.activeTabID]
4310 }
4311
4312 // activeCtrl returns the controller of the active tab, or nil.
4313 // Self-locking; safe to call from any goroutine without external lock.
4314 func (a *App) activeCtrl() control.SessionAPI {
4315 a.mu.RLock()
4316 defer a.mu.RUnlock()
4317 return a.activeCtrlLocked()
4318 }
4319
4320 // activeCtrlLocked is like activeCtrl but assumes the caller already holds a.mu.
4321 func (a *App) activeCtrlLocked() control.SessionAPI {
4322 t := a.activeTabLocked()
4323 if t == nil {
4324 return nil
4325 }
4326 return t.Ctrl
4327 }
4328
4329 func (a *App) tabByID(tabID string) *WorkspaceTab {
4330 a.mu.RLock()
4331 defer a.mu.RUnlock()
4332 return a.tabByIDLocked(tabID)
4333 }
4334
4335 func (a *App) tabByIDLocked(tabID string) *WorkspaceTab {
4336 if strings.TrimSpace(tabID) == "" {
4337 return a.activeTabLocked()
4338 }
4339 return a.tabs[tabID]
4340 }
4341
4342 func (a *App) ctrlByTabID(tabID string) control.SessionAPI {
4343 a.mu.RLock()
4344 defer a.mu.RUnlock()
4345 tab := a.tabByIDLocked(tabID)
4346 if tab == nil {
4347 return nil
4348 }
4349 return tab.Ctrl
4350 }
4351
4352 // autosave per tab
4353
4354 const maxTabSnapshotFailureRetries = 2
4355
4356 // autosaveWarnInterval rate-limits the user-facing autosave-failure notice
4357 // per tab; slog keeps recording every failure regardless.
4358 const autosaveWarnInterval = 5 * time.Minute
4359
4360 func tabSnapshotRetryDelay(failures int) time.Duration {
4361 switch {
4362 case failures <= 1:
4363 return 100 * time.Millisecond
4364 case failures == 2:
4365 return 250 * time.Millisecond
4366 default:
4367 return 500 * time.Millisecond
4368 }
4369 }
4370
4371 func (a *App) scheduleTabSnapshot(tabID string) {
4372 a.mu.RLock()
4373 tab := a.tabByEventSinkIDLocked(tabID)
4374 a.mu.RUnlock()
4375 if tab == nil {
4376 return
4377 }
4378 tab.saveMu.Lock()
4379 defer tab.saveMu.Unlock()
4380 if tab.closing {
4381 // Tab is being torn down: don't start new snapshot work that could
4382 // race DeleteSession and resurrect a trashed session file (#4384).
4383 return
4384 }
4385 if tab.saving {
4386 tab.saveAgain = true
4387 return
4388 }
4389 tab.saving = true
4390 tab.saveFailures = 0
4391 go a.tabSnapshotLoop(tab)
4392 }
4393
4394 // quiesceTabAutosave marks the tab as closing and blocks until any in-flight
4395 // tabSnapshotLoop has finished its current (and final) write. After it returns,
4396 // no background goroutine can call Snapshot on this tab's controller again, so
4397 // a subsequent DeleteSession cannot race a late write. Safe to call after the
4398 // controller's session path has been cleared: the loop's Snapshot becomes a
4399 // no-op and it exits on its next iteration.
4400 func (a *App) quiesceTabAutosave(tab *WorkspaceTab) {
4401 if tab == nil {
4402 return
4403 }
4404 tab.saveMu.Lock()
4405 if tab.saveCond == nil {
4406 // saveCond is lazily initialized on first snapshot; if it was never
4407 // set there is no loop to wait for.
4408 tab.closing = true
4409 tab.saveMu.Unlock()
4410 return
4411 }
4412 tab.closing = true
4413 for tab.saving {
4414 tab.saveCond.Wait()
4415 }
4416 tab.saveMu.Unlock()
4417 }
4418
4419 func (a *App) tabSnapshotLoop(tab *WorkspaceTab) {
4420 defer a.recoverToPending("tabSnapshotLoop")
4421 for {
4422 var snapshotErr error
4423 a.mu.RLock()
4424 ctrl := tab.Ctrl
4425 a.mu.RUnlock()
4426 if ctrl != nil {
4427 if err := a.snapshotTab(tab); err == nil {
4428 a.mu.RLock()
4429 scope, workspaceRoot := tab.Scope, tab.WorkspaceRoot
4430 a.mu.RUnlock()
4431 a.requestSessionCatalogPath(scope, workspaceRoot, ctrl.SessionPath())
4432 if !a.maybeAutoTitleTopic(tab) {
4433 a.emitProjectTreeChangedForSessionDirs(ctrl.SessionDir())
4434 }
4435 } else {
4436 snapshotErr = err
4437 }
4438 }
4439 tab.saveMu.Lock()
4440 if tab.saveCond == nil {
4441 tab.saveCond = sync.NewCond(&tab.saveMu)
4442 }
4443 if snapshotErr == nil {
4444 tab.saveFailures = 0
4445 } else {
4446 tab.saveFailures++
4447 }
4448 if tab.closing {
4449 // Tab is being torn down: stop without picking up saveAgain work.
4450 tab.saving = false
4451 tab.saveCond.Broadcast()
4452 tab.saveMu.Unlock()
4453 if snapshotErr != nil {
4454 slog.Warn("desktop: session autosave failed during teardown", "tab", tab.ID, "err", snapshotErr)
4455 }
4456 return
4457 }
4458 if tab.saveAgain {
4459 tab.saveAgain = false
4460 tab.saveMu.Unlock()
4461 if snapshotErr != nil {
4462 slog.Warn("desktop: session autosave failed; newer snapshot queued", "tab", tab.ID, "err", snapshotErr)
4463 }
4464 continue
4465 }
4466 if snapshotErr != nil && tab.saveFailures <= maxTabSnapshotFailureRetries {
4467 delay := tabSnapshotRetryDelay(tab.saveFailures)
4468 attempt := tab.saveFailures
4469 tab.saveMu.Unlock()
4470 // Retries are routine (transient AV/indexer holds); tell the user
4471 // only when the whole burst gives up, not once per attempt.
4472 slog.Warn("desktop: session autosave failed; retrying", "tab", tab.ID, "attempt", attempt, "err", snapshotErr)
4473 time.Sleep(delay)
4474 continue
4475 }
4476 exhausted := snapshotErr
4477 tab.saving = false
4478 tab.saveCond.Broadcast()
4479 tab.saveMu.Unlock()
4480 if exhausted != nil {
4481 a.reportTabSnapshotError(tab, "autosave", exhausted)
4482 }
4483 return
4484 }
4485 }
4486
4487 func (a *App) maybeAutoTitleTopic(tab *WorkspaceTab) bool {
4488 if tab == nil {
4489 return false
4490 }
4491 a.topicTitleMutationMu.Lock()
4492 defer a.topicTitleMutationMu.Unlock()
4493 // Runs on the autosave goroutine; TopicID/Scope/WorkspaceRoot/Ctrl are
4494 // written under a.mu by session switches and recovery.
4495 a.mu.RLock()
4496 topicID := strings.TrimSpace(tab.TopicID)
4497 titleRoot := tab.WorkspaceRoot
4498 if tab.Scope == "global" {
4499 titleRoot = ""
4500 }
4501 ctrl := tab.Ctrl
4502 a.mu.RUnlock()
4503 if topicID == "" || ctrl == nil {
4504 return false
4505 }
4506 if source := loadTopicTitleSource(titleRoot, topicID); source != topicTitleSourceAuto {
4507 return false
4508 }
4509 sessionPath := ctrl.SessionPath()
4510 if sessionPath == "" {
4511 return false
4512 }
4513 if sessionHasManualDisplayTitle(sessionPath) {
4514 return false
4515 }
4516 nextTitle, updated := autoTitleTopicFromSession(titleRoot, topicID, sessionPath)
4517 if !updated {
4518 return false
4519 }
4520 if topicAutoTitleCommittedHookForTest != nil {
4521 topicAutoTitleCommittedHookForTest()
4522 }
4523 a.updateOpenTopicTitle(topicID, nextTitle, topicTitleSourceAuto)
4524 changedDirs := a.updateTopicSessionTitles(topicID, nextTitle)
4525 if len(changedDirs) > 0 {
4526 a.emitProjectTreeChangedForSessionDirs(changedDirs...)
4527 } else {
4528 a.emitProjectTreeMetadataChanged()
4529 }
4530 return true
4531 }
4532
4533 func autoTitleTopicFromSession(workspaceRoot, topicID, sessionPath string) (string, bool) {
4534 if source := loadTopicTitleSource(workspaceRoot, topicID); source != topicTitleSourceAuto {
4535 return "", false
4536 }
4537 if sessionHasManualDisplayTitle(sessionPath) {
4538 return "", false
4539 }
4540 proposal := autoTopicTitleProposalFromSession(sessionPath)
4541 if proposal.Title == "" {
4542 return "", false
4543 }
4544 if !shouldApplyAutoTopicTitle(workspaceRoot, topicID, proposal) {
4545 return "", false
4546 }
4547 nextTitle := proposal.Title
4548 sameTitle := nextTitle == strings.TrimSpace(loadTopicTitle(workspaceRoot, topicID))
4549 applied, err := applyAutoTopicTitle(workspaceRoot, topicID, nextTitle, proposal)
4550 if err != nil || !applied {
4551 return "", false
4552 }
4553 if sameTitle {
4554 return "", false
4555 }
4556 return nextTitle, true
4557 }
4558
4559 type autoTopicTitleProposal struct {
4560 Title string
4561 Stage int
4562 UserTurns int
4563 BasisHash string
4564 }
4565
4566 func autoTopicTitleProposalFromSession(path string) autoTopicTitleProposal {
4567 users := topicTitleUserTurnsFromSession(path)
4568 if len(users) == 0 {
4569 return autoTopicTitleProposal{}
4570 }
4571 stage := 1
4572 if len(users) >= 3 {
4573 stage = 3
4574 }
4575 basis := users
4576 if len(basis) > stage {
4577 basis = basis[:stage]
4578 }
4579 title := topicTitleFromUserTurns(basis)
4580 if title == "" {
4581 return autoTopicTitleProposal{}
4582 }
4583 sum := sha256.Sum256(fmt.Appendf(nil, "%d\x00%s", stage, strings.Join(basis, "\x00")))
4584 return autoTopicTitleProposal{
4585 Title: title,
4586 Stage: stage,
4587 UserTurns: len(users),
4588 BasisHash: hex.EncodeToString(sum[:8]),
4589 }
4590 }
4591
4592 func shouldApplyAutoTopicTitle(workspaceRoot, topicID string, proposal autoTopicTitleProposal) bool {
4593 if proposal.Stage <= 0 || proposal.BasisHash == "" {
4594 return false
4595 }
4596 meta := loadTopicAutoTitleMeta(workspaceRoot)[topicID]
4597 if meta.Stage > proposal.Stage {
4598 return false
4599 }
4600 if meta.Stage == proposal.Stage && meta.BasisHash == proposal.BasisHash {
4601 return false
4602 }
4603 return true
4604 }
4605
4606 func sessionHasManualDisplayTitle(sessionPath string) bool {
4607 legacyPath, ok := validatedLegacySessionPathForRead(sessionPath)
4608 if !ok {
4609 return false
4610 }
4611 sessionPath = string(legacyPath)
4612 if meta, ok, err := agent.LoadBranchMeta(sessionPath); err == nil && ok {
4613 if strings.TrimSpace(meta.CustomTitle) != "" {
4614 return true
4615 }
4616 }
4617 dir := filepath.Dir(sessionPath)
4618 if dir == "." || dir == string(filepath.Separator) {
4619 return false
4620 }
4621 return strings.TrimSpace(loadSessionTitles(dir)[filepath.Base(sessionPath)]) != ""
4622 }
4623
4624 func topicTitleFallbackForOpen(workspaceRoot, topicID, sessionPath string) (string, string, bool) {
4625 topicID = strings.TrimSpace(topicID)
4626 legacyPath, ok := validatedLegacySessionPathForRead(sessionPath)
4627 if topicID == "" || !ok {
4628 return "", "", false
4629 }
4630 sessionPath = string(legacyPath)
4631 storedTitle := strings.TrimSpace(loadTopicTitle(workspaceRoot, topicID))
4632 storedSource := strings.TrimSpace(loadTopicTitleSource(workspaceRoot, topicID))
4633 if storedTitle != "" {
4634 if storedSource == topicTitleSourceManual || !isDefaultTopicTitle(storedTitle) {
4635 return "", "", false
4636 }
4637 }
4638
4639 if storedTitle == "" {
4640 dir := filepath.Dir(sessionPath)
4641 if meta, ok, err := agent.LoadBranchMeta(sessionPath); err == nil && ok {
4642 if title := storedSessionTopicTitle(dir, sessionPath, meta); title != "" {
4643 return title, topicTitleSourceManual, true
4644 }
4645 } else if title := topicTitleFromText(loadSessionTitles(dir)[filepath.Base(sessionPath)]); title != "" {
4646 return title, topicTitleSourceManual, true
4647 }
4648 }
4649
4650 if storedSource == topicTitleSourceManual {
4651 return "", "", false
4652 }
4653 if storedSource == "" || storedSource == topicTitleSourceAuto {
4654 if title := topicTitleFromSession(sessionPath); title != "" {
4655 return title, topicTitleSourceAuto, true
4656 }
4657 }
4658 return "", "", false
4659 }
4660
4661 func topicTitleFromSession(path string) string {
4662 users := topicTitleUserTurnsFromSession(path)
4663 if len(users) == 0 {
4664 return ""
4665 }
4666 return topicTitleFromText(users[0])
4667 }
4668
4669 func topicTitleUserTurnsFromSession(path string) []string {
4670 users, _ := loadTopicTitleUserTurnsFromSession(path)
4671 return users
4672 }
4673
4674 func loadTopicTitleUserTurnsFromSession(path string) ([]string, error) {
4675 legacyPath, ok := validatedLegacySessionPathForRead(path)
4676 if !ok {
4677 return nil, &sessionLocatorError{reason: "invalid_legacy_path"}
4678 }
4679 // Event-log aware: decoding the .jsonl checkpoint directly would stop
4680 // seeing user turns after the first save, silently disabling the ≥3-turn
4681 // title upgrade.
4682 msgs, err := agent.LoadSessionUserMessages(string(legacyPath))
4683 if err != nil {
4684 return nil, err
4685 }
4686 var users []string
4687 for _, msg := range msgs {
4688 // Host-injected synthetic turns (readiness nudges, recovery retries) and
4689 // mid-turn steers are persisted as role "user" but are not user-authored:
4690 // counting them inflated userTurns past the stage-3 threshold and let
4691 // "Host final-answer readiness check failed…" become a topic title.
4692 // UserPreviewText is the canonical user-authored view: it unwraps
4693 // memory-compiler execution contracts and strips transient blocks
4694 // (and runs HandoffTask), so internal wrappers can never become a
4695 // title basis (#5666).
4696 if content := topicTitleUserText(msg.Message); content != "" {
4697 users = append(users, content)
4698 }
4699 }
4700 return users, nil
4701 }
4702
4703 func topicTitleFromUserTurns(users []string) string {
4704 type candidate struct {
4705 title string
4706 score int
4707 }
4708 best := candidate{score: -1}
4709 for i, text := range users {
4710 title := topicTitleFromText(text)
4711 if title == "" || lowSignalTopicTitle(title) {
4712 continue
4713 }
4714 runes := len([]rune(title))
4715 score := min(runes, 24)
4716 if i == 0 {
4717 score += 3
4718 }
4719 if runes < 5 {
4720 score -= 6
4721 }
4722 if score > best.score {
4723 best = candidate{title: title, score: score}
4724 }
4725 }
4726 if best.title != "" {
4727 return best.title
4728 }
4729 if len(users) > 0 {
4730 return topicTitleFromText(users[0])
4731 }
4732 return ""
4733 }
4734
4735 func lowSignalTopicTitle(title string) bool {
4736 normalized := strings.ToLower(strings.TrimSpace(title))
4737 normalized = strings.Trim(normalized, " \t\r\n,。!?;:、,.!?;:\"'`“”‘’()()[]【】")
4738 switch normalized {
4739 case "", "好", "好的", "好啊", "可以", "嗯", "对", "是的", "继续", "继续吧", "采纳建议", "采用建议", "收到", "明白", "ok", "okay", "yes", "yep", "go on", "continue", "thanks", "thank you":
4740 return true
4741 default:
4742 return false
4743 }
4744 }
4745
4746 func topicTitleFromText(text string) string {
4747 text = strings.TrimSpace(text)
4748 if text == "" {
4749 return ""
4750 }
4751 text = strings.Join(strings.Fields(text), " ")
4752 text = strings.Trim(text, " \t\r\n,。!?;:、,.!?;:\"'`“”‘’()()[]【】")
4753 if text == "" {
4754 return ""
4755 }
4756 const maxRunes = 18
4757 runes := []rune(text)
4758 if len(runes) > maxRunes {
4759 text = strings.TrimRightFunc(string(runes[:maxRunes]), unicode.IsPunct) + "…"
4760 }
4761 if isDefaultTopicTitle(text) {
4762 return ""
4763 }
4764 return text
4765 }
4766
4767 // persistence: desktop-projects.json
4768
4769 const desktopProjectsFile = "desktop-projects.json"
4770 const tabsFileName = "desktop-tabs.json"
4771 const desktopGlobalOrderToken = "__global__"
4772 const legacyProjectSidebarRecoveryMarker = "desktop-projects-legacy-recovered"
4773
4774 var desktopProjectsFileMu sync.Mutex
4775
4776 func desktopConfigDir() string {
4777 return config.ReasonixHomeDir()
4778 }
4779
4780 func (a *App) saveTabsLocked() {
4781 dir, entries, activeID, version := a.saveTabsCollectLocked()
4782 a.saveTabsWrite(dir, entries, activeID, version)
4783 }
4784
4785 // saveTabsCollectLocked gathers the tab-snapshot data under the caller's lock
4786 // (it calls orderedTabIDsLocked which requires a.mu). Returns the config dir,
4787 // the serializable entries, the active tab ID, and a monotonic snapshot version.
4788 // The write can happen outside the lock to avoid blocking the UI with disk I/O.
4789 func (a *App) saveTabsCollectLocked() (string, []desktopTabEntry, string, uint64) {
4790 dir := desktopConfigDir()
4791 var entries []desktopTabEntry
4792 for _, id := range a.orderedTabIDsLocked() {
4793 if tab := a.tabs[id]; tab != nil {
4794 if a.suppressTabStartupRestoreLocked(tab) {
4795 continue
4796 }
4797 entries = append(entries, persistedDesktopTabEntry(tab))
4798 }
4799 }
4800 a.tabsSaveVersion++
4801 return dir, entries, persistedActiveTabID(entries, a.activeTabID), a.tabsSaveVersion
4802 }
4803
4804 func (a *App) orderedTabIDsLocked() []string {
4805 ordered, needsRepair := a.orderedTabIDsSnapshotLocked()
4806 if needsRepair {
4807 a.tabOrder = append([]string(nil), ordered...)
4808 }
4809 return ordered
4810 }
4811
4812 func (a *App) orderedTabIDsSnapshotLocked() ([]string, bool) {
4813 seen := make(map[string]bool, len(a.tabs))
4814 ordered := make([]string, 0, len(a.tabs))
4815 for _, id := range a.tabOrder {
4816 if _, ok := a.tabs[id]; ok && !seen[id] {
4817 ordered = append(ordered, id)
4818 seen[id] = true
4819 }
4820 }
4821 var missing []string
4822 for id := range a.tabs {
4823 if !seen[id] {
4824 missing = append(missing, id)
4825 }
4826 }
4827 sort.Strings(missing)
4828 ordered = append(ordered, missing...)
4829 return ordered, len(ordered) != len(a.tabOrder) || len(missing) > 0
4830 }
4831
4832 func loadTabsFile() desktopTabsFile {
4833 path := filepath.Join(desktopConfigDir(), tabsFileName)
4834 b, err := readFileUTF8(path)
4835 if err != nil {
4836 return desktopTabsFile{}
4837 }
4838 var f desktopTabsFile
4839 _ = json.Unmarshal(b, &f)
4840 return f
4841 }
4842
4843 func desktopMCPMigrationRoots(tabs desktopTabsFile) []string {
4844 seen := map[string]bool{}
4845 var roots []string
4846 add := func(root string) {
4847 root = normalizeProjectRoot(root)
4848 key := projectRootKey(root)
4849 if root == "" || seen[key] {
4850 return
4851 }
4852 seen[key] = true
4853 roots = append(roots, root)
4854 }
4855 if cur := loadWorkspace(); cur != "" {
4856 add(cur)
4857 }
4858 for _, root := range loadWorkspaces() {
4859 add(root)
4860 }
4861 for _, entry := range tabs.Tabs {
4862 if entry.Scope == "project" {
4863 add(entry.WorkspaceRoot)
4864 }
4865 }
4866 for _, project := range loadProjectsFile().Projects {
4867 add(project.Root)
4868 }
4869 return roots
4870 }
4871
4872 func recoverLegacyProjectSidebarRoots(tabs desktopTabsFile) (bool, error) {
4873 markerPath := filepath.Join(desktopConfigDir(), legacyProjectSidebarRecoveryMarker)
4874 if _, err := os.Stat(markerPath); err == nil {
4875 return false, nil
4876 }
4877
4878 changed := false
4879 err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
4880 seen := map[string]bool{}
4881 for _, project := range f.Projects {
4882 root := normalizeProjectRoot(project.Root)
4883 if root != "" {
4884 seen[projectRootKey(root)] = true
4885 }
4886 }
4887
4888 add := func(root string) {
4889 root = normalizeProjectRoot(root)
4890 key := projectRootKey(root)
4891 if root == "" || seen[key] || !existingDirectory(root) {
4892 return
4893 }
4894 seen[key] = true
4895 f.Projects = append(f.Projects, desktopProject{Root: root})
4896 changed = true
4897 }
4898 if cur := loadWorkspace(); cur != "" {
4899 add(cur)
4900 }
4901 for _, root := range loadWorkspaces() {
4902 add(root)
4903 }
4904 for _, entry := range tabs.Tabs {
4905 if entry.Scope == "project" {
4906 add(entry.WorkspaceRoot)
4907 }
4908 }
4909 return changed, nil
4910 })
4911 if err != nil {
4912 return false, err
4913 }
4914 return changed, writeLegacyProjectSidebarRecoveryMarker(markerPath)
4915 }
4916
4917 func existingDirectory(path string) bool {
4918 info, err := os.Stat(path)
4919 return err == nil && info.IsDir()
4920 }
4921
4922 func writeLegacyProjectSidebarRecoveryMarker(path string) error {
4923 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
4924 return err
4925 }
4926 return os.WriteFile(path, []byte("ok\n"), 0o644)
4927 }
4928
4929 func loadProjectsFile() desktopProjectFile {
4930 path := filepath.Join(desktopConfigDir(), desktopProjectsFile)
4931 b, err := readFileUTF8(path)
4932 if err != nil {
4933 return desktopProjectFile{}
4934 }
4935 var f desktopProjectFile
4936 _ = json.Unmarshal(b, &f)
4937 f = normalizeProjectsFile(f)
4938 if organization, ok := loadProjectOrganizationFile(); ok {
4939 return applyProjectOrganization(f, organization)
4940 }
4941 // Upgrade existing inline organization state immediately. The sidecar is
4942 // what makes a later old-version save non-destructive.
4943 if projectsFileHasOrganization(f) {
4944 _ = saveProjectOrganizationFile(f)
4945 }
4946 return f
4947 }
4948
4949 func saveProjectsFile(f desktopProjectFile) error {
4950 dir := desktopConfigDir()
4951 if err := os.MkdirAll(dir, 0o755); err != nil {
4952 return err
4953 }
4954 f = normalizeProjectsFile(f)
4955 if err := saveProjectOrganizationFile(f); err != nil {
4956 return err
4957 }
4958 b, err := json.MarshalIndent(f, "", " ")
4959 if err != nil {
4960 return err
4961 }
4962 path := filepath.Join(dir, desktopProjectsFile)
4963 tmp := path + ".tmp"
4964 if err := os.WriteFile(tmp, b, 0o644); err != nil {
4965 return err
4966 }
4967 return fileutil.ReplaceFile(tmp, path)
4968 }
4969
4970 func updateProjectsFile(mutator func(*desktopProjectFile) (bool, error)) error {
4971 desktopProjectsFileMu.Lock()
4972 defer desktopProjectsFileMu.Unlock()
4973 return updateProjectsFileLocked(mutator)
4974 }
4975
4976 func updateProjectsFileLocked(mutator func(*desktopProjectFile) (bool, error)) error {
4977 release, err := acquireDesktopProjectsFileLock()
4978 if err != nil {
4979 return err
4980 }
4981 defer release()
4982 return updateProjectsFileCrossProcessLocked(mutator)
4983 }
4984
4985 func prependTopicInProjectsFile(workspaceRoot, topicID string, ensureProject bool) error {
4986 // Single-topic prepends are intentional writes (topic creation, a live tab
4987 // indexing its session, restore from trash): they clear any delete
4988 // tombstone so the topic fully returns instead of landing in a half-state
4989 // where only its title resurfaces.
4990 return prependTopicsInProjectsFileOpts(workspaceRoot, []string{topicID}, ensureProject, false)
4991 }
4992
4993 func prependTopicsInProjectsFile(workspaceRoot string, topicIDs []string, ensureProject bool) error {
4994 // Batch prepends come from the legacy migration and index-repair scans:
4995 // they must respect delete tombstones so a scan never resurrects a topic
4996 // the user removed.
4997 return prependTopicsInProjectsFileOpts(workspaceRoot, topicIDs, ensureProject, true)
4998 }
4999
5000 func prependTopicsInProjectsFileOpts(workspaceRoot string, topicIDs []string, ensureProject, respectTombstones bool) error {
5001 workspaceRoot = normalizeProjectRoot(workspaceRoot)
5002 topicIDs = uniqueStrings(topicIDs)
5003 if len(topicIDs) == 0 {
5004 return nil
5005 }
5006 return updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
5007 // Tombstones are checked under the projects-file lock: a DeleteTopic
5008 // that lands between a scan reading DeletedTopics and this write must
5009 // not be resurrected by the stale batch.
5010 live := topicIDs
5011 changed := false
5012 if respectTombstones {
5013 live = make([]string, 0, len(topicIDs))
5014 for _, id := range topicIDs {
5015 if !containsDesktopString(f.DeletedTopics, id) {
5016 live = append(live, id)
5017 }
5018 }
5019 if len(live) == 0 {
5020 return false, nil
5021 }
5022 } else {
5023 for _, id := range topicIDs {
5024 if next := removeString(f.DeletedTopics, id); !sameStringList(next, f.DeletedTopics) {
5025 f.DeletedTopics = next
5026 changed = true
5027 }
5028 }
5029 }
5030 if workspaceRoot == "" {
5031 next := uniqueStrings(append(append([]string(nil), live...), f.GlobalTopics...))
5032 if sameStringList(next, f.GlobalTopics) {
5033 return changed, nil
5034 }
5035 f.GlobalTopics = next
5036 return true, nil
5037 }
5038 for i, p := range f.Projects {
5039 if !sameProjectRoot(p.Root, workspaceRoot) {
5040 continue
5041 }
5042 next := uniqueStrings(append(append([]string(nil), live...), p.Topics...))
5043 if sameStringList(next, p.Topics) {
5044 return changed, nil
5045 }
5046 f.Projects[i].Topics = next
5047 return true, nil
5048 }
5049 if !ensureProject {
5050 return changed, nil
5051 }
5052 f.Projects = append(f.Projects, desktopProject{Root: workspaceRoot, Topics: live})
5053 return true, nil
5054 })
5055 }
5056
5057 func removeTopicFromProjectsFile(topicID string) error {
5058 topicID = strings.TrimSpace(topicID)
5059 if topicID == "" {
5060 return nil
5061 }
5062 desktopProjectsFileMu.Lock()
5063 defer desktopProjectsFileMu.Unlock()
5064 return removeTopicFromProjectsFileLocked(topicID)
5065 }
5066
5067 func removeTopicFromProjectsFileLocked(topicID string) error {
5068 release, err := acquireDesktopProjectsFileLock()
5069 if err != nil {
5070 return err
5071 }
5072 defer release()
5073 return removeTopicFromProjectsFileCrossProcessLocked(topicID)
5074 }
5075
5076 func removeTopicFromProjectsFileCrossProcessLocked(topicID string) error {
5077 return updateProjectsFileCrossProcessLocked(func(f *desktopProjectFile) (bool, error) {
5078 changed := false
5079 if next := removeString(f.GlobalTopics, topicID); !sameStringList(next, f.GlobalTopics) {
5080 f.GlobalTopics = next
5081 changed = true
5082 }
5083 if next := removeString(f.GlobalPinnedTopics, topicID); !sameStringList(next, f.GlobalPinnedTopics) {
5084 f.GlobalPinnedTopics = next
5085 changed = true
5086 }
5087 if next, removed := groupsWithoutTopic(f.GlobalGroups, topicID); removed {
5088 f.GlobalGroups = next
5089 f.GlobalGroupsRevision++
5090 changed = true
5091 }
5092 if next := prependUniqueString(f.DeletedTopics, topicID); !sameStringList(next, f.DeletedTopics) {
5093 f.DeletedTopics = next
5094 changed = true
5095 }
5096 for i, p := range f.Projects {
5097 if next := removeString(p.Topics, topicID); !sameStringList(next, p.Topics) {
5098 f.Projects[i].Topics = next
5099 changed = true
5100 }
5101 if next := removeString(p.PinnedTopics, topicID); !sameStringList(next, p.PinnedTopics) {
5102 f.Projects[i].PinnedTopics = next
5103 changed = true
5104 }
5105 if next, removed := groupsWithoutTopic(p.Groups, topicID); removed {
5106 f.Projects[i].Groups = next
5107 f.Projects[i].GroupsRevision++
5108 changed = true
5109 }
5110 }
5111 return changed, nil
5112 })
5113 }
5114
5115 func normalizeProjectRoot(root string) string {
5116 root = strings.TrimSpace(root)
5117 if root == "" {
5118 return ""
5119 }
5120 if abs, err := filepath.Abs(root); err == nil {
5121 return abs
5122 }
5123 return root
5124 }
5125
5126 func sameProjectRoot(a, b string) bool {
5127 return sameDesktopPath(normalizeProjectRoot(a), normalizeProjectRoot(b))
5128 }
5129
5130 func projectIndexByRoot(projects []desktopProject, root string) int {
5131 root = normalizeProjectRoot(root)
5132 if root == "" {
5133 return -1
5134 }
5135 for i, project := range projects {
5136 if sameProjectRoot(project.Root, root) {
5137 return i
5138 }
5139 }
5140 return -1
5141 }
5142
5143 func projectRootInList(roots []string, root string) bool {
5144 root = normalizeProjectRoot(root)
5145 if root == "" {
5146 return false
5147 }
5148 for _, candidate := range roots {
5149 if sameProjectRoot(candidate, root) {
5150 return true
5151 }
5152 }
5153 return false
5154 }
5155
5156 func normalizeProjectsFile(f desktopProjectFile) desktopProjectFile {
5157 out := desktopProjectFile{
5158 GlobalTitle: strings.TrimSpace(f.GlobalTitle),
5159 GlobalColor: normalizeProjectColor(f.GlobalColor),
5160 GlobalTopics: uniqueStrings(f.GlobalTopics),
5161 GlobalPinnedTopics: uniqueStrings(f.GlobalPinnedTopics),
5162 GlobalManualTopicOrder: f.GlobalManualTopicOrder,
5163 GlobalGroups: normalizeGroups(f.GlobalGroups),
5164 GlobalGroupsRevision: f.GlobalGroupsRevision,
5165 DeletedTopics: uniqueStrings(f.DeletedTopics),
5166 }
5167 for _, p := range f.Projects {
5168 root := normalizeProjectRoot(p.Root)
5169 if root == "" {
5170 continue
5171 }
5172 p.Root = root
5173 p.Title = strings.TrimSpace(p.Title)
5174 p.Color = normalizeProjectColor(p.Color)
5175 p.Topics = uniqueStrings(p.Topics)
5176 p.PinnedTopics = uniqueStrings(p.PinnedTopics)
5177 p.Groups = normalizeGroups(p.Groups)
5178 if i := projectIndexByRoot(out.Projects, root); i >= 0 {
5179 if out.Projects[i].Title == "" && p.Title != "" {
5180 out.Projects[i].Title = p.Title
5181 }
5182 if out.Projects[i].Color == "" && p.Color != "" {
5183 out.Projects[i].Color = p.Color
5184 }
5185 out.Projects[i].Topics = uniqueStrings(append(out.Projects[i].Topics, p.Topics...))
5186 out.Projects[i].PinnedTopics = uniqueStrings(append(out.Projects[i].PinnedTopics, p.PinnedTopics...))
5187 out.Projects[i].ManualTopicOrder = out.Projects[i].ManualTopicOrder || p.ManualTopicOrder
5188 out.Projects[i].Groups = mergeDesktopGroups(out.Projects[i].Groups, p.Groups)
5189 out.Projects[i].GroupsRevision = max(out.Projects[i].GroupsRevision, p.GroupsRevision)
5190 continue
5191 }
5192 out.Projects = append(out.Projects, p)
5193 }
5194 for _, root := range uniqueStrings(f.PinnedProjects) {
5195 root = normalizeProjectRoot(root)
5196 if i := projectIndexByRoot(out.Projects, root); i >= 0 && !projectRootInList(out.PinnedProjects, out.Projects[i].Root) {
5197 out.PinnedProjects = append(out.PinnedProjects, out.Projects[i].Root)
5198 }
5199 }
5200 out.SidebarOrder = normalizeSidebarOrder(f.SidebarOrder, out.Projects)
5201 return out
5202 }
5203
5204 func normalizeSidebarOrder(order []string, projects []desktopProject) []string {
5205 seenGlobal := false
5206 // Dedupe roots against a roots-only list: out also holds the global order
5207 // token, which must never be path-compared against project roots.
5208 var seenRoots []string
5209 out := make([]string, 0, len(order))
5210 for _, value := range order {
5211 value = strings.TrimSpace(value)
5212 if value == desktopGlobalOrderToken {
5213 if !seenGlobal {
5214 seenGlobal = true
5215 out = append(out, value)
5216 }
5217 continue
5218 }
5219 root := normalizeProjectRoot(value)
5220 i := projectIndexByRoot(projects, root)
5221 if i < 0 {
5222 continue
5223 }
5224 root = projects[i].Root
5225 if projectRootInList(seenRoots, root) {
5226 continue
5227 }
5228 seenRoots = append(seenRoots, root)
5229 out = append(out, root)
5230 }
5231 return out
5232 }
5233
5234 func sameProjectOrder(a, b []desktopProject) bool {
5235 if len(a) != len(b) {
5236 return false
5237 }
5238 for i := range a {
5239 if a[i].Root != b[i].Root {
5240 return false
5241 }
5242 }
5243 return true
5244 }
5245
5246 func uniqueStrings(values []string) []string {
5247 seen := make(map[string]bool, len(values))
5248 out := make([]string, 0, len(values))
5249 for _, value := range values {
5250 value = strings.TrimSpace(value)
5251 if value == "" || seen[value] {
5252 continue
5253 }
5254 seen[value] = true
5255 out = append(out, value)
5256 }
5257 return out
5258 }
5259
5260 func prependUniqueString(values []string, value string) []string {
5261 value = strings.TrimSpace(value)
5262 if value == "" {
5263 return uniqueStrings(values)
5264 }
5265 return uniqueStrings(append([]string{value}, values...))
5266 }
5267
5268 func removeString(values []string, value string) []string {
5269 value = strings.TrimSpace(value)
5270 if value == "" {
5271 return uniqueStrings(values)
5272 }
5273 out := make([]string, 0, len(values))
5274 for _, item := range uniqueStrings(values) {
5275 if item != value {
5276 out = append(out, item)
5277 }
5278 }
5279 return out
5280 }
5281
5282 func containsDesktopString(values []string, value string) bool {
5283 value = strings.TrimSpace(value)
5284 if value == "" {
5285 return false
5286 }
5287 return slices.Contains(uniqueStrings(values), value)
5288 }
5289
5290 func pinnedTopicIDs(topicIDs []string, pinned []string) []string {
5291 if len(topicIDs) == 0 || len(pinned) == 0 {
5292 return topicIDs
5293 }
5294 available := make(map[string]bool, len(topicIDs))
5295 for _, tid := range topicIDs {
5296 available[tid] = true
5297 }
5298 out := make([]string, 0, len(topicIDs))
5299 seen := make(map[string]bool, len(topicIDs))
5300 for _, tid := range uniqueStrings(pinned) {
5301 if available[tid] && !seen[tid] {
5302 out = append(out, tid)
5303 seen[tid] = true
5304 }
5305 }
5306 for _, tid := range topicIDs {
5307 if !seen[tid] {
5308 out = append(out, tid)
5309 }
5310 }
5311 return out
5312 }
5313
5314 func orderedTopicIDs(explicit []string, titleMap map[string]string) []string {
5315 seen := map[string]bool{}
5316 out := make([]string, 0, len(explicit)+len(titleMap))
5317 for _, tid := range explicit {
5318 tid = strings.TrimSpace(tid)
5319 if tid == "" || seen[tid] {
5320 continue
5321 }
5322 seen[tid] = true
5323 out = append(out, tid)
5324 }
5325 var remaining []string
5326 for tid := range titleMap {
5327 if !seen[tid] {
5328 remaining = append(remaining, tid)
5329 }
5330 }
5331 sort.Strings(remaining)
5332 return append(out, remaining...)
5333 }
5334
5335 func projectTreeOrderKey(node ProjectNode) string {
5336 switch node.Kind {
5337 case "global_folder":
5338 return desktopGlobalOrderToken
5339 case "project":
5340 return normalizeProjectRoot(node.Root)
5341 default:
5342 return ""
5343 }
5344 }
5345
5346 func applyProjectTreeOrder(nodes []ProjectNode, order []string) []ProjectNode {
5347 if len(order) == 0 {
5348 return nodes
5349 }
5350 byKey := make(map[string]ProjectNode, len(nodes))
5351 for _, node := range nodes {
5352 key := projectTreeOrderKey(node)
5353 if key != "" {
5354 byKey[key] = node
5355 }
5356 }
5357 seen := make(map[string]bool, len(nodes))
5358 out := make([]ProjectNode, 0, len(nodes))
5359 for _, value := range order {
5360 key := strings.TrimSpace(value)
5361 if key != desktopGlobalOrderToken {
5362 key = normalizeProjectRoot(key)
5363 }
5364 if key == "" || seen[key] {
5365 continue
5366 }
5367 node, ok := byKey[key]
5368 if !ok {
5369 continue
5370 }
5371 seen[key] = true
5372 out = append(out, node)
5373 }
5374 for _, node := range nodes {
5375 key := projectTreeOrderKey(node)
5376 if key != "" && seen[key] {
5377 continue
5378 }
5379 if key != "" {
5380 seen[key] = true
5381 }
5382 out = append(out, node)
5383 }
5384 return out
5385 }
5386
5387 func applyPinnedProjectOrder(nodes []ProjectNode, pinnedRoots []string) []ProjectNode {
5388 pinnedRoots = uniqueStrings(pinnedRoots)
5389 if len(pinnedRoots) == 0 {
5390 return nodes
5391 }
5392 byRoot := make(map[string]ProjectNode, len(nodes))
5393 for _, node := range nodes {
5394 if node.Kind == "project" && node.Root != "" {
5395 byRoot[normalizeProjectRoot(node.Root)] = node
5396 }
5397 }
5398 seen := make(map[string]bool, len(pinnedRoots))
5399 out := make([]ProjectNode, 0, len(nodes))
5400 for _, root := range pinnedRoots {
5401 root = normalizeProjectRoot(root)
5402 node, ok := byRoot[root]
5403 if !ok || seen[root] {
5404 continue
5405 }
5406 seen[root] = true
5407 out = append(out, node)
5408 }
5409 for _, node := range nodes {
5410 if node.Kind == "project" && node.Root != "" && seen[normalizeProjectRoot(node.Root)] {
5411 continue
5412 }
5413 out = append(out, node)
5414 }
5415 return out
5416 }
5417
5418 func projectDisplayName(p desktopProject) string {
5419 if title := strings.TrimSpace(p.Title); title != "" {
5420 return title
5421 }
5422 return workspaceName(p.Root)
5423 }
5424
5425 func normalizeProjectColor(color string) string {
5426 switch strings.TrimSpace(strings.ToLower(color)) {
5427 case "red", "orange", "amber", "green", "teal", "blue", "purple", "pink":
5428 return strings.TrimSpace(strings.ToLower(color))
5429 default:
5430 return ""
5431 }
5432 }
5433
5434 func projectColor(root string) string {
5435 root = normalizeProjectRoot(root)
5436 if root == "" {
5437 return globalProjectColor()
5438 }
5439 for _, p := range loadProjectsFile().Projects {
5440 if sameProjectRoot(p.Root, root) {
5441 return normalizeProjectColor(p.Color)
5442 }
5443 }
5444 return ""
5445 }
5446
5447 func globalProjectColor() string {
5448 return normalizeProjectColor(loadProjectsFile().GlobalColor)
5449 }
5450
5451 func globalProjectTitle() string {
5452 if title := strings.TrimSpace(loadProjectsFile().GlobalTitle); title != "" {
5453 return title
5454 }
5455 return "Global"
5456 }
5457
5458 func addProject(root, title string) error {
5459 root = normalizeProjectRoot(root)
5460 if root == "" {
5461 return fmt.Errorf("project root is required")
5462 }
5463 title = strings.TrimSpace(title)
5464 return updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
5465 for i, p := range f.Projects {
5466 if sameProjectRoot(p.Root, root) {
5467 changed := false
5468 if f.Projects[i].Root != root {
5469 f.Projects[i].Root = root
5470 changed = true
5471 }
5472 if title != "" && f.Projects[i].Title != title {
5473 f.Projects[i].Title = title
5474 changed = true
5475 }
5476 if !changed {
5477 return false, nil
5478 }
5479 return true, nil
5480 }
5481 }
5482 f.Projects = append(f.Projects, desktopProject{Root: root, Title: title})
5483 return true, nil
5484 })
5485 }
5486
5487 func renameProject(root, title string) error {
5488 title = strings.TrimSpace(title)
5489 root = normalizeProjectRoot(root)
5490 return updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
5491 if root == "" {
5492 if f.GlobalTitle == title {
5493 return false, nil
5494 }
5495 f.GlobalTitle = title
5496 return true, nil
5497 }
5498 for i, p := range f.Projects {
5499 if sameProjectRoot(p.Root, root) {
5500 if f.Projects[i].Root == root && f.Projects[i].Title == title {
5501 return false, nil
5502 }
5503 f.Projects[i].Root = root
5504 f.Projects[i].Title = title
5505 return true, nil
5506 }
5507 }
5508 f.Projects = append(f.Projects, desktopProject{Root: root, Title: title})
5509 return true, nil
5510 })
5511 }
5512
5513 func setProjectColor(root, color string) error {
5514 root = normalizeProjectRoot(root)
5515 color = normalizeProjectColor(color)
5516 return updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
5517 if root == "" {
5518 if f.GlobalColor == color {
5519 return false, nil
5520 }
5521 f.GlobalColor = color
5522 return true, nil
5523 }
5524 for i, p := range f.Projects {
5525 if sameProjectRoot(p.Root, root) {
5526 if f.Projects[i].Root == root && f.Projects[i].Color == color {
5527 return false, nil
5528 }
5529 f.Projects[i].Root = root
5530 f.Projects[i].Color = color
5531 return true, nil
5532 }
5533 }
5534 f.Projects = append(f.Projects, desktopProject{Root: root, Color: color})
5535 return true, nil
5536 })
5537 }
5538
5539 func removeProject(root string) error {
5540 root = normalizeProjectRoot(root)
5541 return updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
5542 projects := make([]desktopProject, 0, len(f.Projects))
5543 for _, p := range f.Projects {
5544 if !sameProjectRoot(p.Root, root) {
5545 projects = append(projects, p)
5546 }
5547 }
5548 if len(projects) == len(f.Projects) {
5549 return false, nil
5550 }
5551 f.Projects = projects
5552 return true, nil
5553 })
5554 }
5555
5556 // topic helpers
5557
5558 const (
5559 topicTitlesFile = "desktop-topic-titles.json"
5560 topicTitleSourcesFile = "desktop-topic-title-sources.json"
5561 topicCreatedAtsFile = "desktop-topic-created-at.json"
5562 topicAutoTitlesFile = "desktop-topic-auto-title-meta.json"
5563 defaultTopicTitle = "新的会话"
5564 defaultTopicTitleEn = "New session"
5565 defaultTopicTitleZhTW = "新的會話"
5566 topicTitleSourceAuto = "auto"
5567 topicTitleSourceManual = "manual"
5568 )
5569
5570 const (
5571 desktopLocaleUnknown int32 = iota
5572 desktopLocaleEn
5573 desktopLocaleZh
5574 desktopLocaleZhTW
5575 )
5576
5577 func (a *App) setDesktopLocale(locale string) {
5578 normalized := strings.ToLower(strings.TrimSpace(locale))
5579 switch {
5580 case strings.HasPrefix(normalized, "zh-tw"), strings.HasPrefix(normalized, "zh-hant"):
5581 a.desktopLocale.Store(desktopLocaleZhTW)
5582 case strings.HasPrefix(normalized, "zh"):
5583 a.desktopLocale.Store(desktopLocaleZh)
5584 default:
5585 a.desktopLocale.Store(desktopLocaleEn)
5586 }
5587 }
5588
5589 func (a *App) localizedDefaultTopicTitle() string {
5590 switch a.desktopLocale.Load() {
5591 case desktopLocaleZh:
5592 return defaultTopicTitle
5593 case desktopLocaleZhTW:
5594 return defaultTopicTitleZhTW
5595 case desktopLocaleEn:
5596 return defaultTopicTitleEn
5597 default:
5598 return defaultTopicTitle
5599 }
5600 }
5601
5602 func isDefaultTopicTitle(title string) bool {
5603 switch strings.TrimSpace(title) {
5604 case "", defaultTopicTitle, defaultTopicTitleEn, defaultTopicTitleZhTW,
5605 "新建会话", "新建會話", "新会话":
5606 return true
5607 default:
5608 return false
5609 }
5610 }
5611
5612 func (a *App) localizedTopicTitle(title, source string) string {
5613 if strings.TrimSpace(source) == topicTitleSourceAuto && isDefaultTopicTitle(title) {
5614 return a.localizedDefaultTopicTitle()
5615 }
5616 return title
5617 }
5618
5619 const topicFileReadTimeout = 200 * time.Millisecond
5620
5621 var readFileWithTimeoutSlots = make(chan struct{}, 16)
5622
5623 func readFileWithTimeout(path string, timeout time.Duration) ([]byte, error) {
5624 if timeout <= 0 {
5625 return readFileUTF8(path)
5626 }
5627 select {
5628 case readFileWithTimeoutSlots <- struct{}{}:
5629 default:
5630 return nil, fmt.Errorf("too many pending file reads")
5631 }
5632 type result struct {
5633 data []byte
5634 err error
5635 }
5636 ch := make(chan result, 1)
5637 go func() {
5638 data, err := readFileUTF8(path)
5639 <-readFileWithTimeoutSlots
5640 ch <- result{data: data, err: err}
5641 }()
5642 timer := time.NewTimer(timeout)
5643 defer timer.Stop()
5644 select {
5645 case r := <-ch:
5646 return r.data, r.err
5647 case <-timer.C:
5648 return nil, fmt.Errorf("timed out after %v reading %s", timeout, filepath.Base(path))
5649 }
5650 }
5651
5652 type topicAutoTitleMeta struct {
5653 Stage int `json:"stage,omitempty"`
5654 UserTurns int `json:"userTurns,omitempty"`
5655 BasisHash string `json:"basisHash,omitempty"`
5656 UpdatedAt int64 `json:"updatedAt,omitempty"`
5657 }
5658
5659 func loadStringMapForUpdate(path string) (map[string]string, error) {
5660 m := map[string]string{}
5661 b, err := readFileUTF8(path)
5662 if err != nil {
5663 if errors.Is(err, os.ErrNotExist) {
5664 return m, nil
5665 }
5666 return nil, err
5667 }
5668 if err := json.Unmarshal(b, &m); err != nil || m == nil {
5669 return map[string]string{}, nil
5670 }
5671 return m, nil
5672 }
5673
5674 func loadTopicTitlesForUpdate(workspaceRoot string) (map[string]string, error) {
5675 snapshot, err := desktopTopicState.snapshot(workspaceRoot)
5676 if err != nil {
5677 if !legacyTopicFilesExist(workspaceRoot) {
5678 return nil, err
5679 }
5680 legacy, legacyErr := loadLegacyStringMap(topicTitlesPath(workspaceRoot))
5681 if legacyErr != nil {
5682 return nil, errors.Join(err, legacyErr)
5683 }
5684 return legacy, nil
5685 }
5686 values := make(map[string]string, len(snapshot.Records))
5687 for id, record := range snapshot.Records {
5688 if record.Title != "" {
5689 values[id] = agent.UserPreviewText(record.Title)
5690 }
5691 }
5692 return values, nil
5693 }
5694
5695 func loadTopicTitleSourcesForUpdate(workspaceRoot string) (map[string]string, error) {
5696 snapshot, err := desktopTopicState.snapshot(workspaceRoot)
5697 if err != nil {
5698 if !legacyTopicFilesExist(workspaceRoot) {
5699 return nil, err
5700 }
5701 legacy, legacyErr := loadLegacyStringMap(topicTitleSourcesPath(workspaceRoot))
5702 if legacyErr != nil {
5703 return nil, errors.Join(err, legacyErr)
5704 }
5705 return legacy, nil
5706 }
5707 values := make(map[string]string, len(snapshot.Records))
5708 for id, record := range snapshot.Records {
5709 if record.TitleSource != "" {
5710 values[id] = record.TitleSource
5711 }
5712 }
5713 return values, nil
5714 }
5715
5716 func saveTopicTitles(workspaceRoot string, m map[string]string) error {
5717 return desktopTopicState.replaceTitles(workspaceRoot, m)
5718 }
5719
5720 func saveTopicTitleSources(workspaceRoot string, m map[string]string) error {
5721 return desktopTopicState.replaceSources(workspaceRoot, m)
5722 }
5723
5724 func saveTopicCreatedAts(workspaceRoot string, m map[string]int64) error {
5725 return desktopTopicState.replaceCreatedAts(workspaceRoot, m)
5726 }
5727
5728 func loadTopicTitle(workspaceRoot, topicID string) string {
5729 return loadTopicTitles(workspaceRoot)[topicID]
5730 }
5731
5732 func loadTopicTitleSource(workspaceRoot, topicID string) string {
5733 return loadTopicTitleSources(workspaceRoot)[topicID]
5734 }
5735
5736 func loadTopicCreatedAt(workspaceRoot, topicID string) int64 {
5737 return loadTopicCreatedAts(workspaceRoot)[topicID]
5738 }
5739
5740 func topicIDCreatedAt(topicID string) int64 {
5741 topicID = strings.TrimSpace(topicID)
5742 for _, prefix := range []string{"topic_", "legacy_"} {
5743 if !strings.HasPrefix(topicID, prefix) {
5744 continue
5745 }
5746 stamp := strings.TrimPrefix(topicID, prefix)
5747 if len(stamp) < len("20060102-150405") {
5748 continue
5749 }
5750 stamp = stamp[:len("20060102-150405")]
5751 t, err := time.ParseInLocation("20060102-150405", stamp, time.UTC)
5752 if err != nil {
5753 continue
5754 }
5755 return t.UnixMilli()
5756 }
5757 return 0
5758 }
5759
5760 func topicCreatedAtForTree(createdAts map[string]int64, topicID string) int64 {
5761 if createdAt := createdAts[topicID]; createdAt > 0 {
5762 return createdAt
5763 }
5764 return topicIDCreatedAt(topicID)
5765 }
5766
5767 func topicTitleForTab(scope, workspaceRoot, topicID string) string {
5768 titleRoot := topicTitleRoot(scope, workspaceRoot)
5769 if title := strings.TrimSpace(loadTopicTitle(titleRoot, topicID)); title != "" {
5770 return title
5771 }
5772 if scope == "global" {
5773 return "Global"
5774 }
5775 return defaultTopicTitle
5776 }
5777
5778 func topicTitleRoot(scope, workspaceRoot string) string {
5779 if scope == "global" {
5780 return ""
5781 }
5782 return workspaceRoot
5783 }
5784
5785 func (a *App) forkTopicTitle(title string) string {
5786 base := strings.TrimSpace(title)
5787 if base == "" || isDefaultTopicTitle(base) {
5788 switch a.desktopLocale.Load() {
5789 case desktopLocaleEn:
5790 base = defaultTopicTitleEn
5791 case desktopLocaleZhTW:
5792 base = defaultTopicTitleZhTW
5793 default:
5794 base = defaultTopicTitle
5795 }
5796 }
5797 return sessiontitle.IncreaseFork(base)
5798 }
5799
5800 type sessionRecoveryEvent struct {
5801 ConversationID string `json:"conversationId,omitempty"`
5802 ActiveVersionID string `json:"activeVersionId,omitempty"`
5803 RecoveryVersionID string `json:"recoveryVersionId,omitempty"`
5804 OriginalPath string `json:"originalPath,omitempty"`
5805 RecoveryPath string `json:"recoveryPath"`
5806 Scope string `json:"scope,omitempty"`
5807 WorkspaceRoot string `json:"workspaceRoot,omitempty"`
5808 TopicID string `json:"topicId,omitempty"`
5809 TopicTitle string `json:"topicTitle,omitempty"`
5810 RecoveryReason string `json:"recoveryReason,omitempty"`
5811 RecoveryDigest string `json:"recoveryDigest,omitempty"`
5812 RecoveryParentID string `json:"recoveryParentId,omitempty"`
5813 Existing bool `json:"existing,omitempty"`
5814 BaseRevision int64 `json:"baseRevision,omitempty"`
5815 DiskRevision int64 `json:"diskRevision,omitempty"`
5816 CanContinue bool `json:"canContinue"`
5817 RequiresChoice bool `json:"requiresChoice"`
5818 }
5819
5820 type sessionRecoveryFailedEvent struct {
5821 Reason string `json:"reason,omitempty"`
5822 ConversationID string `json:"conversationId,omitempty"`
5823 TopicID string `json:"topicId,omitempty"`
5824 RecoveryPath string `json:"recoveryPath,omitempty"`
5825 WorkspaceRoot string `json:"workspaceRoot,omitempty"`
5826 CanContinue bool `json:"canContinue"`
5827 RecoveryPending bool `json:"recoveryPending"`
5828 }
5829
5830 func (a *App) tabSessionRecoveryMeta(tab *WorkspaceTab) func(control.SessionRecoveryRequest) agent.BranchMeta {
5831 return func(req control.SessionRecoveryRequest) agent.BranchMeta {
5832 if tab == nil {
5833 return agent.BranchMeta{Name: agent.RecoveryBranchDefaultName}
5834 }
5835 // This runs on the snapshot-recovery path, which can fire from the
5836 // controller's autosave goroutine; snapshot the tab fields under a.mu so
5837 // we don't read them mid-mutation. Recovery callbacks never hold a.mu, so
5838 // taking it here can't deadlock. Controller reads happen off-lock.
5839 a.mu.RLock()
5840 ctrl := tab.Ctrl
5841 scope := strings.TrimSpace(tab.Scope)
5842 workspaceRoot := strings.TrimSpace(tab.WorkspaceRoot)
5843 topicID := tab.TopicID
5844 topicTitle := tab.TopicTitle
5845 model := strings.TrimSpace(tab.model)
5846 tokenMode := boot.TokenModeFull // deprecated dual-write compat value
5847 qualityFloor := strings.TrimSpace(tab.qualityFloor)
5848 mode := normalizeTabMode(tab.mode)
5849 toolApprovalMode := normalizeToolApprovalMode(tab.toolApprovalMode)
5850 goal := strings.TrimSpace(tab.goal)
5851 a.mu.RUnlock()
5852 if ctrl != nil {
5853 mode = tabModeFromAxes(ctrl.PlanMode(), ctrl.AutoApproveTools())
5854 toolApprovalMode = normalizeToolApprovalMode(ctrl.ToolApprovalMode())
5855 if g := strings.TrimSpace(ctrl.Goal()); g != "" && ctrl.GoalStatus() == control.GoalStatusRunning {
5856 goal = g
5857 } else {
5858 goal = ""
5859 }
5860 }
5861 if scope != "project" {
5862 scope = "global"
5863 }
5864 if scope == "global" {
5865 workspaceRoot = ""
5866 }
5867 return agent.BranchMeta{
5868 Name: agent.RecoveryBranchDefaultName,
5869 Scope: scope,
5870 WorkspaceRoot: workspaceRoot,
5871 TopicID: topicID,
5872 TopicTitle: topicTitle,
5873 Model: model,
5874 AgentPreset: currentTabAgentPreset(&WorkspaceTab{qualityFloor: qualityFloor}),
5875 QualityFloor: control.QualityFloorStandard,
5876 TokenMode: tokenMode,
5877 Mode: persistedTabMode(mode),
5878 ToolApprovalMode: persistedToolApprovalMode(toolApprovalMode),
5879 Goal: goal,
5880 }
5881 }
5882 }
5883
5884 // emitSessionRecoveredAndRefresh registers the frontend pending item before a
5885 // catalog reconcile can publish the revision that classifies it.
5886 func (a *App) emitSessionRecoveredAndRefresh(dir string, recovered sessionRecoveryEvent) {
5887 a.emitRuntimeEvent("session:recovered", recovered)
5888 a.emitProjectTreeChangedForSessionDirs(dir)
5889 }
5890
5891 func setTopicTitle(workspaceRoot, topicID, title string) error {
5892 return setTopicTitleWithSource(workspaceRoot, topicID, title, topicTitleSourceManual)
5893 }
5894
5895 func setTopicTitleWithSource(workspaceRoot, topicID, title, source string) error {
5896 return desktopTopicState.setTitle(workspaceRoot, topicID, title, source)
5897 }
5898
5899 func createTopicState(workspaceRoot, topicID, title, source string, createdAt int64) error {
5900 return desktopTopicState.createTopic(workspaceRoot, topicID, title, source, createdAt)
5901 }
5902
5903 func recordTopicAutoTitleMeta(workspaceRoot, topicID string, proposal autoTopicTitleProposal) error {
5904 topicID = strings.TrimSpace(topicID)
5905 if topicID == "" || proposal.Stage <= 0 || proposal.BasisHash == "" {
5906 return nil
5907 }
5908 value := topicAutoTitleMeta{
5909 Stage: proposal.Stage,
5910 UserTurns: proposal.UserTurns,
5911 BasisHash: proposal.BasisHash,
5912 UpdatedAt: time.Now().UnixMilli(),
5913 }
5914 return desktopTopicState.setAutoMeta(workspaceRoot, topicID, &value)
5915 }
5916
5917 func applyAutoTopicTitle(workspaceRoot, topicID, title string, proposal autoTopicTitleProposal) (bool, error) {
5918 topicID = strings.TrimSpace(topicID)
5919 if topicID == "" || proposal.Stage <= 0 || proposal.BasisHash == "" {
5920 return false, nil
5921 }
5922 return desktopTopicState.applyAutoTitle(workspaceRoot, topicID, title, topicAutoTitleMeta{
5923 Stage: proposal.Stage, UserTurns: proposal.UserTurns,
5924 BasisHash: proposal.BasisHash, UpdatedAt: time.Now().UnixMilli(),
5925 })
5926 }
5927
5928 func deleteTopicAutoTitleMeta(workspaceRoot, topicID string) error {
5929 topicID = strings.TrimSpace(topicID)
5930 if topicID == "" {
5931 return nil
5932 }
5933 return desktopTopicState.setAutoMeta(workspaceRoot, topicID, nil)
5934 }
5935
5936 func setTopicCreatedAt(workspaceRoot, topicID string, createdAt int64) error {
5937 return desktopTopicState.setCreatedAt(workspaceRoot, topicID, createdAt)
5938 }
5939
5940 func deleteTopicState(workspaceRoot, topicID string) error {
5941 return desktopTopicState.delete(workspaceRoot, topicID)
5942 }
5943
5944 // topicIndexMu serializes recovery writes to desktop-projects.json and topic
5945 // title indexes. Startup builds restored tabs concurrently, and each tab may
5946 // repair its missing index.
5947 var topicIndexMu sync.Mutex
5948
5949 // topicAutoTitleCommittedHookForTest pauses between the authoritative auto
5950 // title commit and its in-memory/session publication. Production leaves it nil.
5951 var topicAutoTitleCommittedHookForTest func()
5952
5953 func ensureTopicIndexed(scope, workspaceRoot, topicID, title, source string) error {
5954 return ensureTopicIndexedState(scope, workspaceRoot, topicID, title, source, 0)
5955 }
5956
5957 func ensureTopicIndexedWithCreatedAt(scope, workspaceRoot, topicID, title, source string, createdAt int64) error {
5958 return ensureTopicIndexedState(scope, workspaceRoot, topicID, title, source, createdAt)
5959 }
5960
5961 func ensureTopicIndexedState(scope, workspaceRoot, topicID, title, source string, createdAt int64) error {
5962 topicID = strings.TrimSpace(topicID)
5963 if topicID == "" {
5964 return fmt.Errorf("topicID is required")
5965 }
5966 topicIndexMu.Lock()
5967 defer topicIndexMu.Unlock()
5968 if strings.TrimSpace(scope) == "global" {
5969 workspaceRoot = ""
5970 } else {
5971 workspaceRoot = normalizeProjectRoot(workspaceRoot)
5972 }
5973 title = strings.TrimSpace(title)
5974 if title == "" {
5975 title = defaultTopicTitle
5976 }
5977 source = strings.TrimSpace(source)
5978 if source == "" {
5979 source = topicTitleSourceManual
5980 }
5981 wasDeleted := containsDesktopString(loadProjectsFile().DeletedTopics, topicID)
5982 if wasDeleted {
5983 // A migrated scope prunes tombstoned SQLite rows before mirroring. Clear
5984 // the tombstone first for an explicit restore; if the authoritative state
5985 // write then fails, restore the tombstone so the topic cannot be half shown.
5986 if err := prependTopicInProjectsFile(workspaceRoot, topicID, true); err != nil {
5987 return err
5988 }
5989 }
5990 var err error
5991 if createdAt > 0 {
5992 err = createTopicState(workspaceRoot, topicID, title, source, createdAt)
5993 } else {
5994 err = setTopicTitleWithSource(workspaceRoot, topicID, title, source)
5995 }
5996 if err != nil {
5997 if wasDeleted {
5998 if rollbackErr := removeTopicFromProjectsFile(topicID); rollbackErr != nil {
5999 return errors.Join(err, fmt.Errorf("restore topic tombstone: %w", rollbackErr))
6000 }
6001 }
6002 return err
6003 }
6004 if wasDeleted {
6005 return nil
6006 }
6007 return prependTopicInProjectsFile(workspaceRoot, topicID, true)
6008 }
6009
6010 // telemetry
6011
6012 func saveTelemetry(path string, snapshot tabTelemetrySnapshot) error {
6013 if snapshot.Version == 0 {
6014 snapshot.Version = 3
6015 }
6016 if snapshot.ReadFiles == nil {
6017 snapshot.ReadFiles = []readFileRecord{}
6018 }
6019 b, err := json.MarshalIndent(snapshot, "", " ")
6020 if err != nil {
6021 return err
6022 }
6023 tmp := path + ".tmp"
6024 if err := os.WriteFile(tmp, b, 0o644); err != nil {
6025 return err
6026 }
6027 return fileutil.ReplaceFile(tmp, path)
6028 }
6029
6030 func loadTelemetry(path string) tabTelemetrySnapshot {
6031 b, err := readFileUTF8(path)
6032 if err != nil {
6033 return tabTelemetrySnapshot{Version: 3, ReadFiles: []readFileRecord{}}
6034 }
6035 var snapshot tabTelemetrySnapshot
6036 if err := json.Unmarshal(b, &snapshot); err == nil && (snapshot.Version > 0 || snapshot.ReadFiles != nil) {
6037 if snapshot.ReadFiles == nil {
6038 snapshot.ReadFiles = []readFileRecord{}
6039 }
6040 if snapshot.Usage.SessionCost == 0 && snapshot.Usage.SessionCostUsd > 0 {
6041 snapshot.Usage.SessionCost = snapshot.Usage.SessionCostUsd
6042 }
6043 // Lazy-migrate pre-CostQuote telemetry: keep original amount, mark legacy.
6044 // Never reconstruct wiped mixed-currency zeros from current price tables.
6045 if snapshot.Version < 3 && snapshot.Usage.CostLedger == nil && snapshot.Usage.SessionCost > 0 {
6046 q := billing.MigrateLegacyUsage(billing.LegacyUsageRecord{
6047 SessionCost: snapshot.Usage.SessionCost,
6048 SessionCurrency: snapshot.Usage.SessionCurrency,
6049 EndedAt: time.Now().UTC(),
6050 })
6051 ledger := billing.NewLedger()
6052 ledger.Add(q, billing.UsageTokens{
6053 PromptTokens: snapshot.Usage.PromptTokens,
6054 CompletionTokens: snapshot.Usage.CompletionTokens,
6055 }, time.Now().UTC())
6056 snapshot.Usage.CostLedger = ledger
6057 total := ledger.Total(billing.NormalizeCurrency(snapshot.Usage.SessionCurrency))
6058 snapshot.Usage.SessionCostQuote = &total
6059 snapshot.Usage.SessionCostComplete = total.Complete
6060 snapshot.Version = 3
6061 } else if snapshot.Version < 3 && snapshot.Usage.SessionCost <= 0 && strings.TrimSpace(snapshot.Usage.SessionCurrency) != "" {
6062 // Explicit zero with currency: prior mixed-currency wipe — mark incomplete.
6063 q := billing.MigrateLegacyUsage(billing.LegacyUsageRecord{
6064 SessionCost: 0,
6065 SessionCurrency: snapshot.Usage.SessionCurrency,
6066 })
6067 snapshot.Usage.SessionCostQuote = &q
6068 snapshot.Usage.SessionCostComplete = false
6069 snapshot.Version = 3
6070 } else if snapshot.Version < 3 {
6071 snapshot.Version = 3
6072 }
6073 return snapshot
6074 }
6075 var records []readFileRecord
6076 if err := json.Unmarshal(b, &records); err != nil || records == nil {
6077 records = []readFileRecord{}
6078 }
6079 return tabTelemetrySnapshot{Version: 1, ReadFiles: records}
6080 }
6081
6082 // project tree
6083
6084 func normalizeTopicStatus(status string) string {
6085 switch status {
6086 case topicStatusThinking, topicStatusStreaming, topicStatusWaitingConfirmation, topicStatusBackgroundJob, topicStatusPaused, topicStatusAwaitingDelivery, topicStatusError, topicStatusDivergedRecovery:
6087 return status
6088 default:
6089 return ""
6090 }
6091 }
6092
6093 func legacySessionMetaMatchesMigrationTarget(meta agent.BranchMeta, scope, workspaceRoot string) bool {
6094 if strings.TrimSpace(meta.TopicID) != "" {
6095 return false
6096 }
6097 return legacySessionScopeMatchesMigrationTarget(meta, scope, workspaceRoot)
6098 }
6099
6100 func legacySessionScopeMatchesMigrationTarget(meta agent.BranchMeta, scope, workspaceRoot string) bool {
6101 metaScope := strings.TrimSpace(meta.Scope)
6102 if metaScope != "" && metaScope != scope {
6103 return false
6104 }
6105 metaRoot := normalizeProjectRoot(meta.WorkspaceRoot)
6106 if scope == "project" {
6107 return metaRoot == "" || sameProjectRoot(workspaceRoot, metaRoot)
6108 }
6109 return metaRoot == "" || sameProjectRoot(globalWorkspaceRoot(), metaRoot)
6110 }
6111
6112 func restoreSessionTopicIndex(dir, sessionPath string) error {
6113 sessionPath = strings.TrimSpace(sessionPath)
6114 if sessionPath == "" {
6115 return nil
6116 }
6117 meta, ok, err := agent.LoadBranchMeta(sessionPath)
6118 if err != nil {
6119 return err
6120 }
6121 if !ok || strings.TrimSpace(meta.TopicID) == "" {
6122 // The migration pass takes per-session meta locks itself, so it must
6123 // run outside the lock taken below.
6124 migrateLegacySessionsIntoGlobalTopics(dir)
6125 return nil
6126 }
6127
6128 // Read-modify-write on the branch-meta sidecar: re-read and save under the
6129 // per-path meta lock so a concurrent save's revision bump can't land in
6130 // between and get rolled back by the write at the end.
6131 unlock, err := agent.LockSessionMetaPath(sessionPath)
6132 if err != nil {
6133 return err
6134 }
6135 defer unlock()
6136 meta, ok, err = agent.LoadBranchMeta(sessionPath)
6137 if err != nil {
6138 return err
6139 }
6140 if !ok || strings.TrimSpace(meta.TopicID) == "" {
6141 return nil
6142 }
6143
6144 topicID := strings.TrimSpace(meta.TopicID)
6145 scope := strings.TrimSpace(meta.Scope)
6146 workspaceRoot := strings.TrimSpace(meta.WorkspaceRoot)
6147 if scope != "global" && scope != "project" {
6148 if workspaceRoot == "" {
6149 scope = "global"
6150 } else {
6151 scope = "project"
6152 }
6153 }
6154 if scope == "global" {
6155 workspaceRoot = ""
6156 } else {
6157 workspaceRoot = normalizeProjectRoot(workspaceRoot)
6158 if workspaceRoot == "" {
6159 scope = "global"
6160 }
6161 }
6162
6163 title := restoredSessionTopicTitle(dir, sessionPath, meta)
6164 if title == "" {
6165 title = defaultTopicTitle
6166 }
6167 if err := ensureTopicIndexed(scope, workspaceRoot, topicID, title, topicTitleSourceManual); err != nil {
6168 return err
6169 }
6170
6171 if scope == "global" {
6172 meta.Scope = "global"
6173 meta.WorkspaceRoot = ""
6174 } else {
6175 meta.Scope = "project"
6176 meta.WorkspaceRoot = workspaceRoot
6177 }
6178 meta.TopicID = topicID
6179 meta.TopicTitle = title
6180 if err := agent.SaveBranchMetaPreserveUpdatedLocked(sessionPath, meta); err != nil {
6181 return err
6182 }
6183 invalidateTopicSessionIndexForPath(sessionPath)
6184 return nil
6185 }
6186
6187 func restoredSessionTopicTitle(dir, sessionPath string, meta agent.BranchMeta) string {
6188 if title := storedSessionTopicTitle(dir, sessionPath, meta); title != "" {
6189 return title
6190 }
6191 if s, err := agent.LoadSession(sessionPath); err == nil {
6192 for _, msg := range s.Messages {
6193 if agent.IsUserAuthoredTurnMessage(msg) {
6194 if title := topicTitleFromText(agent.UserMessageText(msg)); title != "" {
6195 return title
6196 }
6197 }
6198 }
6199 }
6200 return ""
6201 }
6202
6203 func storedSessionTopicTitle(dir, sessionPath string, meta agent.BranchMeta) string {
6204 if title := topicTitleFromText(meta.TopicTitle); title != "" {
6205 return title
6206 }
6207 return topicTitleFromText(loadSessionTitles(dir)[filepath.Base(sessionPath)])
6208 }
6209
6210 func legacySessionTopicID(path string) string {
6211 id := agent.BranchID(path)
6212 id = strings.TrimSpace(id)
6213 if id == "" {
6214 return ""
6215 }
6216 sum := sha256.Sum256([]byte(id))
6217 var b strings.Builder
6218 b.WriteString("legacy_")
6219 for _, r := range id {
6220 switch {
6221 case unicode.IsLetter(r), unicode.IsDigit(r):
6222 b.WriteRune(r)
6223 case r == '-', r == '_':
6224 b.WriteRune(r)
6225 default:
6226 b.WriteByte('_')
6227 }
6228 }
6229 prefix := strings.TrimRight(b.String(), "_")
6230 if prefix == "legacy" {
6231 prefix = "legacy_session"
6232 }
6233 return prefix + "_" + hex.EncodeToString(sum[:])[:12]
6234 }
6235
6236 // TopicMeta describes a topic for the project tree.
6237 type TopicMeta struct {
6238 ID string `json:"id"`
6239 Title string `json:"title"`
6240 CreatedAt int64 `json:"createdAt"`
6241 }
6242
6243 // CreateTopic creates a new topic under a project workspace and returns its metadata.
6244 func (a *App) CreateTopic(scope, workspaceRoot, title string) (TopicMeta, error) {
6245 trimmedTitle := strings.TrimSpace(title)
6246 titleSource := topicTitleSourceManual
6247 if trimmedTitle == "" {
6248 trimmedTitle = defaultTopicTitle
6249 titleSource = topicTitleSourceAuto
6250 }
6251 topicID := newTopicID()
6252 createdAt := time.Now().UnixMilli()
6253 if scope == "global" {
6254 workspaceRoot = ""
6255 }
6256 if workspaceRoot != "" {
6257 if abs, err := filepath.Abs(workspaceRoot); err == nil {
6258 workspaceRoot = abs
6259 }
6260 }
6261 releaseAdmission, err := a.beginProjectRuntimeAdmission(scope, workspaceRoot)
6262 if err != nil {
6263 return TopicMeta{}, err
6264 }
6265 defer releaseAdmission()
6266 if err := createTopicState(workspaceRoot, topicID, trimmedTitle, titleSource, createdAt); err != nil {
6267 return TopicMeta{}, err
6268 }
6269 // New topics should appear first in their project/global group so the item
6270 // just created is immediately visible and selected in the sidebar.
6271 _ = prependTopicInProjectsFile(workspaceRoot, topicID, workspaceRoot != "")
6272 a.emitProjectTreeMetadataChanged()
6273 return TopicMeta{ID: topicID, Title: a.localizedTopicTitle(trimmedTitle, titleSource), CreatedAt: createdAt}, nil
6274 }
6275
6276 // RenameProject updates the sidebar-only display title for a project folder.
6277 // Empty title clears the override and falls back to the folder name.
6278 func (a *App) RenameProject(workspaceRoot, title string) error {
6279 if err := renameProject(workspaceRoot, title); err != nil {
6280 return err
6281 }
6282 a.syncTabWorkspaceRootSpellings()
6283 a.emitProjectTreeMetadataChanged()
6284 return nil
6285 }
6286
6287 // SetProjectColor updates the project-level accent color used by project topics
6288 // in the sidebar and tabs. Empty color restores the default accent.
6289 func (a *App) SetProjectColor(workspaceRoot, color string) error {
6290 if err := setProjectColor(workspaceRoot, color); err != nil {
6291 return err
6292 }
6293 a.syncTabWorkspaceRootSpellings()
6294 a.emitProjectTreeMetadataChanged()
6295 return nil
6296 }
6297
6298 // SetProjectPinned controls whether a project folder is pinned above the rest of
6299 // the desktop project tree.
6300 func (a *App) SetProjectPinned(workspaceRoot string, pinned bool) error {
6301 root := normalizeProjectRoot(workspaceRoot)
6302 if root == "" {
6303 return fmt.Errorf("workspaceRoot is required")
6304 }
6305 if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
6306 i := projectIndexByRoot(f.Projects, root)
6307 if i < 0 {
6308 return false, fmt.Errorf("project %q not found", root)
6309 }
6310 root = f.Projects[i].Root
6311 next := make([]string, 0, len(f.PinnedProjects))
6312 for _, pinnedRoot := range f.PinnedProjects {
6313 if !sameProjectRoot(pinnedRoot, root) {
6314 next = append(next, pinnedRoot)
6315 }
6316 }
6317 if pinned {
6318 next = prependUniqueString(next, root)
6319 }
6320 if sameStringList(next, f.PinnedProjects) {
6321 return false, nil
6322 }
6323 f.PinnedProjects = next
6324 return true, nil
6325 }); err != nil {
6326 return err
6327 }
6328 a.emitProjectTreeMetadataChanged()
6329 return nil
6330 }
6331
6332 // ReorderProjects persists the user-defined order of project folders and,
6333 // when present, the virtual Global sidebar section.
6334 func (a *App) ReorderProjects(workspaceRoots []string) error {
6335 if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
6336 var seenProjects []string
6337 next := make([]desktopProject, 0, len(workspaceRoots))
6338 sidebarOrder := make([]string, 0, len(workspaceRoots))
6339 hasGlobalOrder := false
6340 for _, root := range workspaceRoots {
6341 root = strings.TrimSpace(root)
6342 if root == desktopGlobalOrderToken {
6343 if hasGlobalOrder {
6344 return false, fmt.Errorf("duplicate global section")
6345 }
6346 hasGlobalOrder = true
6347 sidebarOrder = append(sidebarOrder, root)
6348 continue
6349 }
6350 root = normalizeProjectRoot(root)
6351 i := projectIndexByRoot(f.Projects, root)
6352 if i < 0 {
6353 return false, fmt.Errorf("project %q not found", root)
6354 }
6355 project := f.Projects[i]
6356 if projectRootInList(seenProjects, project.Root) {
6357 return false, fmt.Errorf("duplicate project %q", root)
6358 }
6359 seenProjects = append(seenProjects, project.Root)
6360 next = append(next, project)
6361 sidebarOrder = append(sidebarOrder, project.Root)
6362 }
6363 if len(next) != len(f.Projects) {
6364 return false, fmt.Errorf("project order length mismatch")
6365 }
6366 changed := !sameProjectOrder(next, f.Projects)
6367 f.Projects = next
6368 if hasGlobalOrder {
6369 if !sameStringList(sidebarOrder, f.SidebarOrder) {
6370 changed = true
6371 }
6372 f.SidebarOrder = sidebarOrder
6373 } else {
6374 if len(f.SidebarOrder) > 0 {
6375 changed = true
6376 }
6377 f.SidebarOrder = nil
6378 }
6379 return changed, nil
6380 }); err != nil {
6381 return err
6382 }
6383 a.emitProjectTreeMetadataChanged()
6384 return nil
6385 }
6386
6387 // RenameTopic updates a topic's display title.
6388 func (a *App) RenameTopic(topicID, title string) error {
6389 a.topicTitleMutationMu.Lock()
6390 defer a.topicTitleMutationMu.Unlock()
6391 // Keep candidate protection inside the same mutation fence used by the
6392 // cleanup worker. Otherwise a rename can mark an archive-pending candidate
6393 // as protected while that worker still proceeds to remove its index.
6394 // Same-value manual renames remain durable evidence of use.
6395 a.protectLegacyCleanupTopicMutation(topicID)
6396 if handled, err := a.updateCanonicalTopicPresentation(topicID, &title, nil); handled || err != nil {
6397 return err
6398 }
6399 trimmed := strings.TrimSpace(title)
6400 if trimmed == "" {
6401 trimmed = defaultTopicTitle
6402 }
6403 // Find which workspace this topic belongs to by scanning all project topic titles.
6404 f := loadProjectsFile()
6405 for _, p := range f.Projects {
6406 m := loadTopicTitles(p.Root)
6407 if _, ok := m[topicID]; ok {
6408 if err := setTopicTitle(p.Root, topicID, trimmed); err != nil {
6409 return err
6410 }
6411 a.updateOpenTopicTitle(topicID, trimmed, topicTitleSourceManual)
6412 changedDirs := a.updateTopicSessionTitles(topicID, trimmed)
6413 if len(changedDirs) > 0 {
6414 a.emitProjectTreeChangedForSessionDirs(changedDirs...)
6415 } else {
6416 a.emitProjectTreeMetadataChanged()
6417 }
6418 return nil
6419 }
6420 }
6421 // Check global.
6422 m := loadTopicTitles("")
6423 if _, ok := m[topicID]; ok {
6424 if err := setTopicTitle("", topicID, trimmed); err != nil {
6425 return err
6426 }
6427 a.updateOpenTopicTitle(topicID, trimmed, topicTitleSourceManual)
6428 changedDirs := a.updateTopicSessionTitles(topicID, trimmed)
6429 if len(changedDirs) > 0 {
6430 a.emitProjectTreeChangedForSessionDirs(changedDirs...)
6431 } else {
6432 a.emitProjectTreeMetadataChanged()
6433 }
6434 return nil
6435 }
6436 if scope, workspaceRoot, ok := a.findTopicLocation(topicID); ok {
6437 if err := ensureTopicIndexed(scope, workspaceRoot, topicID, trimmed, topicTitleSourceManual); err != nil {
6438 return err
6439 }
6440 a.updateOpenTopicTitle(topicID, trimmed, topicTitleSourceManual)
6441 changedDirs := a.updateTopicSessionTitles(topicID, trimmed)
6442 if len(changedDirs) > 0 {
6443 a.emitProjectTreeChangedForSessionDirs(changedDirs...)
6444 } else {
6445 a.emitProjectTreeMetadataChanged()
6446 }
6447 return nil
6448 }
6449 // Catalog-only topics (no title map entry, no open tab) persist through
6450 // renameCatalogOnlyTopic instead of failing (#9090).
6451 return a.renameCatalogOnlyTopic(topicID, trimmed)
6452 }
6453
6454 func (a *App) findTopicLocation(topicID string) (string, string, bool) {
6455 topicID = strings.TrimSpace(topicID)
6456 if topicID == "" {
6457 return "", "", false
6458 }
6459 a.mu.RLock()
6460 for _, tab := range a.tabs {
6461 if tab == nil || tab.TopicID != topicID {
6462 continue
6463 }
6464 scope := tab.Scope
6465 workspaceRoot := tab.WorkspaceRoot
6466 a.mu.RUnlock()
6467 if scope == "global" {
6468 return "global", "", true
6469 }
6470 return "project", normalizeProjectRoot(workspaceRoot), true
6471 }
6472 a.mu.RUnlock()
6473
6474 infos, err := agent.ListSessions(config.SessionDir())
6475 if err != nil {
6476 return "", "", false
6477 }
6478 for _, info := range infos {
6479 if strings.TrimSpace(info.TopicID) != topicID {
6480 continue
6481 }
6482 scope := strings.TrimSpace(info.Scope)
6483 if scope == "" {
6484 scope = "global"
6485 }
6486 if scope == "global" {
6487 return "global", "", true
6488 }
6489 return "project", normalizeProjectRoot(info.WorkspaceRoot), true
6490 }
6491 return "", "", false
6492 }
6493
6494 func (a *App) updateOpenTopicTitle(topicID, title, source string) {
6495 if strings.TrimSpace(topicID) == "" || strings.TrimSpace(title) == "" {
6496 return
6497 }
6498 a.mu.Lock()
6499 defer a.mu.Unlock()
6500 for _, tab := range a.runtimeTabsLocked() {
6501 if tab != nil && tab.TopicID == topicID {
6502 tab.TopicTitle = title
6503 tab.topicTitleSource = source
6504 }
6505 }
6506 }
6507
6508 func (a *App) updateTopicSessionTitles(topicID, title string) []string {
6509 if strings.TrimSpace(topicID) == "" || strings.TrimSpace(title) == "" {
6510 return nil
6511 }
6512 var changedDirs []string
6513 for _, dir := range a.knownSessionDirs() {
6514 changed := false
6515 for _, match := range topicSessionMatches(dir, topicID) {
6516 // Read-modify-write on the branch-meta sidecar: hold the per-path
6517 // meta lock so a concurrent save's revision bump can't land between
6518 // the load and save below and get rolled back by this write.
6519 unlock, lockErr := agent.LockSessionMetaPath(match.path)
6520 if lockErr != nil {
6521 continue
6522 }
6523 meta, ok, err := agent.LoadBranchMeta(match.path)
6524 if err != nil || !ok {
6525 unlock()
6526 continue
6527 }
6528 meta.TopicTitle = title
6529 err = agent.SaveBranchMetaPreserveUpdatedLocked(match.path, meta)
6530 unlock()
6531 if err == nil {
6532 invalidateTopicSessionIndex(dir)
6533 changed = true
6534 }
6535 }
6536 if changed {
6537 changedDirs = append(changedDirs, dir)
6538 }
6539 }
6540 return changedDirs
6541 }
6542
6543 func (a *App) emitProjectTreeChanged() {
6544 a.requestProjectTreeCatalogRefresh()
6545 a.emitProjectTreeChangedEvent()
6546 }
6547
6548 func (a *App) requestProjectTreeCatalogRefresh() {
6549 if a.projectTreeCatalogRefreshHook != nil {
6550 a.projectTreeCatalogRefreshHook()
6551 }
6552 a.requestSessionCatalogMetadataSync()
6553 for _, target := range a.sessionCatalogTargets() {
6554 a.requestSessionCatalogReconcile(target.Path)
6555 }
6556 }
6557
6558 // emitProjectTreeChangedForSessionDirs schedules only the affected catalog
6559 // directories. It never scans synchronously on the mutation or UI goroutine.
6560 func (a *App) emitProjectTreeChangedForSessionDirs(dirs ...string) {
6561 for _, dir := range dirs {
6562 a.requestSessionCatalogReconcile(dir)
6563 }
6564 a.emitProjectTreeChangedEvent()
6565 }
6566
6567 // emitProjectTreeMetadataChanged refreshes ordering, titles, pins, and runtime
6568 // status without walking session storage.
6569 func (a *App) emitProjectTreeMetadataChanged() {
6570 a.requestSessionCatalogMetadataSync()
6571 a.emitProjectTreeChangedEvent()
6572 }
6573
6574 // SetTopicPinned controls whether a topic is pinned to the top of its project
6575 // or Global section in the desktop project tree.
6576 func (a *App) SetTopicPinned(topicID string, pinned bool) error {
6577 if handled, err := a.updateCanonicalTopicPresentation(topicID, nil, &pinned); handled || err != nil {
6578 return err
6579 }
6580 topicID = strings.TrimSpace(topicID)
6581 if topicID == "" {
6582 return fmt.Errorf("topicID is required")
6583 }
6584 if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
6585 for i, p := range f.Projects {
6586 m := loadTopicTitles(p.Root)
6587 if _, ok := m[topicID]; !ok && !containsDesktopString(p.Topics, topicID) {
6588 continue
6589 }
6590 next := removeString(f.Projects[i].PinnedTopics, topicID)
6591 if pinned {
6592 next = prependUniqueString(f.Projects[i].PinnedTopics, topicID)
6593 }
6594 if sameStringList(next, f.Projects[i].PinnedTopics) {
6595 return false, nil
6596 }
6597 f.Projects[i].PinnedTopics = next
6598 return true, nil
6599 }
6600 globalTitles := loadTopicTitles("")
6601 if _, ok := globalTitles[topicID]; !ok && !containsDesktopString(f.GlobalTopics, topicID) {
6602 return false, fmt.Errorf("topic %q not found", topicID)
6603 }
6604 next := removeString(f.GlobalPinnedTopics, topicID)
6605 if pinned {
6606 next = prependUniqueString(f.GlobalPinnedTopics, topicID)
6607 }
6608 if sameStringList(next, f.GlobalPinnedTopics) {
6609 return false, nil
6610 }
6611 f.GlobalPinnedTopics = next
6612 return true, nil
6613 }); err != nil {
6614 return err
6615 }
6616 a.emitProjectTreeMetadataChanged()
6617 return nil
6618 }
6619
6620 // ListProjectTree builds the sidebar tree: project folders each containing
6621 // their topics, plus a Global section.
6622 // topicSummary is used by ListProjectTree and mergeSessionInfos to track
6623 // per-topic turn count and last activity.
6624 type topicSummary struct {
6625 turns int
6626 adoptedRecoveryTurns int
6627 lastActivityAt int64
6628 hasNormalSession bool
6629 hasRecoveryOnly bool
6630 hasAdoptedRecovery bool
6631 }
6632
6633 func (s topicSummary) displayTurns() int {
6634 if s.adoptedRecoveryTurns > s.turns {
6635 return s.adoptedRecoveryTurns
6636 }
6637 return s.turns
6638 }
6639
6640 // runtimeSessionStatus is one open or detached runtime session, as shown in
6641 // the sidebar tree.
6642 type runtimeSessionStatus struct {
6643 open bool
6644 running bool
6645 }
6646
6647 // topicHiddenAsRecoveryOnly keeps the legacy runtime fallback from creating a
6648 // duplicate row for an idle recovery-only topic. The catalog-backed tree now
6649 // supplies one logical row for recovery-only topics, and physical branches
6650 // remain available from History.
6651 func topicHiddenAsRecoveryOnly(summary topicSummary, pinned bool, runtimeSessions []runtimeSessionStatus) bool {
6652 if !summary.hasRecoveryOnly || summary.hasNormalSession || summary.hasAdoptedRecovery || pinned {
6653 return false
6654 }
6655 for _, session := range runtimeSessions {
6656 if session.open || session.running {
6657 return false
6658 }
6659 }
6660 return true
6661 }
6662
6663 func topicSummaryKey(scope, workspaceRoot, topicID string) string {
6664 if scope == "global" {
6665 return "global::" + topicID
6666 }
6667 // Producers key by the live tab's root spelling while the sidebar keys by
6668 // the registry's canonical spelling; fold both so runtime status never
6669 // splits across equivalent roots.
6670 return "project:" + projectRootKey(workspaceRoot) + ":" + topicID
6671 }
6672
6673 func projectSessionNodeKey(scope, sessionPath string) string {
6674 sum := sha256.Sum256([]byte(sessionRuntimeKey(sessionPath)))
6675 return scope + "_session_" + hex.EncodeToString(sum[:8])
6676 }
6677
6678 // ContextPanelInfo is the right-side panel's data for one tab.
6679 type ContextPanelInfo struct {
6680 UsedTokens int `json:"usedTokens"`
6681 WindowTokens int `json:"windowTokens"`
6682 PromptTokens int `json:"promptTokens"`
6683 CompletionTokens int `json:"completionTokens"`
6684 TotalTokens int `json:"totalTokens"`
6685 ReasoningTokens int `json:"reasoningTokens"`
6686 CacheHitTokens int `json:"cacheHitTokens"`
6687 CacheMissTokens int `json:"cacheMissTokens"`
6688 Estimated bool `json:"estimated,omitempty"`
6689 // Session-cumulative token counts (from telemetry, atomic snapshot).
6690 // Separate from the per-turn fields above so existing consumers (status bar
6691 // turn tokens, donut chart) are unaffected.
6692 SessionCacheHitTokens int `json:"sessionCacheHitTokens"`
6693 SessionCacheMissTokens int `json:"sessionCacheMissTokens"`
6694 SessionCompletionTokens int `json:"sessionCompletionTokens"`
6695 SessionEstimated bool `json:"sessionEstimated,omitempty"`
6696 RequestCount int `json:"requestCount"`
6697 ElapsedMs int64 `json:"elapsedMs"`
6698 SessionCost float64 `json:"sessionCost"`
6699 SessionCurrency string `json:"sessionCurrency,omitempty"`
6700 SessionCostUsd float64 `json:"sessionCostUsd,omitempty"`
6701 SessionCostComplete bool `json:"sessionCostComplete,omitempty"`
6702 SessionCostEstimated bool `json:"sessionCostEstimated,omitempty"`
6703 SessionBillingMode string `json:"sessionBillingMode,omitempty"`
6704 SessionCostQuote *billing.CostQuote `json:"sessionCostQuote,omitempty"`
6705 Sources map[string]usageSourceStats `json:"sources,omitempty"`
6706 Mock bool `json:"mock,omitempty"`
6707 ReadFiles []readFileRecord `json:"readFiles"`
6708 ChangedFiles []ChangedFileInfo `json:"changedFiles"`
6709 ContextBudget *ContextBudgetInfo `json:"contextBudget,omitempty"`
6710 }
6711
6712 type ChangedFileInfo struct {
6713 Path string `json:"path"`
6714 OldPath string `json:"oldPath,omitempty"`
6715 Sources []string `json:"sources"`
6716 GitStatus string `json:"gitStatus,omitempty"`
6717 Turns []int `json:"turns"`
6718 LatestPrompt string `json:"latestPrompt,omitempty"`
6719 LatestTime int64 `json:"latestTime,omitempty"`
6720 }
6721
6722 // ContextPanel returns the context usage, read files, and changed files for a
6723 // specific tab.
6724 func (a *App) ContextPanel(tabID string) ContextPanelInfo {
6725 a.mu.RLock()
6726 tab, ok := a.tabs[tabID]
6727 var ctrl control.SessionAPI
6728 if ok && tab != nil {
6729 ctrl = tab.Ctrl
6730 }
6731 a.mu.RUnlock()
6732 if !ok {
6733 return ContextPanelInfo{ReadFiles: []readFileRecord{}, ChangedFiles: []ChangedFileInfo{}}
6734 }
6735
6736 info := ContextPanelInfo{ReadFiles: []readFileRecord{}, ChangedFiles: []ChangedFileInfo{}}
6737 if ctrl != nil {
6738 if sp := ctrl.SessionPath(); sp != "" {
6739 tab.syncTelemetryToSession(sp)
6740 }
6741 _, window := ctrl.ContextSnapshot()
6742 info.WindowTokens = window
6743 // This panel breaks the last turn down into segments, so its total must
6744 // be that turn's usage and not the live-view measurement the status-bar
6745 // gauge reports — otherwise the segments stop summing to the total.
6746 if u := ctrl.LastUsage(); u != nil {
6747 info.UsedTokens = u.PromptTokens + u.CompletionTokens
6748 }
6749 if info.UsedTokens == 0 {
6750 if snap := tab.displayTelemetrySnapshot(); snap.Usage.LastUsedTokens > 0 {
6751 info.UsedTokens = snap.Usage.LastUsedTokens
6752 }
6753 }
6754 if u := ctrl.LastUsage(); u != nil {
6755 info.PromptTokens = u.PromptTokens
6756 info.CompletionTokens = u.CompletionTokens
6757 info.ReasoningTokens = u.ReasoningTokens
6758 info.CacheHitTokens = u.CacheHitTokens
6759 info.CacheMissTokens = u.CacheMissTokens
6760 info.Estimated = u.Estimated
6761 } else {
6762 // Executor rebuilt (session rebind): fall back to the telemetry-
6763 // persisted per-turn breakdown so the donut chart and type
6764 // breakdown show the last turn's composition instead of "other".
6765 snap := tab.displayTelemetrySnapshot()
6766 info.PromptTokens = snap.Usage.LastPromptTokens
6767 info.CompletionTokens = snap.Usage.LastCompletionTokens
6768 info.ReasoningTokens = snap.Usage.LastReasoningTokens
6769 info.CacheHitTokens = snap.Usage.LastCacheHitTokens
6770 info.CacheMissTokens = snap.Usage.LastCacheMissTokens
6771 info.Estimated = snap.Usage.LastEstimated
6772 }
6773 }
6774
6775 telemetry := tab.displayTelemetrySnapshot()
6776 if records := telemetry.ReadFiles; records != nil {
6777 info.ReadFiles = records
6778 }
6779 usage := telemetry.Usage
6780 info.TotalTokens = usage.TotalTokens
6781 info.RequestCount = usage.RequestCount
6782 info.ElapsedMs = usage.ElapsedMs
6783 info.SessionCost = usage.SessionCost
6784 info.SessionCurrency = usage.SessionCurrency
6785 info.SessionCostUsd = usage.SessionCostUsd
6786 info.SessionCostComplete = usage.SessionCostComplete
6787 info.SessionCostEstimated = true
6788 info.SessionCostQuote = usage.SessionCostQuote
6789 if usage.SessionCostQuote != nil {
6790 info.SessionBillingMode = usage.SessionCostQuote.BillingMode
6791 info.SessionCostEstimated = usage.SessionCostQuote.Estimated
6792 if !usage.SessionCostQuote.Complete {
6793 info.SessionCostComplete = false
6794 }
6795 }
6796 info.Sources = usage.Sources
6797 info.SessionCacheHitTokens = usage.CacheHitTokens
6798 info.SessionCacheMissTokens = usage.CacheMissTokens
6799 info.SessionCompletionTokens = usage.CompletionTokens
6800 info.SessionEstimated = usage.Estimated
6801 if ctrl != nil {
6802 if snap := ctrl.ContextMaintenanceSnapshot(); snap.ContextBudget != nil {
6803 info.ContextBudget = contextBudgetInfo(snap.ContextBudget)
6804 }
6805 }
6806
6807 // Gather workspace changes for this tab's root.
6808 if ctrl != nil && tab.WorkspaceRoot != "" {
6809 for _, meta := range ctrl.Checkpoints() {
6810 for _, path := range meta.Paths {
6811 info.ChangedFiles = append(info.ChangedFiles, ChangedFileInfo{
6812 Path: path,
6813 Sources: []string{"session"},
6814 Turns: []int{meta.Turn},
6815 LatestPrompt: meta.Prompt,
6816 LatestTime: meta.Time.UnixMilli(),
6817 })
6818 }
6819 }
6820 }
6821
6822 return info
6823 }
6824
6825 // utility
6826
6827 func (a *App) newUniqueTabIDLocked() string {
6828 for {
6829 id := newTabID()
6830 if _, exists := a.tabs[id]; !exists {
6831 return id
6832 }
6833 }
6834 }
6835
6836 func (a *App) restoredTabIDLocked(id string) string {
6837 id = strings.TrimSpace(id)
6838 if id == "" {
6839 return a.newUniqueTabIDLocked()
6840 }
6841 if _, exists := a.tabs[id]; exists {
6842 return a.newUniqueTabIDLocked()
6843 }
6844 return id
6845 }
6846
6847 func normalizeTabMode(mode string) string {
6848 switch mode {
6849 case "plan", "yolo", "plan-yolo", "yolo-plan":
6850 if mode == "yolo-plan" {
6851 return "plan-yolo"
6852 }
6853 return mode
6854 default:
6855 return "normal"
6856 }
6857 }
6858
6859 func tabModeFromAxes(plan, autoApproveTools bool) string {
6860 switch {
6861 case plan && autoApproveTools:
6862 return "plan-yolo"
6863 case plan:
6864 return "plan"
6865 case autoApproveTools:
6866 return "yolo"
6867 default:
6868 return "normal"
6869 }
6870 }
6871
6872 func tabModeHasPlan(mode string) bool {
6873 switch normalizeTabMode(mode) {
6874 case "plan", "plan-yolo":
6875 return true
6876 default:
6877 return false
6878 }
6879 }
6880
6881 func tabModeHasAutoApproveTools(mode string) bool {
6882 switch normalizeTabMode(mode) {
6883 case "yolo", "plan-yolo":
6884 return true
6885 default:
6886 return false
6887 }
6888 }
6889
6890 func currentTabMode(tab *WorkspaceTab) string {
6891 if tab == nil {
6892 return "normal"
6893 }
6894 if tab.Ctrl != nil {
6895 return tabModeFromAxes(tab.Ctrl.PlanMode(), tab.Ctrl.AutoApproveTools())
6896 }
6897 return normalizeTabMode(tab.mode)
6898 }
6899
6900 func currentTabGoal(tab *WorkspaceTab) string {
6901 if tab == nil {
6902 return ""
6903 }
6904 if tab.Ctrl != nil {
6905 return tab.Ctrl.Goal()
6906 }
6907 return strings.TrimSpace(tab.goal)
6908 }
6909
6910 func currentTabGoalStatus(tab *WorkspaceTab) string {
6911 if tab == nil {
6912 return control.GoalStatusStopped
6913 }
6914 if tab.Ctrl != nil {
6915 return tab.Ctrl.GoalStatus()
6916 }
6917 if strings.TrimSpace(tab.goal) != "" {
6918 return control.GoalStatusRunning
6919 }
6920 return control.GoalStatusStopped
6921 }
6922
6923 func currentTabCollaborationMode(tab *WorkspaceTab) string {
6924 if tab == nil {
6925 return "normal"
6926 }
6927 if tabModeHasPlan(currentTabMode(tab)) {
6928 return "plan"
6929 }
6930 if strings.TrimSpace(currentTabGoal(tab)) != "" && currentTabGoalStatus(tab) == control.GoalStatusRunning {
6931 return "goal"
6932 }
6933 return "normal"
6934 }
6935
6936 func currentTabToolApprovalMode(tab *WorkspaceTab) string {
6937 if tab == nil {
6938 return control.ToolApprovalWorkspaceWrite
6939 }
6940 if tab.Ctrl != nil {
6941 return tab.Ctrl.ToolApprovalMode()
6942 }
6943 return normalizeToolApprovalMode(tab.toolApprovalMode)
6944 }
6945
6946 // Snapshot-based forms of the currentTabX helpers, for callers that already
6947 // hold a consistent tabRuntimeSnapshot.
6948
6949 func (s tabRuntimeSnapshot) currentMode() string {
6950 if s.ctrl != nil {
6951 return tabModeFromAxes(s.ctrl.PlanMode(), s.ctrl.AutoApproveTools())
6952 }
6953 return normalizeTabMode(s.mode)
6954 }
6955
6956 func (s tabRuntimeSnapshot) currentGoal() string {
6957 if s.ctrl != nil {
6958 return s.ctrl.Goal()
6959 }
6960 return strings.TrimSpace(s.goal)
6961 }
6962
6963 func (s tabRuntimeSnapshot) currentGoalStatus() string {
6964 if s.ctrl != nil {
6965 return s.ctrl.GoalStatus()
6966 }
6967 if strings.TrimSpace(s.goal) != "" {
6968 return control.GoalStatusRunning
6969 }
6970 return control.GoalStatusStopped
6971 }
6972
6973 func (s tabRuntimeSnapshot) collaborationMode() string {
6974 if tabModeHasPlan(s.currentMode()) {
6975 return "plan"
6976 }
6977 if strings.TrimSpace(s.currentGoal()) != "" && s.currentGoalStatus() == control.GoalStatusRunning {
6978 return "goal"
6979 }
6980 return "normal"
6981 }
6982
6983 func (s tabRuntimeSnapshot) currentToolApprovalMode() string {
6984 if s.ctrl != nil {
6985 return s.ctrl.ToolApprovalMode()
6986 }
6987 return normalizeToolApprovalMode(s.toolApprovalMode)
6988 }
6989
6990 // normalizedRuntime reads live Controller state only after the App snapshot has
6991 // released a.mu. Rebuild callers hold turnStartMu while invoking it, so all
6992 // three axes and the legacy Goal fallback describe one admitted runtime state.
6993 func (s tabRuntimeSnapshot) normalizedRuntime() normalizedTabRuntime {
6994 plan := tabModeHasPlan(normalizeTabMode(s.mode))
6995 approvalMode := normalizeToolApprovalMode(s.toolApprovalMode)
6996 goal := strings.TrimSpace(s.goal)
6997 goalStatus := control.GoalStatusStopped
6998 if goal != "" {
6999 goalStatus = control.GoalStatusRunning
7000 }
7001 if s.ctrl != nil {
7002 plan = s.ctrl.PlanMode()
7003 approvalMode = normalizeToolApprovalMode(s.ctrl.ToolApprovalMode())
7004 goal = strings.TrimSpace(s.ctrl.Goal())
7005 goalStatus = s.ctrl.GoalStatus()
7006 }
7007
7008 runtime := normalizedTabRuntime{
7009 collaborationMode: "normal",
7010 toolApprovalMode: approvalMode,
7011 tokenMode: boot.NormalizeTokenMode(s.tokenMode),
7012 qualityFloor: control.QualityFloorStandard,
7013 }
7014 switch {
7015 case plan:
7016 runtime.collaborationMode = "plan"
7017 case goal != "" && goalStatus == control.GoalStatusRunning:
7018 runtime.collaborationMode = "goal"
7019 runtime.legacyGoal = goal
7020 }
7021 return runtime
7022 }
7023
7024 func (r normalizedTabRuntime) tabMode() string {
7025 return tabModeFromAxes(r.collaborationMode == "plan", r.toolApprovalMode == control.ToolApprovalDangerFullAccess)
7026 }
7027
7028 func applyNormalizedRuntimeToTabLocked(tab *WorkspaceTab, runtime normalizedTabRuntime) {
7029 if tab == nil {
7030 return
7031 }
7032 tab.mode = runtime.tabMode()
7033 tab.toolApprovalMode = normalizeToolApprovalMode(runtime.toolApprovalMode)
7034 tab.qualityFloor = runtime.qualityFloor
7035 if runtime.collaborationMode == "goal" {
7036 tab.goal = strings.TrimSpace(runtime.legacyGoal)
7037 } else {
7038 tab.goal = ""
7039 }
7040 }
7041
7042 func normalizeToolApprovalMode(mode string) string {
7043 return config.NormalizeToolApprovalMode(mode)
7044 }
7045
7046 func persistedToolApprovalMode(mode string) string {
7047 return normalizeToolApprovalMode(mode)
7048 }
7049
7050 // persistedTabMode stores only the collaboration axis. Permission now has its
7051 // own authoritative ToolApprovalMode field, so new state must never encode it
7052 // again through the legacy yolo/plan-yolo values. Legacy readers still accept
7053 // those values during migration.
7054 func persistedTabMode(mode string) string {
7055 switch normalizeTabMode(mode) {
7056 case "plan", "plan-yolo":
7057 return "plan"
7058 }
7059 return ""
7060 }
7061
7062 func newTabID() string {
7063 var b [16]byte
7064 if _, err := rand.Read(b[:]); err != nil {
7065 now := time.Now().UTC()
7066 return "tab_" + now.Format("20060102150405") + "_" + fmt.Sprintf("%09d", now.Nanosecond())
7067 }
7068 return "tab_" + hex.EncodeToString(b[:])
7069 }
7070
7071 func newTopicID() string {
7072 var b [8]byte
7073 if _, err := rand.Read(b[:]); err != nil {
7074 now := time.Now().UTC()
7075 return "topic_" + now.Format("20060102-150405") + "_" + fmt.Sprintf("%09d", now.Nanosecond())
7076 }
7077 return "topic_" + time.Now().UTC().Format("20060102-150405") + "_" + hex.EncodeToString(b[:])
7078 }
7079
7080 func globalWorkspaceRoot() string {
7081 return filepath.Join(desktopConfigDir(), "global-workspace")
7082 }
7083
7084 func ensureGlobalWorkspaceRoot() (string, error) {
7085 root := globalWorkspaceRoot()
7086 if err := os.MkdirAll(root, 0o755); err != nil {
7087 return "", err
7088 }
7089 return root, nil
7090 }
7091
7092 func globalTabWorkspaceRoot() string {
7093 root, err := ensureGlobalWorkspaceRoot()
7094 if err != nil {
7095 return globalWorkspaceRoot()
7096 }
7097 return root
7098 }
7099
7100 func loadPinnedTabSession(dir, sessionPath string) (*agent.Session, string, bool, error) {
7101 return loadPinnedTabSessionWithPreloadAndMigrationFallback(dir, sessionPath, loadedTabSession{}, true)
7102 }
7103
7104 func loadPinnedTabSessionWithPreload(dir, sessionPath string, preloaded loadedTabSession) (*agent.Session, string, bool, error) {
7105 return loadPinnedTabSessionWithPreloadAndMigrationFallback(dir, sessionPath, preloaded, false)
7106 }
7107
7108 func loadPinnedTabSessionWithPreloadAndMigrationFallback(dir, sessionPath string, preloaded loadedTabSession, allowMigrationFallback bool) (*agent.Session, string, bool, error) {
7109 path, ok := pinnedTabSessionPath(dir, sessionPath)
7110 if !ok && allowMigrationFallback {
7111 path, ok = migratedPinnedTabSessionPath(dir, sessionPath)
7112 }
7113 if !ok {
7114 return nil, "", false, nil
7115 }
7116 if agent.IsCleanupPending(path) {
7117 return nil, "", false, nil
7118 }
7119 if preloaded.matches(path) {
7120 if preloaded.Session != nil && len(preloaded.Session.Snapshot()) == 0 {
7121 return nil, path, true, nil
7122 }
7123 return preloaded.Session, path, true, nil
7124 }
7125 loaded, err := agent.LoadSession(path)
7126 if err != nil {
7127 if os.IsNotExist(err) {
7128 return nil, path, true, nil
7129 }
7130 return nil, path, true, err
7131 }
7132 // An empty file (0 messages) is a pre-created placeholder, not a real
7133 // session to resume. Treating it as valid would make ctrl.Resume replace
7134 // the executor's live session (with system prompt) with the empty one,
7135 // causing the saved transcript to lack the agent identity contract.
7136 if len(loaded.Snapshot()) == 0 {
7137 return nil, path, true, nil
7138 }
7139 return loaded, path, true, nil
7140 }
7141
7142 func migratedPinnedTabSessionPath(dir, sessionPath string) (string, bool) {
7143 sessionPath = strings.TrimSpace(sessionPath)
7144 if sessionPath == "" || dir == "" || !filepath.IsAbs(sessionPath) {
7145 return "", false
7146 }
7147 if _, err := os.Stat(sessionPath); err == nil || !os.IsNotExist(err) {
7148 return "", false
7149 }
7150 base := filepath.Base(sessionPath)
7151 if base == "." || base == string(filepath.Separator) || !strings.HasSuffix(base, ".jsonl") {
7152 return "", false
7153 }
7154 path, _, err := validateSessionPath(dir, filepath.Join(dir, base))
7155 if err != nil {
7156 return "", false
7157 }
7158 return path, true
7159 }
7160
7161 func pinnedTabSessionPath(dir, sessionPath string) (string, bool) {
7162 sessionPath = strings.TrimSpace(sessionPath)
7163 if sessionPath == "" || dir == "" {
7164 return "", false
7165 }
7166 path, _, err := validateSessionPath(dir, sessionPath)
7167 if err != nil {
7168 if filepath.IsAbs(sessionPath) {
7169 return "", false
7170 }
7171 base := filepath.Base(sessionPath)
7172 if base == "." || base == string(filepath.Separator) || !strings.HasSuffix(base, ".jsonl") {
7173 return "", false
7174 }
7175 path, _, err = validateSessionPath(dir, filepath.Join(dir, base))
7176 if err != nil {
7177 return "", false
7178 }
7179 }
7180 return path, true
7181 }
7182
7183 func pinnedTabSessionPathForBuild(scope, workspaceRoot, targetDir, sessionPath string) (string, bool) {
7184 if path, ok := pinnedTabSessionPath(targetDir, sessionPath); ok {
7185 return path, true
7186 }
7187 // Before per-project storage, desktop tabs persisted exact paths under the
7188 // global session directory. Accept only that owned legacy root, and require
7189 // project ownership metadata before routing a project tab through it.
7190 legacyDir := config.SessionDir()
7191 path, ok := pinnedTabSessionPath(legacyDir, sessionPath)
7192 if !ok {
7193 return "", false
7194 }
7195 meta, hasMeta, err := agent.LoadBranchMeta(path)
7196 if strings.TrimSpace(scope) == "project" {
7197 if err != nil || !hasMeta || meta.Scope != "project" || !sameProjectRoot(meta.WorkspaceRoot, workspaceRoot) {
7198 return "", false
7199 }
7200 } else if err == nil && hasMeta && meta.Scope == "project" {
7201 return "", false
7202 }
7203 return path, true
7204 }
7205
7206 // saveTabSessionMeta persists the tab's scope/topic/mode fields into the
7207 // session's branch-meta sidecar at path. Tab fields are snapshotted under a.mu
7208 // (controller reads happen off-lock) so a concurrent tab mutation can't tear
7209 // the persisted record.
7210 func (a *App) saveTabSessionMeta(tab *WorkspaceTab, path string) error {
7211 if tab == nil {
7212 return nil
7213 }
7214 snap, ok, err := a.tabSessionMetaSnapshot(tab, path, false)
7215 if err != nil || !ok {
7216 return err
7217 }
7218 return a.saveTabSessionMetaSnapshotAndIndex(snap)
7219 }
7220
7221 type tabSessionMetaSnapshot struct {
7222 path legacySessionPath
7223 scope, workspaceRoot string
7224 topicID, topicTitle string
7225 tokenMode string
7226 qualityFloor string
7227 mode string
7228 toolApprovalMode string
7229 goal string
7230 }
7231
7232 func (a *App) saveTabSessionMetaForCurrentSession(tab *WorkspaceTab) error {
7233 snap, ok, err := a.tabSessionMetaSnapshotForCurrentSession(tab)
7234 if err != nil || !ok {
7235 return err
7236 }
7237 return a.saveTabSessionMetaSnapshotAndIndex(snap)
7238 }
7239
7240 func (a *App) tabSessionMetaSnapshotForCurrentSession(tab *WorkspaceTab) (tabSessionMetaSnapshot, bool, error) {
7241 return a.tabSessionMetaSnapshot(tab, "", true)
7242 }
7243
7244 type tabSessionMetaSource struct {
7245 snapshot tabSessionMetaSnapshot
7246 ctrl control.SessionAPI
7247 generation uint64
7248 sessionID, storedPath string
7249 readOnly bool
7250 }
7251
7252 func (a *App) captureTabSessionMetaSource(tab *WorkspaceTab) (tabSessionMetaSource, bool) {
7253 if tab == nil {
7254 return tabSessionMetaSource{}, false
7255 }
7256 a.mu.RLock()
7257 defer a.mu.RUnlock()
7258 if tab.ID != "" && a.tabs[tab.ID] != tab {
7259 return tabSessionMetaSource{}, false
7260 }
7261 return tabSessionMetaSource{
7262 ctrl: tab.Ctrl,
7263 generation: tab.SessionGeneration,
7264 sessionID: strings.TrimSpace(tab.SessionID),
7265 storedPath: strings.TrimSpace(tab.SessionPath),
7266 readOnly: tab.ReadOnly,
7267 snapshot: tabSessionMetaSnapshot{
7268 scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot,
7269 topicID: tab.TopicID, topicTitle: tab.TopicTitle,
7270 tokenMode: currentTabTokenMode(tab), qualityFloor: control.QualityFloorStandard,
7271 mode: normalizeTabMode(tab.mode), toolApprovalMode: normalizeToolApprovalMode(tab.toolApprovalMode),
7272 goal: strings.TrimSpace(tab.goal),
7273 },
7274 }, true
7275 }
7276
7277 func canonicalTabSessionMetaDisposition(sessionID, storedPath, requestedPath string) (bool, error) {
7278 if sessionID == "" {
7279 return false, nil
7280 }
7281 if err := session.ValidateSessionID(sessionID); err != nil {
7282 return false, &sessionLocatorError{reason: "invalid_canonical_session_id"}
7283 }
7284 if locator := classifySessionLocator(storedPath); locator.kind != sessionLocatorEmpty {
7285 if locator.kind != sessionLocatorCanonical || locator.ref.SessionID != sessionID {
7286 return false, &sessionLocatorError{reason: "session_identity_conflict"}
7287 }
7288 }
7289 if requestedPath != "" {
7290 locator := classifySessionLocator(requestedPath)
7291 if locator.kind == sessionLocatorInvalid || locator.kind == sessionLocatorCanonical && locator.ref.SessionID != sessionID {
7292 return false, &sessionLocatorError{reason: "session_identity_conflict"}
7293 }
7294 }
7295 // Canonical session metadata belongs to the Session Service, workspace
7296 // registry, and desktop-tabs.json. Never dual-write a legacy sidecar.
7297 return true, nil
7298 }
7299
7300 func updateTabSessionMetaFromController(source *tabSessionMetaSource) (ctrlPath, ctrlDir string, activeWork bool) {
7301 if source.ctrl != nil {
7302 ctrlPath = strings.TrimSpace(source.ctrl.SessionPath())
7303 if dir, ok := safeControllerSessionDir(source.ctrl); ok {
7304 ctrlDir = strings.TrimSpace(dir)
7305 }
7306 status := source.ctrl.RuntimeStatus()
7307 activeWork = status.Running || status.PendingPrompt || status.BackgroundJobs > 0
7308 source.snapshot.mode = tabModeFromAxes(source.ctrl.PlanMode(), source.ctrl.AutoApproveTools())
7309 source.snapshot.toolApprovalMode = normalizeToolApprovalMode(source.ctrl.ToolApprovalMode())
7310 if source.ctrl.GoalStatus() == control.GoalStatusRunning {
7311 source.snapshot.goal = strings.TrimSpace(source.ctrl.Goal())
7312 } else {
7313 source.snapshot.goal = ""
7314 }
7315 }
7316 return ctrlPath, ctrlDir, activeWork
7317 }
7318
7319 func tabSessionMetaLegacyPath(source tabSessionMetaSource, requestedPath string, useCurrent bool, ctrlPath, ctrlDir string, activeWork bool) (legacySessionPath, bool, error) {
7320 currentPath := strings.TrimSpace(requestedPath)
7321 if useCurrent {
7322 currentPath = ctrlPath
7323 if currentPath == "" {
7324 currentPath = source.storedPath
7325 }
7326 }
7327 if currentPath == "" {
7328 return "", false, nil
7329 }
7330 locator := classifySessionLocator(currentPath)
7331 switch locator.kind {
7332 case sessionLocatorCanonical:
7333 return "", false, nil
7334 case sessionLocatorInvalid:
7335 return "", false, &sessionLocatorError{reason: locator.reason}
7336 case sessionLocatorEmpty:
7337 return "", false, nil
7338 }
7339
7340 sessionDir := desktopSessionDir("")
7341 if source.snapshot.workspaceRoot != "" {
7342 sessionDir = desktopSessionDir(source.snapshot.workspaceRoot)
7343 } else if ctrlDir != "" {
7344 sessionDir = ctrlDir
7345 }
7346 runtimeDir := sessionDir
7347 if ctrlDir != "" {
7348 if _, _, err := validateSessionPath(ctrlDir, currentPath); err == nil {
7349 runtimeDir = ctrlDir
7350 }
7351 }
7352 if source.snapshot.topicID == "" && !activeWork && source.storedPath != "" && classifySessionLocator(source.storedPath).kind == sessionLocatorLegacy && sessionPathHasNoContent(sessionDir, source.storedPath) {
7353 return "", false, nil
7354 }
7355 path, err := tabSessionMetaPathForSession(runtimeDir, sessionDir, currentPath)
7356 if err != nil {
7357 return "", false, err
7358 }
7359 return path, true, nil
7360 }
7361
7362 func (a *App) tabSessionMetaSourceCurrent(tab *WorkspaceTab, source tabSessionMetaSource) bool {
7363 a.mu.RLock()
7364 defer a.mu.RUnlock()
7365 current := tab.ID == "" || a.tabs[tab.ID] == tab
7366 return current && tab.Ctrl == source.ctrl && tab.SessionGeneration == source.generation &&
7367 strings.TrimSpace(tab.SessionID) == source.sessionID && strings.TrimSpace(tab.SessionPath) == source.storedPath
7368 }
7369
7370 func (a *App) tabSessionMetaSnapshot(tab *WorkspaceTab, requestedPath string, useCurrent bool) (tabSessionMetaSnapshot, bool, error) {
7371 source, ok := a.captureTabSessionMetaSource(tab)
7372 if !ok || source.readOnly {
7373 return tabSessionMetaSnapshot{}, false, nil
7374 }
7375 canonical, err := canonicalTabSessionMetaDisposition(source.sessionID, source.storedPath, requestedPath)
7376 if err != nil || canonical {
7377 return tabSessionMetaSnapshot{}, false, err
7378 }
7379 ctrlPath, ctrlDir, activeWork := updateTabSessionMetaFromController(&source)
7380 path, ok, err := tabSessionMetaLegacyPath(source, requestedPath, useCurrent, ctrlPath, ctrlDir, activeWork)
7381 if err != nil || !ok {
7382 return tabSessionMetaSnapshot{}, false, err
7383 }
7384 // Controller reads above are intentionally off App.mu. Fence the result
7385 // before it can select a file target for a tab that has since switched.
7386 if !a.tabSessionMetaSourceCurrent(tab, source) {
7387 return tabSessionMetaSnapshot{}, false, nil
7388 }
7389 source.snapshot.path = path
7390 return source.snapshot, true, nil
7391 }
7392
7393 func saveTabSessionMetaSnapshot(snap tabSessionMetaSnapshot) error {
7394 path := string(snap.path)
7395 if strings.TrimSpace(path) == "" {
7396 return nil
7397 }
7398 // Read-modify-write on the branch-meta sidecar: hold the per-path meta lock
7399 // so agent-side writers (autosave UpdateSessionMeta, in-flight markers)
7400 // can't interleave and drop fields.
7401 unlock, err := agent.LockSessionMetaPath(path)
7402 if err != nil {
7403 return err
7404 }
7405 defer unlock()
7406 m, err := agent.EnsureBranchMetaLocked(path)
7407 if err != nil {
7408 return err
7409 }
7410 scope := snap.scope
7411 workspaceRoot := snap.workspaceRoot
7412 if ownerScope, ownerRoot, _, ok := legacyMigrationTargetForDir(filepath.Dir(path)); ok {
7413 if ownerScope == "project" {
7414 scope = ownerScope
7415 workspaceRoot = ownerRoot
7416 }
7417 }
7418 if scope == "project" {
7419 workspaceRoot = normalizeProjectRoot(workspaceRoot)
7420 } else {
7421 scope = "global"
7422 workspaceRoot = ""
7423 }
7424 m.Scope = scope
7425 m.WorkspaceRoot = workspaceRoot
7426 m.TopicID = snap.topicID
7427 m.TopicTitle = snap.topicTitle
7428 m.QualityFloor, m.TokenMode, m.AgentPreset = control.QualityFloorStandard, boot.TokenModeFull, boot.AgentPresetStandard
7429 m.Mode = persistedTabMode(snap.mode)
7430 m.ToolApprovalMode = persistedToolApprovalMode(snap.toolApprovalMode)
7431 m.Goal = strings.TrimSpace(snap.goal)
7432 if err := agent.SaveBranchMetaPreserveUpdatedLocked(path, m); err != nil {
7433 return err
7434 }
7435 invalidateTopicSessionIndexForPath(path)
7436 return nil
7437 }
7438
7439 func tabSessionMetaPathForSession(runtimeDir, sessionDir, sessionPath string) (legacySessionPath, error) {
7440 return resolveLegacySessionPath(sessionPath, runtimeDir, sessionDir)
7441 }
7442
7443 type tabSessionProfile struct {
7444 tokenMode, qualityFloor, mode string
7445 toolApprovalMode, goal string
7446 }
7447
7448 func defaultTabSessionProfile() tabSessionProfile {
7449 return tabSessionProfile{
7450 tokenMode: boot.TokenModeFull,
7451 qualityFloor: control.QualityFloorStandard,
7452 mode: "normal",
7453 toolApprovalMode: control.ToolApprovalWorkspaceWrite,
7454 }
7455 }
7456
7457 func tabSessionProfileFromMeta(sessionPath string, meta agent.BranchMeta) tabSessionProfile {
7458 profile := defaultTabSessionProfile()
7459 // Retired role fields remain readable but no longer affect execution.
7460 profile.tokenMode = boot.TokenModeFull
7461 profile.qualityFloor = control.QualityFloorStandard
7462 profile.mode = normalizeTabMode(meta.Mode)
7463 profile.toolApprovalMode = normalizeToolApprovalMode(meta.ToolApprovalMode)
7464 if profile.toolApprovalMode == control.ToolApprovalReadOnly && tabModeHasAutoApproveTools(meta.Mode) {
7465 profile.toolApprovalMode = control.ToolApprovalWorkspaceWrite
7466 }
7467 profile.goal = runningTabSessionGoal(sessionPath, meta.Goal)
7468 return profile
7469 }
7470
7471 func loadTabSessionProfile(sessionPath string) tabSessionProfile {
7472 legacyPath, valid := validatedLegacySessionPathForRead(sessionPath)
7473 if !valid {
7474 return defaultTabSessionProfile()
7475 }
7476 sessionPath = string(legacyPath)
7477 meta, ok, err := agent.LoadBranchMeta(sessionPath)
7478 if err != nil || !ok {
7479 return defaultTabSessionProfile()
7480 }
7481 return tabSessionProfileFromMeta(sessionPath, meta)
7482 }
7483
7484 func applyTabSessionProfile(tab *WorkspaceTab, profile tabSessionProfile) {
7485 if tab == nil {
7486 return
7487 }
7488 tab.qualityFloor = profile.qualityFloor
7489 tab.mode = normalizeTabMode(profile.mode)
7490 tab.toolApprovalMode = normalizeToolApprovalMode(profile.toolApprovalMode)
7491 if tab.toolApprovalMode == control.ToolApprovalReadOnly && tabModeHasAutoApproveTools(tab.mode) {
7492 tab.toolApprovalMode = control.ToolApprovalWorkspaceWrite
7493 }
7494 tab.mode = tabModeFromAxes(tabModeHasPlan(tab.mode), tab.toolApprovalMode == control.ToolApprovalDangerFullAccess)
7495 tab.goal = strings.TrimSpace(profile.goal)
7496 }
7497
7498 func persistedTabGoal(tab *WorkspaceTab) string {
7499 goal := strings.TrimSpace(currentTabGoal(tab))
7500 if goal == "" || currentTabGoalStatus(tab) != control.GoalStatusRunning {
7501 return ""
7502 }
7503 return goal
7504 }
7505
7506 type tabSessionGoalState struct {
7507 Goal string `json:"goal,omitempty"`
7508 Status string `json:"status,omitempty"`
7509 }
7510
7511 func runningTabSessionGoal(sessionPath, fallback string) string {
7512 fallback = strings.TrimSpace(fallback)
7513 if fallback == "" {
7514 return ""
7515 }
7516 legacyPath, ok := validatedLegacySessionPathForRead(sessionPath)
7517 if !ok {
7518 return fallback
7519 }
7520 sessionPath = string(legacyPath)
7521 data, err := readFileUTF8(store.SessionGoalState(sessionPath))
7522 if err != nil {
7523 return fallback
7524 }
7525 var state tabSessionGoalState
7526 if err := json.Unmarshal(data, &state); err != nil {
7527 return fallback
7528 }
7529 switch state.Status {
7530 case control.GoalStatusRunning:
7531 if goal := strings.TrimSpace(state.Goal); goal != "" {
7532 return goal
7533 }
7534 return fallback
7535 case "", control.GoalStatusStopped:
7536 return ""
7537 default:
7538 return ""
7539 }
7540 }
7541
7542 func canonicalTabSessionPath(path string) string {
7543 locator := classifySessionLocator(path)
7544 if locator.kind != sessionLocatorLegacy {
7545 return ""
7546 }
7547 path = string(locator.legacyPath)
7548 if validPath, _, err := validateSessionPath(config.SessionDir(), path); err == nil {
7549 return validPath
7550 }
7551 // Project-scope sessions live outside config.SessionDir(). Their absolute
7552 // transcript path still has to pass the same filename and link-escape
7553 // checks before it can reach runtime identity or file helpers.
7554 if filepath.IsAbs(path) {
7555 if validPath, _, err := validateSessionPath(filepath.Dir(path), path); err == nil {
7556 return validPath
7557 }
7558 }
7559 return ""
7560 }
7561
7562 func (a *App) rememberTabSessionPath(tab *WorkspaceTab, path string) {
7563 if tab == nil {
7564 return
7565 }
7566 locator := classifySessionLocator(path)
7567 if locator.kind == sessionLocatorInvalid || locator.kind == sessionLocatorEmpty {
7568 return
7569 }
7570 if locator.kind == sessionLocatorLegacy {
7571 path = canonicalTabSessionPath(path)
7572 if path == "" {
7573 return
7574 }
7575 }
7576 a.mu.Lock()
7577 if current := a.tabs[tab.ID]; current == tab {
7578 setTabSessionIdentity(tab, path)
7579 a.saveTabsLocked()
7580 } else {
7581 setTabSessionIdentity(tab, path)
7582 }
7583 a.mu.Unlock()
7584 }
7585
7586 func (a *App) persistTabSessionPath(tab *WorkspaceTab, path string) {
7587 locator := classifySessionLocator(path)
7588 if tab == nil || locator.kind == sessionLocatorEmpty || locator.kind == sessionLocatorInvalid {
7589 return
7590 }
7591 if locator.kind == sessionLocatorCanonical {
7592 a.rememberTabSessionPath(tab, sessionRoute(locator.ref.SessionID))
7593 return
7594 }
7595 path = canonicalTabSessionPath(path)
7596 if path == "" {
7597 return
7598 }
7599 // A tab restored from the short-lived tab-scoped implementation may not
7600 // have had a session path when startup loaded its legacy pins. Publish that
7601 // one-time migration before reconcile loads the new session-owned sidecar.
7602 migratePendingLegacyPinnedFiles(tab, path)
7603 if reconciled, ok := a.reconcileTabWithSessionPath(tab, path); ok {
7604 path = canonicalTabSessionPath(reconciled)
7605 }
7606 _ = a.saveTabSessionMeta(tab, path)
7607 a.rememberTabSessionPath(tab, path)
7608 }
7609
7610 func (a *App) knownSessionDirs() []string {
7611 seen := map[string]bool{}
7612 out := []string{}
7613 add := func(dir string) {
7614 dir = strings.TrimSpace(dir)
7615 if dir == "" {
7616 return
7617 }
7618 if abs, err := filepath.Abs(dir); err == nil {
7619 dir = abs
7620 }
7621 if seen[dir] {
7622 return
7623 }
7624 seen[dir] = true
7625 out = append(out, dir)
7626 }
7627 add(config.SessionDir()) // legacy/global sessions from earlier desktop builds
7628 add(desktopSessionDir(globalWorkspaceRoot()))
7629 for _, project := range loadProjectsFile().Projects {
7630 dir := desktopSessionDir(project.Root)
7631 if _, err := os.Stat(dir); os.IsNotExist(err) {
7632 continue // project dir removed or external volume unmounted
7633 }
7634 add(dir)
7635 }
7636 a.mu.RLock()
7637 for _, tab := range a.tabs {
7638 add(tabSessionDir(tab))
7639 }
7640 for _, tab := range a.detachedSessions {
7641 add(tabSessionDir(tab))
7642 }
7643 a.mu.RUnlock()
7644 return out
7645 }
7646
7647 func topicSessionMatchMatchesTarget(match topicSessionMatch, scope, workspaceRoot string) bool {
7648 if scope == "project" {
7649 return match.scope == "project" && sameProjectRoot(match.workspaceRoot, workspaceRoot)
7650 }
7651 return match.scope == "" || match.scope == "global"
7652 }
7653
7654 func (a *App) findTopicSessionForTarget(scope, workspaceRoot, topicID string) (string, string) {
7655 return a.findTopicSessionForTargetByContent(scope, workspaceRoot, topicID, false)
7656 }
7657
7658 func (a *App) findTopicContentSessionForTarget(scope, workspaceRoot, topicID string) (string, string) {
7659 return a.findTopicSessionForTargetByContent(scope, workspaceRoot, topicID, true)
7660 }
7661
7662 func (a *App) findTopicSessionForTargetByContent(scope, workspaceRoot, topicID string, requireContent bool) (string, string) {
7663 topicID = strings.TrimSpace(topicID)
7664 if topicID == "" {
7665 return "", ""
7666 }
7667 type candidate struct {
7668 match topicSessionMatch
7669 dir string
7670 }
7671 var candidates []candidate
7672 for _, dir := range a.knownSessionDirs() {
7673 for _, match := range topicSessionMatches(dir, topicID) {
7674 if !topicSessionMatchMatchesTarget(match, scope, workspaceRoot) {
7675 continue
7676 }
7677 candidates = append(candidates, candidate{match: match, dir: dir})
7678 }
7679 }
7680 sort.Slice(candidates, func(i, j int) bool {
7681 a, b := candidates[i].match, candidates[j].match
7682 if !a.updatedAt.Equal(b.updatedAt) {
7683 return a.updatedAt.After(b.updatedAt)
7684 }
7685 return a.path < b.path
7686 })
7687 // Content-bearing sessions outrank content-free ones regardless of
7688 // updatedAt: a freshly created empty session must not hijack the topic
7689 // from the conversation the user actually had (#7305). The content probe
7690 // reads session files, so it walks newest-first and stops at the first
7691 // hit — the common case checks one file.
7692 for _, c := range candidates {
7693 if sessionFileHasConversationContent(c.match.path) {
7694 return c.match.path, c.dir
7695 }
7696 }
7697 if requireContent || len(candidates) == 0 {
7698 return "", ""
7699 }
7700 return candidates[0].match.path, candidates[0].dir
7701 }
7702
7703 type topicSessionFileSignature struct {
7704 Name string `json:"name"`
7705 Size int64 `json:"size"`
7706 ModTime int64 `json:"mod_time"`
7707 }
7708
7709 type topicSessionMatch struct {
7710 path string
7711 updatedAt time.Time
7712 scope string
7713 workspaceRoot string
7714 }
7715
7716 type topicSessionDirIndex struct {
7717 signature []topicSessionFileSignature
7718 byTopic map[string][]topicSessionMatch
7719 }
7720
7721 // mergeSessionInfos merges one directory's session listing into the maps used by
7722 // ListProjectTree. The result collection loop calls it serially.
7723 func mergeSessionInfos(dir string, infos []agent.SessionInfo, titles map[string]string, sessionInfos map[string]agent.SessionInfo, sessionTitles map[string]string, topicSummaries map[string]topicSummary) {
7724 for _, info := range infos {
7725 sessionKey := sessionRuntimeKey(info.Path)
7726 if sessionKey != "" {
7727 sessionInfos[sessionKey] = info
7728 title := strings.TrimSpace(info.CustomTitle)
7729 if title == "" {
7730 title = titles[filepath.Base(info.Path)]
7731 }
7732 sessionTitles[sessionKey] = title
7733 }
7734 if strings.TrimSpace(info.TopicID) == "" {
7735 continue
7736 }
7737 key := topicSummaryKey(info.Scope, info.WorkspaceRoot, info.TopicID)
7738 summary := topicSummaries[key]
7739 lastActivityAt := info.LastActivityAt.UnixMilli()
7740 if sessionInfoIsAutomaticRecovery(info) {
7741 // A covered conflict copy duplicates its parent, so its turns must not
7742 // be added. Any branch with unique content keeps the topic visible.
7743 if sessionInfoIsUnmodifiedRecoveryCopy(info, dir) {
7744 summary.hasRecoveryOnly = true
7745 } else {
7746 summary.hasAdoptedRecovery = true
7747 if info.Turns > summary.adoptedRecoveryTurns {
7748 summary.adoptedRecoveryTurns = info.Turns
7749 }
7750 }
7751 if lastActivityAt > summary.lastActivityAt {
7752 summary.lastActivityAt = lastActivityAt
7753 }
7754 topicSummaries[key] = summary
7755 continue
7756 }
7757 summary.hasNormalSession = true
7758 summary.turns += info.Turns
7759 if lastActivityAt > summary.lastActivityAt {
7760 summary.lastActivityAt = lastActivityAt
7761 }
7762 topicSummaries[key] = summary
7763 }
7764 }
7765
7766 var topicSessionIndexCache = struct {
7767 sync.Mutex
7768 byDir map[string]topicSessionDirIndex
7769 }{byDir: map[string]topicSessionDirIndex{}}
7770
7771 func topicSessionDirKey(dir string) string {
7772 dir = strings.TrimSpace(dir)
7773 if dir == "" {
7774 return ""
7775 }
7776 if abs, err := filepath.Abs(dir); err == nil {
7777 return abs
7778 }
7779 return dir
7780 }
7781
7782 func topicSessionDirSnapshot(dir string) ([]topicSessionFileSignature, []string, error) {
7783 entries, err := os.ReadDir(dir)
7784 if err != nil {
7785 return nil, nil, err
7786 }
7787 signature := []topicSessionFileSignature{}
7788 sessionNames := []string{}
7789 for _, entry := range entries {
7790 name := entry.Name()
7791 if entry.IsDir() {
7792 continue
7793 }
7794 isSession := store.IsSessionTranscriptName(name)
7795 isMeta := strings.HasSuffix(name, ".jsonl.meta")
7796 if !isSession && !isMeta {
7797 continue
7798 }
7799 info, err := entry.Info()
7800 if err != nil {
7801 continue
7802 }
7803 signature = append(signature, topicSessionFileSignature{
7804 Name: name,
7805 Size: info.Size(),
7806 ModTime: info.ModTime().UnixNano(),
7807 })
7808 if isSession {
7809 sessionNames = append(sessionNames, name)
7810 }
7811 }
7812 sort.Slice(signature, func(i, j int) bool {
7813 return signature[i].Name < signature[j].Name
7814 })
7815 sort.Strings(sessionNames)
7816 return signature, sessionNames, nil
7817 }
7818
7819 func topicSessionSignaturesEqual(a, b []topicSessionFileSignature) bool {
7820 if len(a) != len(b) {
7821 return false
7822 }
7823 for i := range a {
7824 if a[i] != b[i] {
7825 return false
7826 }
7827 }
7828 return true
7829 }
7830
7831 func topicSessionIndexForDir(dir string) (topicSessionDirIndex, error) {
7832 key := topicSessionDirKey(dir)
7833 if key == "" {
7834 return topicSessionDirIndex{}, nil
7835 }
7836 signature, sessionNames, err := topicSessionDirSnapshot(key)
7837 if err != nil {
7838 if os.IsNotExist(err) {
7839 return topicSessionDirIndex{}, nil
7840 }
7841 return topicSessionDirIndex{}, err
7842 }
7843 topicSessionIndexCache.Lock()
7844 cached, ok := topicSessionIndexCache.byDir[key]
7845 if ok && topicSessionSignaturesEqual(cached.signature, signature) {
7846 topicSessionIndexCache.Unlock()
7847 return cached, nil
7848 }
7849 topicSessionIndexCache.Unlock()
7850
7851 index := topicSessionDirIndex{
7852 signature: signature,
7853 byTopic: map[string][]topicSessionMatch{},
7854 }
7855 for _, name := range sessionNames {
7856 path := filepath.Join(key, name)
7857 meta, ok, err := agent.LoadBranchMeta(path)
7858 if err != nil || !ok {
7859 continue
7860 }
7861 topicID := strings.TrimSpace(meta.TopicID)
7862 if topicID == "" {
7863 continue
7864 }
7865 index.byTopic[topicID] = append(index.byTopic[topicID], topicSessionMatch{
7866 path: path,
7867 updatedAt: meta.UpdatedAt,
7868 scope: meta.DefaultScope(),
7869 workspaceRoot: meta.WorkspaceRoot,
7870 })
7871 }
7872
7873 topicSessionIndexCache.Lock()
7874 topicSessionIndexCache.byDir[key] = index
7875 topicSessionIndexCache.Unlock()
7876 return index, nil
7877 }
7878
7879 func topicSessionIndexHasContentTopic(index topicSessionDirIndex, topicID string) bool {
7880 matches := index.byTopic[strings.TrimSpace(topicID)]
7881 for _, match := range matches {
7882 if sessionFileHasConversationContent(match.path) {
7883 return true
7884 }
7885 }
7886 return false
7887 }
7888
7889 // topicSessionIndexHasForeignLeaseTopic reports whether any session file
7890 // indexed under topicID is currently lease-held by a runtime other than this
7891 // process. A blank topic can still be lease-held — its session lease keeper
7892 // keeps a leftover blank tab's lease alive across a hide-to-tray close, and a
7893 // stale-but-live holder blocks a genuinely new session from ever settling on
7894 // this path. Reusing it anyway would make the "new" tab collide with that
7895 // holder: every lease-gated switch (effort/model/token mode) would fail as if
7896 // a foreign window owned it, and creating another "new" conversation would
7897 // keep re-picking the same stuck topic (#6028, #6109).
7898 func topicSessionIndexHasForeignLeaseTopic(index topicSessionDirIndex, topicID string) bool {
7899 matches := index.byTopic[strings.TrimSpace(topicID)]
7900 for _, match := range matches {
7901 if agent.SessionLeaseHeldByOtherRuntime(match.path) {
7902 return true
7903 }
7904 }
7905 return false
7906 }
7907
7908 func topicSessionMatches(dir, topicID string) []topicSessionMatch {
7909 index, err := topicSessionIndexForDir(dir)
7910 if err != nil {
7911 return nil
7912 }
7913 matches := index.byTopic[strings.TrimSpace(topicID)]
7914 if len(matches) == 0 {
7915 return nil
7916 }
7917 out := make([]topicSessionMatch, 0, len(matches))
7918 for _, match := range matches {
7919 if agent.IsCleanupPending(match.path) {
7920 continue
7921 }
7922 out = append(out, match)
7923 }
7924 if len(out) == 0 {
7925 return nil
7926 }
7927 return out
7928 }
7929
7930 func invalidateTopicSessionIndex(dir string) {
7931 key := topicSessionDirKey(dir)
7932 if key == "" {
7933 return
7934 }
7935 topicSessionIndexCache.Lock()
7936 delete(topicSessionIndexCache.byDir, key)
7937 topicSessionIndexCache.Unlock()
7938 }
7939
7940 func invalidateTopicSessionIndexForPath(path string) {
7941 path = strings.TrimSpace(path)
7942 if path == "" {
7943 return
7944 }
7945 invalidateTopicSessionIndex(filepath.Dir(path))
7946 }
7947
7948 // findTopicSession returns the most recently updated .jsonl file whose .meta
7949 // carries the given topicID, using a directory-level sidecar index cache.
7950 func findTopicSession(dir, topicID string) string {
7951 if topicID == "" || dir == "" {
7952 return ""
7953 }
7954 var bestPath string
7955 var bestTime time.Time
7956 for _, match := range topicSessionMatches(dir, topicID) {
7957 if match.updatedAt.After(bestTime) {
7958 bestTime = match.updatedAt
7959 bestPath = match.path
7960 }
7961 }
7962 return bestPath
7963 }
7964
7964 lines GO