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