返回 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 "os"
13 "path/filepath"
14 "sort"
15 "strings"
16 "sync"
17 "sync/atomic"
18 "time"
19 "unicode"
20
21 "github.com/wailsapp/wails/v2/pkg/runtime"
22
23 "reasonix/internal/agent"
24 "reasonix/internal/boot"
25 "reasonix/internal/config"
26 "reasonix/internal/control"
27 "reasonix/internal/event"
28 "reasonix/internal/eventwire"
29 "reasonix/internal/extension/providerext"
30 "reasonix/internal/fileutil"
31 "reasonix/internal/notify"
32 "reasonix/internal/provider"
33 "reasonix/internal/store"
34 "reasonix/internal/worktree"
35 )
36
37 // --- WorkspaceTab -----------------------------------------------------------
38
39 // tabDisplayState follows one live runtime across visible, detached, and
40 // reattached WorkspaceTab wrappers. Keeping one shared state pointer closes the
41 // handoff window where an event already routed to the old wrapper could append
42 // after a clone copied its buffers.
43 type tabDisplayState struct {
44 mu sync.Mutex
45 planner displayTurnBuffer
46 executor displayTurnBuffer
47 pendingWrites []*pendingDisplayWrite
48 persistRunning bool
49 }
50
51 const displayPersistRetryLimit = 4
52
53 var errNoDesktopChatModel = errors.New("no desktop chat model is available; add a chat-capable provider in Settings > Model > Access")
54
55 type pendingDisplayWrite struct {
56 dir string
57 sessionPath string
58 userContent string
59 messages []HistoryMessage
60 persist func(string, string, string, []HistoryMessage) error
61 }
62
63 // displayTextAccumulator retains provider chunks without repeatedly copying
64 // the complete prefix. A turn only materializes the final string when its
65 // display-only history is persisted; successful executor turns are discarded
66 // without ever joining their chunks.
67 type displayTextAccumulator struct {
68 parts []string
69 size int
70 }
71
72 func (a *displayTextAccumulator) append(text string) {
73 if text == "" {
74 return
75 }
76 a.parts = append(a.parts, text)
77 a.size += len(text)
78 }
79
80 func (a *displayTextAccumulator) replace(text string) {
81 a.parts = nil
82 a.size = 0
83 a.append(text)
84 }
85
86 func (a *displayTextAccumulator) hasNonWhitespace() bool {
87 for _, part := range a.parts {
88 if strings.TrimSpace(part) != "" {
89 return true
90 }
91 }
92 return false
93 }
94
95 func (a *displayTextAccumulator) string() string {
96 switch len(a.parts) {
97 case 0:
98 return ""
99 case 1:
100 return a.parts[0]
101 }
102 var out strings.Builder
103 out.Grow(a.size)
104 for _, part := range a.parts {
105 out.WriteString(part)
106 }
107 return out.String()
108 }
109
110 type bufferedHistoryMessage struct {
111 message HistoryMessage
112 content displayTextAccumulator
113 reasoning displayTextAccumulator
114 }
115
116 func (m *bufferedHistoryMessage) materialize() HistoryMessage {
117 out := m.message
118 if out.Role == "assistant" {
119 out.Content = m.content.string()
120 out.Reasoning = m.reasoning.string()
121 }
122 if len(out.MemoryCitations) > 0 {
123 out.MemoryCitations = append([]provider.MemoryCitation(nil), out.MemoryCitations...)
124 }
125 if len(out.ToolCalls) > 0 {
126 out.ToolCalls = append([]HistoryToolCall(nil), out.ToolCalls...)
127 }
128 return out
129 }
130
131 type displayTurnBuffer struct {
132 messages []*bufferedHistoryMessage
133 tools map[string]string
134 }
135
136 func (b *displayTurnBuffer) reset() {
137 b.messages = nil
138 b.tools = nil
139 }
140
141 func (b *displayTurnBuffer) materialize() []HistoryMessage {
142 if len(b.messages) == 0 {
143 return nil
144 }
145 out := make([]HistoryMessage, 0, len(b.messages))
146 for _, message := range b.messages {
147 out = append(out, message.materialize())
148 }
149 return out
150 }
151
152 // WorkspaceTab is one open conversation tab in the desktop. Each tab owns an
153 // independent controller (its own agent, session, tool registry, plugin host,
154 // memory, permissions) scoped to a workspace root, so multiple projects and
155 // topics can be active concurrently without interfering.
156 type WorkspaceTab struct {
157 ID string // stable random id
158 Scope string // "project" | "global"
159 WorkspaceRoot string // project root dir (empty for global)
160 SharedHostKey string // opaque key for the shared plugin host (set by buildTabController)
161 TopicID string // topic within the project
162 TopicTitle string // display title
163 topicTitleSource string // auto or manual; controls localization at API boundaries
164 SessionPath string // exact .jsonl file this tab continues
165 ReadOnly bool // true for external channel transcripts opened for browsing
166 Ctrl control.SessionAPI // nil while booting / on error
167 Label string // model label (for the tab badge)
168 Ready bool // true once boot.Build completes
169 StartupErr string // build error, surfaced to the frontend
170 StartupErrLeaseHeld bool // true when StartupErr can be retried after a session lease releases
171 runtimeID string // process-local SessionRuntime registry identity
172 sessionLease *agent.SessionLease
173 sessionLeaseMu sync.Mutex
174 sessionLeaseKey atomic.Pointer[string] // lock-free mirror; updated with sessionLease under sessionLeaseMu
175 sink *tabEventSink // routes events with this tab's ID
176 buildCancel context.CancelFunc // cancels in-flight boot for tabs removed before Ready
177 buildGeneration uint64 // identifies the current in-flight build
178 removed bool // set when the visible tab is pruned/closed before build completes
179 reconcileMu sync.Mutex // serializes stale controller workspace repair for this tab
180 turnStartMu sync.Mutex // serializes foreground turn admission for this tab
181
182 ActivityStatus string // transient project-tree status for the in-flight turn
183
184 // Per-turn autosave per tab.
185 saveMu sync.Mutex
186 saving bool
187 saveAgain bool
188 saveFailures int
189 // lastAutosaveWarnAt debounces the user-facing autosave-failure notice:
190 // a persistently failing disk (AV hold, full volume) otherwise emits a
191 // chat warning for every completed turn. Logs are never debounced.
192 lastAutosaveWarnAt time.Time
193
194 // closing is set under saveMu when the tab is being torn down. Once set,
195 // tabSnapshotLoop stops taking new snapshot work and CloseTab waits on
196 // saveCond until any in-flight snapshot finishes - so no background
197 // snapshot can write a session file back to disk after CloseTab returns.
198 // Without this, deleting a just-closed session races that write and the
199 // session "resurrects" (#4384).
200 closing bool
201 saveCond *sync.Cond
202
203 // readTelemetry tracks files read during this tab's session.
204 readTelemetry []readFileRecord
205 usageTelemetry sessionUsageStats
206 // telemetrySessionKey is the sessionRuntimeKey the telemetry above belongs
207 // to. Controller-side session rotations (typed /new, bot /reset) bypass the
208 // App bindings, so telemetry writers and readers re-key through
209 // syncTelemetryToSession before trusting the in-memory totals — otherwise a
210 // previous session's cost keeps accumulating under the new session and gets
211 // persisted into its sidecar (#5850).
212 telemetrySessionKey string
213 telemMu sync.Mutex
214
215 // Display-only output belongs to the live runtime, not a particular visible
216 // tab wrapper. detach/reattach paths share this state before rebinding the
217 // event sink so output cannot fall into a discarded wrapper.
218 displayStateMu sync.Mutex
219 displayState *tabDisplayState
220
221 model string // active model ref (for meta)
222 effort *string
223 tokenMode string
224 mode string // "normal" | "plan" | "yolo" | "plan-yolo"; yolo/full access is runtime-only
225 goal string
226 toolApprovalMode string
227 disabledMCP map[string]ServerView
228 mcpOrder []string
229 }
230
231 const (
232 topicStatusThinking = "thinking"
233 topicStatusStreaming = "streaming"
234 topicStatusWaitingConfirmation = "waiting_confirmation"
235 topicStatusBackgroundJob = "background_job"
236 topicStatusPaused = "paused"
237 topicStatusError = "error"
238 )
239
240 type readFileRecord struct {
241 Path string `json:"path"`
242 Turn int `json:"turn"`
243 Time int64 `json:"time"`
244 Offset int `json:"offset,omitempty"`
245 Limit int `json:"limit,omitempty"`
246 Truncated bool `json:"truncated,omitempty"`
247 }
248
249 type sessionUsageStats struct {
250 PromptTokens int `json:"promptTokens"`
251 CompletionTokens int `json:"completionTokens"`
252 TotalTokens int `json:"totalTokens"`
253 ReasoningTokens int `json:"reasoningTokens"`
254 CacheHitTokens int `json:"cacheHitTokens"`
255 CacheMissTokens int `json:"cacheMissTokens"`
256 CacheWriteTokens int `json:"cacheWriteTokens,omitempty"`
257 // CacheWriteBilledTokens preserves provider-specific cache-write pricing
258 // across persisted telemetry repricing without changing hit-rate totals.
259 CacheWriteBilledTokens float64 `json:"cacheWriteBilledTokens,omitempty"`
260 Estimated bool `json:"estimated,omitempty"`
261 // LastUsedTokens is the executor-reported context fill (prompt+completion)
262 // from the most recent turn. It is persisted so the status bar / context
263 // panel can show a meaningful fill percentage after a session rebind
264 // rebuilds the controller (which resets the in-memory executor state).
265 LastUsedTokens int `json:"lastUsedTokens,omitempty"`
266 // Per-turn token breakdown from the most recent turn. Persisted separately
267 // from the cumulative totals above so the context-panel donut chart and
268 // type breakdown survive a session rebind (which resets executor.LastUsage).
269 LastPromptTokens int `json:"lastPromptTokens,omitempty"`
270 LastCompletionTokens int `json:"lastCompletionTokens,omitempty"`
271 LastReasoningTokens int `json:"lastReasoningTokens,omitempty"`
272 LastCacheHitTokens int `json:"lastCacheHitTokens,omitempty"`
273 LastCacheMissTokens int `json:"lastCacheMissTokens,omitempty"`
274 LastEstimated bool `json:"lastEstimated,omitempty"`
275 RequestCount int `json:"requestCount"`
276 ElapsedMs int64 `json:"elapsedMs"`
277 SessionCost float64 `json:"sessionCost,omitempty"`
278 SessionCurrency string `json:"sessionCurrency,omitempty"`
279 SessionCostUsd float64 `json:"sessionCostUsd,omitempty"`
280 Sources map[string]usageSourceStats `json:"sources,omitempty"`
281
282 activeTurnStartedAt int64
283 sourceSessionCache map[string]sourceSessionCacheCounters
284 }
285
286 type usageSourceStats struct {
287 PromptTokens int `json:"promptTokens"`
288 CompletionTokens int `json:"completionTokens"`
289 TotalTokens int `json:"totalTokens"`
290 ReasoningTokens int `json:"reasoningTokens"`
291 CacheHitTokens int `json:"cacheHitTokens"`
292 CacheMissTokens int `json:"cacheMissTokens"`
293 CacheWriteTokens int `json:"cacheWriteTokens,omitempty"`
294 CacheWriteBilledTokens float64 `json:"cacheWriteBilledTokens,omitempty"`
295 Estimated bool `json:"estimated,omitempty"`
296 RequestCount int `json:"requestCount"`
297 SessionCost float64 `json:"sessionCost,omitempty"`
298 SessionCurrency string `json:"sessionCurrency,omitempty"`
299 SessionCostUsd float64 `json:"sessionCostUsd,omitempty"`
300 }
301
302 type sourceSessionCacheCounters struct {
303 Hit int
304 Miss int
305 }
306
307 func cloneSessionUsageStats(in sessionUsageStats) sessionUsageStats {
308 out := in
309 if len(in.Sources) > 0 {
310 out.Sources = make(map[string]usageSourceStats, len(in.Sources))
311 for source, stats := range in.Sources {
312 out.Sources[source] = stats
313 }
314 }
315 if len(in.sourceSessionCache) > 0 {
316 out.sourceSessionCache = make(map[string]sourceSessionCacheCounters, len(in.sourceSessionCache))
317 for source, counters := range in.sourceSessionCache {
318 out.sourceSessionCache[source] = counters
319 }
320 }
321 return out
322 }
323
324 func (s *sessionUsageStats) cacheTokenDelta(source string, u *provider.Usage, sessionHit, sessionMiss int) (hit, miss int) {
325 if u != nil {
326 hit = u.CacheHitTokens
327 miss = u.CacheMissTokens
328 }
329 if source != event.UsageSourceExecutor && source != event.UsageSourcePlanner {
330 return hit, miss
331 }
332 if sessionHit+sessionMiss <= 0 {
333 return hit, miss
334 }
335 if s.sourceSessionCache == nil {
336 s.sourceSessionCache = map[string]sourceSessionCacheCounters{}
337 }
338 prev, ok := s.sourceSessionCache[source]
339 s.sourceSessionCache[source] = sourceSessionCacheCounters{Hit: sessionHit, Miss: sessionMiss}
340 if !ok {
341 return sessionHit, sessionMiss
342 }
343 if sessionHit < prev.Hit || sessionMiss < prev.Miss {
344 if hit+miss > 0 {
345 return hit, miss
346 }
347 return sessionHit, sessionMiss
348 }
349 return sessionHit - prev.Hit, sessionMiss - prev.Miss
350 }
351
352 type tabTelemetrySnapshot struct {
353 Version int `json:"version"`
354 ReadFiles []readFileRecord `json:"readFiles"`
355 Usage sessionUsageStats `json:"usage"`
356 }
357
358 func cloneStringPtr(v *string) *string {
359 if v == nil {
360 return nil
361 }
362 cp := *v
363 return &cp
364 }
365
366 func cloneServerViewMap(in map[string]ServerView) map[string]ServerView {
367 out := make(map[string]ServerView, len(in))
368 for name, view := range in {
369 view.EnvKeys = append([]string(nil), view.EnvKeys...)
370 view.HeaderKeys = append([]string(nil), view.HeaderKeys...)
371 out[name] = view
372 }
373 return out
374 }
375
376 func (t *WorkspaceTab) currentSessionPath() string {
377 if t == nil {
378 return ""
379 }
380 tabPath := strings.TrimSpace(t.SessionPath)
381 // Recovery handoff is two-phase: the desktop callback acquires the new
382 // lease and updates SessionPath before Controller commits its own path. The
383 // lease-backed tab path is authoritative during that window; otherwise a
384 // concurrent, newer tab-layout save can overwrite the recovery anchor with
385 // the controller's old path. Outside a handoff, keep the controller-first
386 // behavior so an unleased/stale tab field cannot mask the live runtime.
387 if tabPath != "" && sessionRuntimeKey(tabPath) == t.sessionLeaseRuntimeKey() {
388 return tabPath
389 }
390 if t.Ctrl != nil {
391 if path := strings.TrimSpace(t.Ctrl.SessionPath()); path != "" {
392 return path
393 }
394 }
395 return tabPath
396 }
397
398 func (t *WorkspaceTab) hasActiveRuntimeWork() bool {
399 if t == nil || t.Ctrl == nil {
400 return false
401 }
402 status := t.Ctrl.RuntimeStatus()
403 return status.Running || status.PendingPrompt || status.BackgroundJobs > 0
404 }
405
406 // sessionRuntimeKey is the comparison/map key for "same session" checks. It
407 // layers agent.CanonicalSessionPath on top of the desktop path normalization
408 // so the key matches the form held by session leases (lowercased on Windows).
409 // Comparing a lease's Path() against a raw tab path without this fold made
410 // every rebuild on Windows look like a foreign holder (self-lock, #5999).
411 // Keys are identities only — never use them as display or file paths.
412 func sessionRuntimeKey(path string) string {
413 return agent.CanonicalSessionPath(canonicalTabSessionPath(path))
414 }
415
416 var sessionLeaseAcquireHookForTest func()
417
418 func (t *WorkspaceTab) ensureSessionLease(path string) error {
419 if t == nil || t.ReadOnly {
420 return nil
421 }
422 key := sessionRuntimeKey(path)
423 if key == "" {
424 return nil
425 }
426 t.sessionLeaseMu.Lock()
427 if t.sessionLease != nil && sessionRuntimeKey(t.sessionLease.Path()) == key {
428 t.storeSessionLeaseRuntimeKey(key)
429 t.sessionLeaseMu.Unlock()
430 return nil
431 }
432 lease, err := agent.TryAcquireSessionLease(key)
433 if err != nil {
434 t.sessionLeaseMu.Unlock()
435 return err
436 }
437 if hook := sessionLeaseAcquireHookForTest; hook != nil {
438 hook()
439 }
440 old := t.sessionLease
441 t.sessionLease = lease
442 t.storeSessionLeaseRuntimeKey(key)
443 t.sessionLeaseMu.Unlock()
444 if old != nil {
445 old.Release()
446 }
447 return nil
448 }
449
450 func (t *WorkspaceTab) releaseSessionLease() {
451 if t == nil {
452 return
453 }
454 t.sessionLeaseMu.Lock()
455 lease := t.sessionLease
456 t.sessionLease = nil
457 t.storeSessionLeaseRuntimeKey("")
458 t.sessionLeaseMu.Unlock()
459 if lease != nil {
460 lease.Release()
461 }
462 }
463
464 // takeSessionLease removes and returns the tab's current lease WITHOUT
465 // releasing it, so ownership can transfer to another holder. All access to
466 // t.sessionLease must go through sessionLeaseMu; never read or assign the
467 // field directly outside these helpers.
468 func (t *WorkspaceTab) takeSessionLease() *agent.SessionLease {
469 if t == nil {
470 return nil
471 }
472 t.sessionLeaseMu.Lock()
473 lease := t.sessionLease
474 t.sessionLease = nil
475 t.storeSessionLeaseRuntimeKey("")
476 t.sessionLeaseMu.Unlock()
477 return lease
478 }
479
480 // adoptSessionLease installs lease as the tab's session lease, releasing any
481 // previously held lease unless it is the very same lease. A nil tab releases
482 // the lease immediately so ownership is never dropped on the floor.
483 func (t *WorkspaceTab) adoptSessionLease(lease *agent.SessionLease) {
484 if t == nil {
485 if lease != nil {
486 lease.Release()
487 }
488 return
489 }
490 t.sessionLeaseMu.Lock()
491 old := t.sessionLease
492 t.sessionLease = lease
493 key := ""
494 if lease != nil {
495 key = sessionRuntimeKey(lease.Path())
496 }
497 t.storeSessionLeaseRuntimeKey(key)
498 t.sessionLeaseMu.Unlock()
499 if old != nil && old != lease {
500 old.Release()
501 }
502 }
503
504 func (t *WorkspaceTab) storeSessionLeaseRuntimeKey(key string) {
505 if t == nil || key == "" {
506 if t != nil {
507 t.sessionLeaseKey.Store(nil)
508 }
509 return
510 }
511 stored := key
512 t.sessionLeaseKey.Store(&stored)
513 }
514
515 // sessionLeaseRuntimeKey reports the runtime key of the currently held lease,
516 // or "" when no lease is held. The mirror is lock-free so callers holding
517 // App.mu never wait on a concurrent lease acquisition (whose test hook and
518 // platform file operations run under sessionLeaseMu).
519 func (t *WorkspaceTab) sessionLeaseRuntimeKey() string {
520 if t == nil {
521 return ""
522 }
523 key := t.sessionLeaseKey.Load()
524 if key == nil {
525 return ""
526 }
527 return *key
528 }
529
530 // releaseSessionLeaseForKey releases the tab's lease only when it is bound to
531 // key. Superseded builds clean up with this instead of releaseSessionLease:
532 // on a removed tab the keys match and the lease is released as before, but
533 // when a session rebind superseded the build, the rebind's replacement build
534 // holds a lease for a *different* session key (rebind early-returns on equal
535 // keys), and releasing that here would strip the live session's protection.
536 func (t *WorkspaceTab) releaseSessionLeaseForKey(key string) {
537 if t == nil || key == "" {
538 return
539 }
540 t.sessionLeaseMu.Lock()
541 lease := t.sessionLease
542 if lease == nil || sessionRuntimeKey(lease.Path()) != key {
543 t.sessionLeaseMu.Unlock()
544 return
545 }
546 t.sessionLease = nil
547 t.storeSessionLeaseRuntimeKey("")
548 t.sessionLeaseMu.Unlock()
549 lease.Release()
550 }
551
552 func detachedRuntimeTabID(key string) string {
553 sum := sha256.Sum256([]byte(key))
554 return "detached_" + hex.EncodeToString(sum[:8])
555 }
556
557 func (a *App) ensureDetachedSessionsLocked() {
558 if a.detachedSessions == nil {
559 a.detachedSessions = map[string]*WorkspaceTab{}
560 }
561 }
562
563 func (a *App) runtimeTabsLocked() []*WorkspaceTab {
564 seen := map[*WorkspaceTab]bool{}
565 out := make([]*WorkspaceTab, 0, len(a.tabs)+len(a.detachedSessions))
566 for _, tab := range a.tabs {
567 if tab != nil && !seen[tab] {
568 seen[tab] = true
569 out = append(out, tab)
570 }
571 }
572 for _, tab := range a.detachedSessions {
573 if tab != nil && !seen[tab] {
574 seen[tab] = true
575 out = append(out, tab)
576 }
577 }
578 return out
579 }
580
581 func (a *App) tabByEventSinkIDLocked(tabID string) *WorkspaceTab {
582 if tab := a.tabs[tabID]; tab != nil {
583 return tab
584 }
585 for _, tab := range a.detachedSessions {
586 if tab != nil && tab.ID == tabID {
587 return tab
588 }
589 }
590 return nil
591 }
592
593 func (a *App) detachSessionRuntime(tab *WorkspaceTab) bool {
594 if tab == nil {
595 return false
596 }
597 a.mu.RLock()
598 ctrl := tab.Ctrl
599 fallbackPath := strings.TrimSpace(tab.SessionPath)
600 sink := tab.sink
601 a.mu.RUnlock()
602 path := fallbackPath
603 if ctrl != nil {
604 if p := strings.TrimSpace(ctrl.SessionPath()); p != "" {
605 path = p
606 }
607 }
608 key := sessionRuntimeKey(path)
609 if key == "" {
610 return false
611 }
612 if sink != nil {
613 sink.clearContext()
614 }
615 a.mu.Lock()
616 a.ensureDetachedSessionsLocked()
617 tab.SessionPath = canonicalTabSessionPath(path)
618 a.bindSessionRuntimeKeyLocked(tab, path)
619 a.detachedSessions[key] = tab
620 a.mu.Unlock()
621 return true
622 }
623
624 // cloneDetachedRuntimeTab copies a running tab's runtime state into a fresh
625 // detached tab. Callers must hold a.mu: the copied fields (Ctrl, Ready,
626 // ActivityStatus, disabledMCP, ...) are written under a.mu by bound methods
627 // and the event sink, and the disabledMCP map read would otherwise race those
628 // writers. The session lease is transferred separately by the caller through
629 // the sessionLeaseMu helpers. key is the runtime identity (map key / tab id
630 // hash); path is the real session path — keys are case-folded on Windows and
631 // must not leak into SessionPath, which is displayed and persisted.
632 func cloneDetachedRuntimeTab(tab *WorkspaceTab, key, path string) *WorkspaceTab {
633 if tab == nil {
634 return nil
635 }
636 tab.telemMu.Lock()
637 readTelemetry := append([]readFileRecord(nil), tab.readTelemetry...)
638 usageTelemetry := cloneSessionUsageStats(tab.usageTelemetry)
639 telemetrySessionKey := tab.telemetrySessionKey
640 tab.telemMu.Unlock()
641
642 return &WorkspaceTab{
643 ID: detachedRuntimeTabID(key),
644 Scope: tab.Scope,
645 WorkspaceRoot: tab.WorkspaceRoot,
646 SharedHostKey: tab.SharedHostKey,
647 TopicID: tab.TopicID,
648 TopicTitle: tab.TopicTitle,
649 topicTitleSource: tab.topicTitleSource,
650 SessionPath: canonicalTabSessionPath(path),
651 Ctrl: tab.Ctrl,
652 Label: tab.Label,
653 Ready: tab.Ready,
654 StartupErr: tab.StartupErr,
655 StartupErrLeaseHeld: tab.StartupErrLeaseHeld,
656 runtimeID: tab.runtimeID,
657 sink: tab.sink,
658 ActivityStatus: tab.ActivityStatus,
659 readTelemetry: readTelemetry,
660 usageTelemetry: usageTelemetry,
661 telemetrySessionKey: telemetrySessionKey,
662 displayState: tab.displayBufferState(),
663 model: tab.model,
664 effort: cloneStringPtr(tab.effort),
665 tokenMode: tab.tokenMode,
666 mode: tab.mode,
667 goal: tab.goal,
668 toolApprovalMode: tab.toolApprovalMode,
669 disabledMCP: cloneServerViewMap(tab.disabledMCP),
670 mcpOrder: append([]string(nil), tab.mcpOrder...),
671 }
672 }
673
674 func (a *App) detachRuntimeForReplacement(tab *WorkspaceTab) bool {
675 if tab == nil {
676 return false
677 }
678 // One a.mu critical section covers the membership check, the field
679 // snapshot, the lease/sink handover, and the re-publication:
680 // - the clone reads fields that bound methods and the event sink write
681 // under a.mu (ActivityStatus every event, disabledMCP is a map);
682 // - inserting the clone without re-checking a.tabs would resurrect a
683 // runtime that DeleteSession/TrashTopic/RemoveWorkspace already
684 // unlinked and closed (the "session resurrects" class, #4384);
685 // - publishing before the lease/sink handover would let a concurrent
686 // attachExistingSessionRuntime claim a half-initialized clone.
687 // The lease transfer stays deadlock-safe here: neither side holds a lease
688 // to release, so no lease I/O runs under a.mu.
689 a.mu.Lock()
690 detached := a.detachRuntimeForReplacementLocked(tab)
691 a.mu.Unlock()
692 return detached
693 }
694
695 // detachRuntimeForReplacementLocked transfers a visible tab's live runtime to
696 // the detached registry without closing its controller or releasing its lease.
697 // Callers must hold App.mu. The transfer itself performs no file or host I/O.
698 func (a *App) detachRuntimeForReplacementLocked(tab *WorkspaceTab) bool {
699 if tab == nil {
700 return false
701 }
702 if tab.removed || a.tabs[tab.ID] != tab {
703 return false
704 }
705 sourcePath := tab.currentSessionPath()
706 key := sessionRuntimeKey(sourcePath)
707 if key == "" {
708 return false
709 }
710 detached := cloneDetachedRuntimeTab(tab, key, sourcePath)
711 if detached == nil {
712 return false
713 }
714 // Transfer lease ownership through the locked helpers: a concurrent
715 // ensureSessionLease (blank-session boot, recovery callback) must never
716 // observe a torn pointer or have its freshly acquired lease clobbered.
717 detached.adoptSessionLease(tab.takeSessionLease())
718 if rt := a.runtimeForTabLocked(tab); rt != nil {
719 rt.Owner = detached
720 detached.runtimeID = rt.ID
721 tab.runtimeID = ""
722 }
723 if detached.sink != nil {
724 detached.sink.setBinding(detached.ID, nil)
725 // clearContext (locked nil + drain the queued emitter), not a bare
726 // ctx=nil: the latter both data-races s.ctx and leaves already-queued
727 // events to flush onto the rebound tab after this session is backgrounded
728 // (#5352 — stale "AI 不断输出" on the now-visible session).
729 detached.sink.clearContext()
730 }
731 a.ensureDetachedSessionsLocked()
732 a.detachedSessions[key] = detached
733 return true
734 }
735
736 // applyRuntimeTab moves source's runtime (controller, sink, lease, telemetry)
737 // onto target. path is the real session path for display/persistence; the
738 // case-folded runtime key must never be written into SessionPath.
739 func applyRuntimeTab(target, source *WorkspaceTab, path string, wailsCtx context.Context, app *App) {
740 if target == nil || source == nil {
741 return
742 }
743 source.telemMu.Lock()
744 readTelemetry := append([]readFileRecord(nil), source.readTelemetry...)
745 usageTelemetry := cloneSessionUsageStats(source.usageTelemetry)
746 telemetrySessionKey := source.telemetrySessionKey
747 source.telemMu.Unlock()
748
749 // Share the runtime-owned display state before rebinding the sink. An event
750 // already routed to source and one arriving on target after setBinding then
751 // append under the same state lock instead of straddling two buffers.
752 target.adoptDisplayState(source.displayBufferState())
753 if source.sink != nil {
754 source.sink.setBinding(target.ID, app)
755 source.sink.setContext(wailsCtx)
756 }
757
758 target.Ctrl = source.Ctrl
759 target.sink = source.sink
760 target.adoptSessionLease(source.takeSessionLease())
761 target.SessionPath = canonicalTabSessionPath(path)
762 target.SharedHostKey = source.SharedHostKey
763 target.Label = source.Label
764 target.Ready = source.Ready && source.Ctrl != nil
765 clearTabStartupError(target)
766 target.ActivityStatus = source.ActivityStatus
767 target.model = source.model
768 target.effort = cloneStringPtr(source.effort)
769 target.tokenMode = source.tokenMode
770 target.mode = source.mode
771 target.goal = source.goal
772 target.toolApprovalMode = source.toolApprovalMode
773 target.disabledMCP = cloneServerViewMap(source.disabledMCP)
774 target.mcpOrder = append([]string(nil), source.mcpOrder...)
775 target.readTelemetry = readTelemetry
776 target.usageTelemetry = usageTelemetry
777 target.telemetrySessionKey = telemetrySessionKey
778 if app != nil {
779 key := sessionRuntimeKey(path)
780 rt := app.runtimeForTabLocked(source)
781 targetRuntime := app.runtimeForTabLocked(target)
782 if rt == nil {
783 rt = targetRuntime
784 }
785 if rt == nil {
786 rt = app.newSessionRuntimeLocked(source, key)
787 } else if targetRuntime != nil && targetRuntime != rt {
788 app.removeSessionRuntimeMappingsLocked(targetRuntime)
789 target.runtimeID = ""
790 }
791 if source.Ctrl != nil && source.Ready {
792 rt.Phase = sessionRuntimeReady
793 rt.Issue = nil
794 closeRuntimeReadyChannelLocked(rt)
795 }
796 rt.Owner = target
797 if rt.Key != "" && rt.Key != key && app.runtimeBySessionKey[rt.Key] == rt {
798 delete(app.runtimeBySessionKey, rt.Key)
799 }
800 rt.Key = key
801 app.runtimeBySessionKey[key] = rt
802 target.runtimeID = rt.ID
803 source.runtimeID = ""
804 if target.sink != nil {
805 target.sink.setRuntimeEpoch(rt.Epoch)
806 }
807 }
808 }
809
810 func (a *App) attachExistingSessionRuntime(tab *WorkspaceTab, path string, wailsCtx context.Context) bool {
811 key := sessionRuntimeKey(path)
812 if tab == nil || key == "" {
813 return false
814 }
815
816 a.mu.Lock()
817 if tab.removed || a.tabs[tab.ID] != tab {
818 a.mu.Unlock()
819 return false
820 }
821 if rt := a.runtimeBySessionKey[key]; rt != nil && !a.runtimeOwnerLiveLocked(rt) {
822 a.removeSessionRuntimeMappingsLocked(rt)
823 }
824 registered := a.runtimeBySessionKey[key]
825 if registered != nil && registered.Phase == sessionRuntimeStarting && registered.Owner != tab {
826 // A starting runtime owns only an admission placeholder; its controller,
827 // lease, and sink have not been published yet. Moving that tab would
828 // supersede the owner build while the attaching build closes its own
829 // candidate, leaving the session permanently starting with no controller.
830 // claimSessionRuntime waits on readyCh and retries the attach after the
831 // owner publishes a terminal phase. The owner itself may still adopt a
832 // usable legacy runtime that predates the registry.
833 a.mu.Unlock()
834 return false
835 }
836 attachable := func(source *WorkspaceTab) bool {
837 if source == nil || source.Ctrl == nil {
838 return false
839 }
840 if rt := a.runtimeForTabLocked(source); rt != nil {
841 return rt.Phase == sessionRuntimeReady
842 }
843 // Compatibility for visible/detached runtimes constructed before the
844 // process-local registry existed.
845 return source.Ready
846 }
847 detached := a.detachedSessions[key]
848 if detached == nil {
849 if rt := a.runtimeBySessionKey[key]; rt != nil && rt.Owner != nil && rt.Owner != tab {
850 detached = rt.Owner
851 if a.tabs[detached.ID] != detached {
852 delete(a.detachedSessions, key)
853 } else {
854 detached = nil
855 }
856 }
857 }
858 if detached != nil {
859 if !attachable(detached) {
860 a.mu.Unlock()
861 return false
862 }
863 delete(a.detachedSessions, key)
864 applyRuntimeTab(tab, detached, path, wailsCtx, a)
865 if current := a.tabs[tab.ID]; current == tab {
866 a.saveTabsLocked()
867 }
868 attachedCtrl := tab.Ctrl
869 a.mu.Unlock()
870 if attachedCtrl != nil {
871 attachedCtrl.ReplayPendingPrompts()
872 }
873 return true
874 }
875
876 var source *WorkspaceTab
877 if rt := a.runtimeBySessionKey[key]; rt != nil && rt.Owner != nil && rt.Owner != tab {
878 source = rt.Owner
879 }
880 for _, candidate := range a.tabs {
881 if source != nil {
882 break
883 }
884 if candidate == nil || candidate == tab {
885 continue
886 }
887 if sessionRuntimeKey(candidate.currentSessionPath()) == key {
888 source = candidate
889 break
890 }
891 }
892 if source == nil {
893 a.mu.Unlock()
894 return false
895 }
896 if !attachable(source) {
897 a.mu.Unlock()
898 return false
899 }
900 delete(a.tabs, source.ID)
901 a.removeTabOrderLocked(source.ID)
902 if a.activeTabID == source.ID {
903 a.activeTabID = tab.ID
904 }
905 applyRuntimeTab(tab, source, path, wailsCtx, a)
906 a.saveTabsLocked()
907 attachedCtrl := tab.Ctrl
908 a.mu.Unlock()
909
910 if attachedCtrl != nil {
911 attachedCtrl.ReplayPendingPrompts()
912 }
913 return true
914 }
915
916 func (t *WorkspaceTab) recordReadFile(rec readFileRecord) {
917 t.telemMu.Lock()
918 t.readTelemetry = append(t.readTelemetry, rec)
919 t.telemMu.Unlock()
920 }
921
922 func (t *WorkspaceTab) recordTurnStarted(now int64) {
923 t.telemMu.Lock()
924 if t.usageTelemetry.activeTurnStartedAt == 0 {
925 t.usageTelemetry.activeTurnStartedAt = now
926 }
927 t.telemMu.Unlock()
928 }
929
930 func (t *WorkspaceTab) recordTurnDone(now int64) {
931 t.telemMu.Lock()
932 if started := t.usageTelemetry.activeTurnStartedAt; started > 0 && now >= started {
933 t.usageTelemetry.ElapsedMs += now - started
934 t.usageTelemetry.activeTurnStartedAt = 0
935 }
936 t.telemMu.Unlock()
937 }
938
939 // contextTelemetryFromUsage returns the latest-attempt context shape for
940 // rebind-surviving Last* telemetry fields. Prefer Context* when set (multi-
941 // attempt sampling recovery); otherwise fall back to billable totals / the
942 // per-event cache delta already computed for this Usage event.
943 //
944 // When a Context shape is present, ContextCacheHit/Miss are kept even if both
945 // are zero — many providers omit cache splits, and falling back to the
946 // event's aggregated cache would re-inflate multi-attempt totals.
947 func contextTelemetryFromUsage(u *provider.Usage, eventCacheHit, eventCacheMiss int) (prompt, completion, reasoning, hit, miss int) {
948 if u == nil {
949 return 0, 0, 0, eventCacheHit, eventCacheMiss
950 }
951 if u.ContextPromptTokens > 0 || u.ContextCompletionTokens > 0 {
952 return u.ContextPromptTokens, u.ContextCompletionTokens, u.ContextReasoningTokens,
953 u.ContextCacheHitTokens, u.ContextCacheMissTokens
954 }
955 return u.PromptTokens, u.CompletionTokens, u.ReasoningTokens, eventCacheHit, eventCacheMiss
956 }
957
958 func (t *WorkspaceTab) recordUsage(e event.Event) {
959 if e.Usage == nil {
960 return
961 }
962 u := e.Usage
963 source := strings.TrimSpace(e.UsageSource)
964 if source == "" {
965 source = event.UsageSourceExecutor
966 }
967 t.telemMu.Lock()
968 t.usageTelemetry.PromptTokens += u.PromptTokens
969 t.usageTelemetry.CompletionTokens += u.CompletionTokens
970 t.usageTelemetry.TotalTokens += u.TotalTokens
971 t.usageTelemetry.ReasoningTokens += u.ReasoningTokens
972 cacheHitTokens, cacheMissTokens := t.usageTelemetry.cacheTokenDelta(source, u, e.SessionHit, e.SessionMiss)
973 t.usageTelemetry.CacheHitTokens += cacheHitTokens
974 t.usageTelemetry.CacheMissTokens += cacheMissTokens
975 t.usageTelemetry.CacheWriteTokens += u.CacheWriteTokens
976 t.usageTelemetry.CacheWriteBilledTokens += u.CacheWriteBilledTokens
977 t.usageTelemetry.Estimated = t.usageTelemetry.Estimated || u.Estimated
978 requestCount := u.RequestCount
979 if requestCount <= 0 {
980 requestCount = 1
981 }
982 t.usageTelemetry.RequestCount += requestCount
983 if source == event.UsageSourceExecutor {
984 // Persist the latest-attempt context shape for rebind fallback — never
985 // the multi-attempt billable aggregate (PromptTokens/CompletionTokens
986 // after stream recovery). ContextSnapshot semantics are latest
987 // prompt+completion; Context* fields carry that shape.
988 prompt, completion, reasoning, hit, miss := contextTelemetryFromUsage(u, cacheHitTokens, cacheMissTokens)
989 t.usageTelemetry.LastUsedTokens = prompt + completion
990 t.usageTelemetry.LastPromptTokens = prompt
991 t.usageTelemetry.LastCompletionTokens = completion
992 t.usageTelemetry.LastReasoningTokens = reasoning
993 t.usageTelemetry.LastCacheHitTokens = hit
994 t.usageTelemetry.LastCacheMissTokens = miss
995 t.usageTelemetry.LastEstimated = u.Estimated
996 }
997 if t.usageTelemetry.Sources == nil {
998 t.usageTelemetry.Sources = map[string]usageSourceStats{}
999 }
1000 src := t.usageTelemetry.Sources[source]
1001 src.PromptTokens += u.PromptTokens
1002 src.CompletionTokens += u.CompletionTokens
1003 src.TotalTokens += u.TotalTokens
1004 src.ReasoningTokens += u.ReasoningTokens
1005 src.CacheHitTokens += cacheHitTokens
1006 src.CacheMissTokens += cacheMissTokens
1007 src.CacheWriteTokens += u.CacheWriteTokens
1008 src.CacheWriteBilledTokens += u.CacheWriteBilledTokens
1009 src.Estimated = src.Estimated || u.Estimated
1010 src.RequestCount += requestCount
1011 if e.Pricing != nil {
1012 currency := e.Pricing.Symbol()
1013 if existing := strings.TrimSpace(t.usageTelemetry.SessionCurrency); existing != "" && existing != currency {
1014 // A scalar total cannot represent mixed currencies. Regional DeepSeek
1015 // changes normally reprice prior usage; this fallback prevents custom
1016 // or otherwise unmappable currencies from being summed together.
1017 t.usageTelemetry.SessionCost = 0
1018 t.usageTelemetry.SessionCostUsd = 0
1019 for sourceName, sourceStats := range t.usageTelemetry.Sources {
1020 sourceStats.SessionCost = 0
1021 sourceStats.SessionCostUsd = 0
1022 sourceStats.SessionCurrency = currency
1023 t.usageTelemetry.Sources[sourceName] = sourceStats
1024 }
1025 src.SessionCost = 0
1026 src.SessionCostUsd = 0
1027 }
1028 cost := e.Pricing.Cost(u)
1029 t.usageTelemetry.SessionCost += cost
1030 t.usageTelemetry.SessionCostUsd = t.usageTelemetry.SessionCost
1031 t.usageTelemetry.SessionCurrency = currency
1032 src.SessionCost += cost
1033 src.SessionCostUsd = src.SessionCost
1034 src.SessionCurrency = currency
1035 }
1036 t.usageTelemetry.Sources[source] = src
1037 t.telemMu.Unlock()
1038 }
1039
1040 func usageStatsAsProviderUsage(stats usageSourceStats) *provider.Usage {
1041 return &provider.Usage{
1042 PromptTokens: stats.PromptTokens,
1043 CompletionTokens: stats.CompletionTokens,
1044 TotalTokens: stats.TotalTokens,
1045 ReasoningTokens: stats.ReasoningTokens,
1046 CacheHitTokens: stats.CacheHitTokens,
1047 CacheMissTokens: stats.CacheMissTokens,
1048 CacheWriteTokens: stats.CacheWriteTokens,
1049 CacheWriteBilledTokens: stats.CacheWriteBilledTokens,
1050 Estimated: stats.Estimated,
1051 }
1052 }
1053
1054 func sessionStatsAsProviderUsage(stats sessionUsageStats) *provider.Usage {
1055 return &provider.Usage{
1056 PromptTokens: stats.PromptTokens,
1057 CompletionTokens: stats.CompletionTokens,
1058 TotalTokens: stats.TotalTokens,
1059 ReasoningTokens: stats.ReasoningTokens,
1060 CacheHitTokens: stats.CacheHitTokens,
1061 CacheMissTokens: stats.CacheMissTokens,
1062 CacheWriteTokens: stats.CacheWriteTokens,
1063 CacheWriteBilledTokens: stats.CacheWriteBilledTokens,
1064 Estimated: stats.Estimated,
1065 }
1066 }
1067
1068 // repriceUsage replaces the scalar cost total using per-source pricing. It
1069 // leaves telemetry untouched when any costed source cannot be mapped to one
1070 // currency.
1071 func (t *WorkspaceTab) repriceUsage(pricingBySource map[string]*provider.Pricing) bool {
1072 if t == nil {
1073 return false
1074 }
1075 t.telemMu.Lock()
1076 defer t.telemMu.Unlock()
1077 if t.usageTelemetry.SessionCost <= 0 {
1078 return true
1079 }
1080 if len(t.usageTelemetry.Sources) == 0 {
1081 pricing := pricingBySource[event.UsageSourceExecutor]
1082 if pricing == nil {
1083 return false
1084 }
1085 t.usageTelemetry.SessionCost = pricing.Cost(sessionStatsAsProviderUsage(t.usageTelemetry))
1086 t.usageTelemetry.SessionCostUsd = t.usageTelemetry.SessionCost
1087 t.usageTelemetry.SessionCurrency = pricing.Symbol()
1088 return true
1089 }
1090
1091 currency := ""
1092 total := 0.0
1093 repriced := make(map[string]usageSourceStats, len(t.usageTelemetry.Sources))
1094 for source, stats := range t.usageTelemetry.Sources {
1095 if stats.SessionCost <= 0 {
1096 repriced[source] = stats
1097 continue
1098 }
1099 pricing := pricingBySource[source]
1100 if pricing == nil {
1101 return false
1102 }
1103 symbol := pricing.Symbol()
1104 if currency != "" && currency != symbol {
1105 return false
1106 }
1107 currency = symbol
1108 stats.SessionCost = pricing.Cost(usageStatsAsProviderUsage(stats))
1109 stats.SessionCostUsd = stats.SessionCost
1110 stats.SessionCurrency = symbol
1111 total += stats.SessionCost
1112 repriced[source] = stats
1113 }
1114 if currency == "" {
1115 return true
1116 }
1117 t.usageTelemetry.Sources = repriced
1118 t.usageTelemetry.SessionCost = total
1119 t.usageTelemetry.SessionCostUsd = total
1120 t.usageTelemetry.SessionCurrency = currency
1121 return true
1122 }
1123
1124 func resolveOfficialDeepSeekPricing(cfg *config.Config, ref string) *provider.Pricing {
1125 if cfg == nil || strings.TrimSpace(ref) == "" {
1126 return nil
1127 }
1128 entry, ok := cfg.ResolveModel(ref)
1129 if !ok || !config.IsOfficialDeepSeekProvider(entry) || !config.IsKnownDeepSeekOfficialPricing(entry.Model, entry.Price) {
1130 return nil
1131 }
1132 return entry.Price
1133 }
1134
1135 func firstConfiguredModelRef(values ...string) string {
1136 for _, value := range values {
1137 if value = strings.TrimSpace(value); value != "" {
1138 return value
1139 }
1140 }
1141 return ""
1142 }
1143
1144 func usagePricingBySource(cfg *config.Config, executorRef string) map[string]*provider.Pricing {
1145 if cfg == nil {
1146 return nil
1147 }
1148 executorPricing := resolveOfficialDeepSeekPricing(cfg, executorRef)
1149 plannerRef := firstConfiguredModelRef(cfg.Agent.PlannerModel, executorRef)
1150 subagentRef := firstConfiguredModelRef(cfg.Agent.SubagentModel, executorRef)
1151 routerRef := firstConfiguredModelRef(cfg.Agent.SubagentModels[event.UsageSourceCapabilityRouter], subagentRef, executorRef)
1152 recoveryRef := firstConfiguredModelRef(cfg.Agent.RecoveryModel, cfg.Agent.GuardianModel, executorRef)
1153 return map[string]*provider.Pricing{
1154 event.UsageSourceExecutor: executorPricing,
1155 event.UsageSourcePlanner: resolveOfficialDeepSeekPricing(cfg, plannerRef),
1156 event.UsageSourceSubagent: resolveOfficialDeepSeekPricing(cfg, subagentRef),
1157 event.UsageSourceCompaction: executorPricing,
1158 event.UsageSourceClassifier: executorPricing,
1159 event.UsageSourceTitle: executorPricing,
1160 event.UsageSourceCapabilityRouter: resolveOfficialDeepSeekPricing(cfg, routerRef),
1161 event.UsageSourceRecoveryReviewer: resolveOfficialDeepSeekPricing(cfg, recoveryRef),
1162 }
1163 }
1164
1165 func (a *App) repriceTabUsageForCurrentCurrency(tab *WorkspaceTab) {
1166 if a == nil || tab == nil {
1167 return
1168 }
1169 a.mu.RLock()
1170 root := tab.WorkspaceRoot
1171 model := tab.model
1172 a.mu.RUnlock()
1173 cfg, err := config.LoadForRoot(root)
1174 if err != nil {
1175 return
1176 }
1177 cfg.ApplyRuntimeAutoPricingCurrency(a.desktopAutoPricingCurrency())
1178 if !tab.repriceUsage(usagePricingBySource(cfg, model)) {
1179 return
1180 }
1181 if path := tab.currentSessionPath(); path != "" {
1182 _ = saveTelemetry(path+".telemetry.json", tab.telemetrySnapshot())
1183 }
1184 }
1185
1186 func (t *WorkspaceTab) telemetrySnapshot() tabTelemetrySnapshot {
1187 t.telemMu.Lock()
1188 defer t.telemMu.Unlock()
1189 records := make([]readFileRecord, len(t.readTelemetry))
1190 copy(records, t.readTelemetry)
1191 usage := t.usageTelemetry
1192 if started := usage.activeTurnStartedAt; started > 0 {
1193 now := time.Now().UnixMilli()
1194 if now >= started {
1195 usage.ElapsedMs += now - started
1196 }
1197 }
1198 if len(t.usageTelemetry.Sources) > 0 {
1199 usage.Sources = make(map[string]usageSourceStats, len(t.usageTelemetry.Sources))
1200 for source, stats := range t.usageTelemetry.Sources {
1201 usage.Sources[source] = stats
1202 }
1203 }
1204 usage.activeTurnStartedAt = 0
1205 usage.sourceSessionCache = nil
1206 return tabTelemetrySnapshot{Version: 2, ReadFiles: records, Usage: usage}
1207 }
1208
1209 func (t *WorkspaceTab) resetTelemetry(sessionPath string) {
1210 t.telemMu.Lock()
1211 t.readTelemetry = nil
1212 t.usageTelemetry = sessionUsageStats{}
1213 t.telemetrySessionKey = sessionRuntimeKey(sessionPath)
1214 t.telemMu.Unlock()
1215 }
1216
1217 // syncTelemetryToSession keys the in-memory telemetry to the runtime's current
1218 // session. When the runtime rotated to a different session underneath the tab
1219 // (typed /new routes through Controller.Submit and never reaches App.NewSession),
1220 // the previous session's totals must not bleed into the new one: swap in the
1221 // new session's persisted sidecar, or start from zero when none exists. The
1222 // sidecar is rewritten on every recorded event, so a reload never loses more
1223 // than the sub-second in-memory delta of an in-flight record.
1224 func (t *WorkspaceTab) syncTelemetryToSession(sessionPath string) {
1225 key := sessionRuntimeKey(sessionPath)
1226 if key == "" {
1227 return
1228 }
1229 t.telemMu.Lock()
1230 same := t.telemetrySessionKey == key
1231 t.telemMu.Unlock()
1232 if same {
1233 return
1234 }
1235 // File I/O stays outside telemMu; re-check the key after reacquiring in
1236 // case a concurrent sync or reset re-keyed the tab first.
1237 snapshot := loadTelemetry(sessionPath + ".telemetry.json")
1238 t.telemMu.Lock()
1239 if t.telemetrySessionKey != key {
1240 t.readTelemetry = snapshot.ReadFiles
1241 t.usageTelemetry = snapshot.Usage
1242 t.telemetrySessionKey = key
1243 }
1244 t.telemMu.Unlock()
1245 }
1246
1247 func (t *WorkspaceTab) resetDisplayTurn() {
1248 state := t.displayBufferState()
1249 state.mu.Lock()
1250 if len(state.planner.messages) == 0 {
1251 state.planner.tools = nil
1252 }
1253 if len(state.executor.messages) == 0 {
1254 state.executor.tools = nil
1255 }
1256 state.mu.Unlock()
1257 }
1258
1259 func (t *WorkspaceTab) recordDisplayEvent(e event.Event) {
1260 state := t.displayBufferState()
1261 state.mu.Lock()
1262 defer state.mu.Unlock()
1263 buffer := &state.executor
1264 if strings.TrimSpace(e.Source) == event.UsageSourcePlanner {
1265 buffer = &state.planner
1266 }
1267 recordHistoryDisplayEvent(buffer, e)
1268 }
1269
1270 func (t *WorkspaceTab) displayBufferState() *tabDisplayState {
1271 t.displayStateMu.Lock()
1272 defer t.displayStateMu.Unlock()
1273 if t.displayState == nil {
1274 t.displayState = &tabDisplayState{}
1275 }
1276 return t.displayState
1277 }
1278
1279 func (t *WorkspaceTab) adoptDisplayState(state *tabDisplayState) {
1280 if t == nil || state == nil {
1281 return
1282 }
1283 t.displayStateMu.Lock()
1284 t.displayState = state
1285 t.displayStateMu.Unlock()
1286 }
1287
1288 func recordHistoryDisplayEvent(buffer *displayTurnBuffer, e event.Event) {
1289 switch e.Kind {
1290 case event.Phase:
1291 if strings.TrimSpace(e.Text) != "" {
1292 buffer.messages = append(buffer.messages, &bufferedHistoryMessage{message: HistoryMessage{Role: "phase", Content: e.Text}})
1293 }
1294 case event.Reasoning:
1295 if e.Text != "" {
1296 hm := ensureDisplayAssistant(buffer)
1297 hm.reasoning.append(e.Text)
1298 }
1299 case event.Text:
1300 if e.Text != "" {
1301 hm := ensureDisplayAssistant(buffer)
1302 hm.content.append(e.Text)
1303 }
1304 case event.Message:
1305 if e.Text != "" || e.Reasoning != "" || len(e.MemoryCitations) > 0 {
1306 hm := ensureDisplayAssistant(buffer)
1307 if e.Text != "" {
1308 hm.content.replace(e.Text)
1309 }
1310 if e.Reasoning != "" {
1311 hm.reasoning.replace(e.Reasoning)
1312 }
1313 if len(e.MemoryCitations) > 0 {
1314 hm.message.MemoryCitations = append([]provider.MemoryCitation(nil), e.MemoryCitations...)
1315 }
1316 }
1317 case event.ToolDispatch:
1318 if e.Tool.Partial || strings.TrimSpace(e.Tool.Name) == "" {
1319 return
1320 }
1321 hm := ensureDisplayAssistantForTool(buffer)
1322 resolvedReadOnly := e.Tool.ReadOnly
1323 call := HistoryToolCall{
1324 ID: e.Tool.ID,
1325 Name: e.Tool.Name,
1326 Arguments: e.Tool.Args,
1327 ResolvedName: e.Tool.ResolvedName,
1328 CapabilityID: e.Tool.CapabilityID,
1329 ResolvedReadOnly: &resolvedReadOnly,
1330 Subject: historyToolSubject(e.Tool.Name, e.Tool.Args),
1331 Summary: historyToolSummary(e.Tool.Name, e.Tool.Args, ""),
1332 Diff: e.Tool.Diff,
1333 Added: e.Tool.Added,
1334 Removed: e.Tool.Removed,
1335 }
1336 replaced := false
1337 if call.ID != "" {
1338 for i := range hm.message.ToolCalls {
1339 if hm.message.ToolCalls[i].ID == call.ID {
1340 hm.message.ToolCalls[i] = call
1341 replaced = true
1342 break
1343 }
1344 }
1345 if buffer.tools == nil {
1346 buffer.tools = map[string]string{}
1347 }
1348 buffer.tools[call.ID] = call.Name
1349 }
1350 if !replaced {
1351 hm.message.ToolCalls = append(hm.message.ToolCalls, call)
1352 }
1353 case event.ToolResult:
1354 callID := strings.TrimSpace(e.Tool.ID)
1355 content := firstNonEmpty(e.Tool.Output, e.Tool.Err)
1356 display, errPreview := plannerToolResultDisplay(content, e.Tool.Err != "")
1357 if callID != "" {
1358 updateBufferedHistoryToolCallSummary(buffer.messages, callID, content)
1359 }
1360 toolName := e.Tool.Name
1361 if toolName == "" && buffer.tools != nil {
1362 toolName = buffer.tools[callID]
1363 }
1364 buffer.messages = append(buffer.messages, &bufferedHistoryMessage{message: HistoryMessage{
1365 Role: "tool",
1366 ToolCallID: callID,
1367 ToolName: toolName,
1368 Content: display,
1369 ToolResultError: errPreview,
1370 }})
1371 case event.Notice:
1372 if strings.TrimSpace(e.Text) != "" {
1373 level := "info"
1374 if e.Level == event.LevelWarn {
1375 level = "warn"
1376 }
1377 buffer.messages = append(buffer.messages, &bufferedHistoryMessage{message: HistoryMessage{
1378 Role: "notice",
1379 Level: level,
1380 Content: e.Text,
1381 Detail: e.Detail,
1382 Code: e.Code,
1383 DecisionReceipt: cloneDecisionReceipt(e.DecisionReceipt),
1384 }})
1385 }
1386 }
1387 }
1388
1389 func ensureDisplayAssistant(buffer *displayTurnBuffer) *bufferedHistoryMessage {
1390 if n := len(buffer.messages); n > 0 && buffer.messages[n-1].message.Role == "assistant" {
1391 return buffer.messages[n-1]
1392 }
1393 message := &bufferedHistoryMessage{message: HistoryMessage{Role: "assistant"}}
1394 buffer.messages = append(buffer.messages, message)
1395 return message
1396 }
1397
1398 func ensureDisplayAssistantForTool(buffer *displayTurnBuffer) *bufferedHistoryMessage {
1399 if n := len(buffer.messages); n > 0 && buffer.messages[n-1].message.Role == "assistant" && !buffer.messages[n-1].content.hasNonWhitespace() {
1400 return buffer.messages[n-1]
1401 }
1402 message := &bufferedHistoryMessage{message: HistoryMessage{Role: "assistant"}}
1403 buffer.messages = append(buffer.messages, message)
1404 return message
1405 }
1406
1407 func updateBufferedHistoryToolCallSummary(messages []*bufferedHistoryMessage, callID, output string) {
1408 if callID == "" {
1409 return
1410 }
1411 for i := len(messages) - 1; i >= 0; i-- {
1412 for j := range messages[i].message.ToolCalls {
1413 call := &messages[i].message.ToolCalls[j]
1414 if call.ID != callID {
1415 continue
1416 }
1417 if call.Summary == "" {
1418 call.Summary = historyToolSummary(call.Name, call.Arguments, output)
1419 }
1420 return
1421 }
1422 }
1423 }
1424
1425 func plannerToolResultDisplay(content string, failed bool) (display, errPreview string) {
1426 if strings.TrimSpace(content) == "" {
1427 return "", ""
1428 }
1429 if failed || historyToolResultFailed(content) {
1430 display = clipHistoryToolPreview(strings.TrimSpace(content))
1431 return display, display
1432 }
1433 return "", ""
1434 }
1435
1436 func (t *WorkspaceTab) takeDisplayTurn(cancelled bool) []HistoryMessage {
1437 state := t.displayBufferState()
1438 state.mu.Lock()
1439 defer state.mu.Unlock()
1440 out := state.planner.materialize()
1441 if cancelled {
1442 out = append(out, state.executor.materialize()...)
1443 if len(out) > 0 {
1444 out = append(out, HistoryMessage{
1445 Role: "notice",
1446 Level: "info",
1447 Code: event.NoticeCodeCancelledTurn,
1448 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.",
1449 })
1450 }
1451 }
1452 state.planner.reset()
1453 state.executor.reset()
1454 return out
1455 }
1456
1457 func enqueuePendingDisplayWrite(state *tabDisplayState, write *pendingDisplayWrite) {
1458 if state == nil || write == nil || write.persist == nil {
1459 return
1460 }
1461 state.mu.Lock()
1462 state.pendingWrites = append(state.pendingWrites, write)
1463 if state.persistRunning {
1464 state.mu.Unlock()
1465 return
1466 }
1467 state.persistRunning = true
1468 state.mu.Unlock()
1469 go retryPendingDisplayWrites(state)
1470 }
1471
1472 func persistOrEnqueueDisplayWrite(state *tabDisplayState, write *pendingDisplayWrite) {
1473 if state == nil || write == nil || write.persist == nil {
1474 return
1475 }
1476 state.mu.Lock()
1477 hasPending := len(state.pendingWrites) > 0
1478 state.mu.Unlock()
1479 if hasPending {
1480 enqueuePendingDisplayWrite(state, write)
1481 return
1482 }
1483 if err := write.persist(write.dir, write.sessionPath, write.userContent, write.messages); err != nil {
1484 slog.Warn("desktop: persist display-only turn history; queued for retry", "err", err)
1485 enqueuePendingDisplayWrite(state, write)
1486 }
1487 }
1488
1489 func retryPendingDisplayWrites(state *tabDisplayState) {
1490 failures := 0
1491 for {
1492 state.mu.Lock()
1493 if len(state.pendingWrites) == 0 {
1494 state.persistRunning = false
1495 state.mu.Unlock()
1496 return
1497 }
1498 write := state.pendingWrites[0]
1499 state.mu.Unlock()
1500
1501 if failures > 0 {
1502 time.Sleep(time.Duration(failures*failures) * 50 * time.Millisecond)
1503 }
1504 if err := write.persist(write.dir, write.sessionPath, write.userContent, write.messages); err != nil {
1505 failures++
1506 if failures < displayPersistRetryLimit {
1507 continue
1508 }
1509 state.mu.Lock()
1510 state.persistRunning = false
1511 state.mu.Unlock()
1512 slog.Warn("desktop: display-only turn history remains pending after retries", "err", err)
1513 return
1514 }
1515
1516 state.mu.Lock()
1517 if len(state.pendingWrites) > 0 && state.pendingWrites[0] == write {
1518 state.pendingWrites[0] = nil
1519 state.pendingWrites = state.pendingWrites[1:]
1520 }
1521 state.mu.Unlock()
1522 failures = 0
1523 }
1524 }
1525
1526 // tabEventSink wraps a parent event.Sink and prepends a tabId to every wire
1527 // event so the frontend can route it to the correct tab's reducer.
1528 //
1529 // tabID and app are rebound while the controller keeps emitting when a running
1530 // session is detached to the background or reattached to another tab, so they
1531 // live under mu like ctx does (a bare field write would data-race Emit). Read
1532 // them via binding(), write via setBinding().
1533 type tabEventSink struct {
1534 tabID string
1535 app *App
1536 mu sync.RWMutex
1537 ctx context.Context
1538 runtimeEpoch string
1539 runtimeEvents asyncRuntimeEmitter
1540 botSink event.Sink // optional: when set, events are also forwarded here
1541 botSinkGen uint64
1542 turnInFlight bool // stays true through the end of TurnDone fan-out
1543 }
1544
1545 type closeableEventSink interface {
1546 Close()
1547 }
1548
1549 // binding snapshots the sink's current tab routing under the sink lock.
1550 func (s *tabEventSink) binding() (string, *App) {
1551 s.mu.RLock()
1552 defer s.mu.RUnlock()
1553 return s.tabID, s.app
1554 }
1555
1556 // setBinding reroutes the sink to another tab. A nil app keeps the current
1557 // App pointer (detach/close paths only change the tab id).
1558 func (s *tabEventSink) setBinding(tabID string, app *App) {
1559 s.mu.Lock()
1560 s.tabID = tabID
1561 if app != nil {
1562 s.app = app
1563 }
1564 s.mu.Unlock()
1565 }
1566
1567 func (s *tabEventSink) setRuntimeEpoch(epoch string) {
1568 if s == nil {
1569 return
1570 }
1571 s.mu.Lock()
1572 s.runtimeEpoch = epoch
1573 s.mu.Unlock()
1574 }
1575
1576 func (s *tabEventSink) runtimeEpochSnapshot() string {
1577 if s == nil {
1578 return ""
1579 }
1580 s.mu.RLock()
1581 defer s.mu.RUnlock()
1582 return s.runtimeEpoch
1583 }
1584
1585 func (s *tabEventSink) Emit(e event.Event) {
1586 if e.Kind == event.TurnStarted {
1587 s.mu.Lock()
1588 s.turnInFlight = true
1589 s.mu.Unlock()
1590 }
1591 tabID, app := s.binding()
1592 if app != nil {
1593 switch e.Kind {
1594 case event.TurnStarted:
1595 s.resetDisplayTurn()
1596 s.recordTurnStarted()
1597 case event.Usage:
1598 s.recordUsageTelemetry(e)
1599 case event.TurnDone:
1600 s.recordTurnDone()
1601 }
1602 if m := app.metrics.Load(); m != nil {
1603 m.observe(e)
1604 if e.Kind == event.TurnDone {
1605 // Content-free recovery counters only (no failure text).
1606 if tab := app.tabByID(tabID); tab != nil && tab.Ctrl != nil {
1607 observeControllerRecoveryMetrics(m, tab.Ctrl)
1608 }
1609 m.persist()
1610 }
1611 }
1612 if e.Kind == event.TurnDone {
1613 s.flushDisplay(e.Cancelled)
1614 }
1615 }
1616 s.emitRuntimeEvent(eventChannel, toWireTab(e, tabID, s.runtimeEpochSnapshot()))
1617 if app != nil {
1618 if status, update := topicActivityStatusFromEvent(e); update {
1619 changed := app.setTabActivityStatus(tabID, status)
1620 if changed || isBackgroundJobLifecycleNotice(e) {
1621 app.emitProjectTreeMetadataChanged()
1622 }
1623 }
1624 }
1625 // Record read_file successes in the tab's telemetry.
1626 if e.Kind == event.ToolResult && e.Tool.Name == "read_file" && e.Tool.Err == "" {
1627 s.recordReadTelemetry(e)
1628 }
1629 if app != nil {
1630 s.recordDisplay(e)
1631 }
1632 // Persist after each turn so a force-kill loses at most the in-flight prompt.
1633 if e.Kind == event.TurnDone && app != nil {
1634 app.scheduleTabSnapshot(tabID)
1635 }
1636 // Forward event to bot channels when a bot forwarder is attached.
1637 // Read the sink under the read lock so SetBotSink can safely swap it
1638 // from another goroutine.
1639 bs, botSinkGen := s.botSinkSnapshot()
1640 if bs != nil {
1641 bs.Emit(e)
1642 // Detach the forwarder after TurnDone so subsequent turns on the
1643 // same tab do not keep pushing to bot channels.
1644 if e.Kind == event.TurnDone {
1645 s.clearBotSink(botSinkGen)
1646 }
1647 }
1648 // Unlike the transient botSink above, the bridge observes every tab for
1649 // its whole lifetime (god view: /desktop status, watch subscriptions,
1650 // remote approvals). observe only does in-memory bookkeeping and queueing.
1651 if app != nil && app.botBridge != nil {
1652 app.botBridge.observe(tabID, e)
1653 }
1654 if e.Kind == event.TurnDone {
1655 s.mu.Lock()
1656 s.turnInFlight = false
1657 s.mu.Unlock()
1658 }
1659 }
1660
1661 // SetBotSink atomically sets or clears the bot event forwarder on this sink.
1662 // It is safe to call concurrently with Emit.
1663 func (s *tabEventSink) SetBotSink(sink event.Sink) uint64 {
1664 s.mu.Lock()
1665 old := s.botSink
1666 s.botSink = sink
1667 s.botSinkGen++
1668 generation := s.botSinkGen
1669 s.mu.Unlock()
1670 if old != nil && old != sink {
1671 if closer, ok := old.(closeableEventSink); ok {
1672 closer.Close()
1673 }
1674 }
1675 return generation
1676 }
1677
1678 func (s *tabEventSink) botSinkSnapshot() (event.Sink, uint64) {
1679 s.mu.RLock()
1680 defer s.mu.RUnlock()
1681 return s.botSink, s.botSinkGen
1682 }
1683
1684 // clearBotSink clears only the forwarder generation observed by the finishing
1685 // turn. A delayed TurnDone must not detach a replacement installed meanwhile.
1686 func (s *tabEventSink) clearBotSink(generation uint64) {
1687 s.mu.Lock()
1688 if s.botSinkGen != generation {
1689 s.mu.Unlock()
1690 return
1691 }
1692 old := s.botSink
1693 s.botSink = nil
1694 s.botSinkGen++
1695 s.mu.Unlock()
1696 if closer, ok := old.(closeableEventSink); ok {
1697 closer.Close()
1698 }
1699 }
1700
1701 // tryBeginTurn reserves the tab until its TurnDone has finished fan-out. The
1702 // controller clears RuntimeStatus().Running before it emits TurnDone, so the
1703 // controller status alone leaves a window where a new turn can inherit the old
1704 // turn's forwarder or have its replacement cleared by the old completion.
1705 func (s *tabEventSink) tryBeginTurn() bool {
1706 s.mu.Lock()
1707 defer s.mu.Unlock()
1708 if s.turnInFlight {
1709 return false
1710 }
1711 s.turnInFlight = true
1712 return true
1713 }
1714
1715 func (s *tabEventSink) cancelTurnStart() {
1716 s.mu.Lock()
1717 s.turnInFlight = false
1718 s.mu.Unlock()
1719 }
1720
1721 func (s *tabEventSink) setContext(ctx context.Context) {
1722 s.mu.Lock()
1723 s.ctx = ctx
1724 s.mu.Unlock()
1725 }
1726
1727 func (s *tabEventSink) clearContext() {
1728 s.mu.Lock()
1729 s.ctx = nil
1730 s.mu.Unlock()
1731 s.runtimeEvents.Clear()
1732 }
1733
1734 func (s *tabEventSink) context() context.Context {
1735 s.mu.RLock()
1736 defer s.mu.RUnlock()
1737 return s.ctx
1738 }
1739
1740 func (s *tabEventSink) emitRuntimeEvent(name string, payload ...interface{}) {
1741 if s == nil {
1742 return
1743 }
1744 ctx := s.context()
1745 if ctx == nil {
1746 return
1747 }
1748 s.runtimeEvents.Emit(ctx, name, payload...)
1749 }
1750
1751 type runtimeEventEmitFunc func(context.Context, string, ...interface{})
1752
1753 type runtimeEventEnvelope struct {
1754 ctx context.Context
1755 name string
1756 payload []interface{}
1757 }
1758
1759 // asyncRuntimeEmitter decouples Wails' runtime event bridge from agent
1760 // emission. runtime.EventsEmit can block when the single webview event channel
1761 // backs up; callers enqueue in-order work and return without holding the
1762 // agent's event.Sync lock.
1763 // runtimeEventsEmitFallback is the emit used when no per-instance override is
1764 // installed. Production keeps the real Wails bridge; the test binary swaps in
1765 // a no-op via TestMain, because Wails EventsEmit log.Fatals outside a running
1766 // Wails app and would kill the whole test process from any code path that
1767 // emits a runtime event with a plain Background context.
1768 var runtimeEventsEmitFallback runtimeEventEmitFunc = runtime.EventsEmit
1769
1770 type asyncRuntimeEmitter struct {
1771 mu sync.Mutex
1772 emit runtimeEventEmitFunc
1773 queue []runtimeEventEnvelope
1774 head int
1775 running bool
1776 }
1777
1778 func (e *asyncRuntimeEmitter) Emit(ctx context.Context, name string, payload ...interface{}) {
1779 if ctx == nil {
1780 return
1781 }
1782 item := runtimeEventEnvelope{
1783 ctx: ctx,
1784 name: name,
1785 payload: append([]interface{}(nil), payload...),
1786 }
1787 e.mu.Lock()
1788 e.queue = append(e.queue, item)
1789 if !e.running {
1790 e.running = true
1791 go e.run()
1792 }
1793 e.mu.Unlock()
1794 }
1795
1796 func (e *asyncRuntimeEmitter) Clear() {
1797 e.mu.Lock()
1798 clear(e.queue)
1799 e.queue = nil
1800 e.head = 0
1801 e.mu.Unlock()
1802 }
1803
1804 func (e *asyncRuntimeEmitter) run() {
1805 for {
1806 e.mu.Lock()
1807 if e.head >= len(e.queue) {
1808 clear(e.queue)
1809 e.queue = nil
1810 e.head = 0
1811 e.running = false
1812 e.mu.Unlock()
1813 return
1814 }
1815 item := e.queue[e.head]
1816 var zero runtimeEventEnvelope
1817 e.queue[e.head] = zero
1818 e.head++
1819 if e.head > 64 && e.head*2 >= len(e.queue) {
1820 e.queue = append([]runtimeEventEnvelope(nil), e.queue[e.head:]...)
1821 e.head = 0
1822 }
1823 emit := e.emit
1824 if emit == nil {
1825 emit = runtimeEventsEmitFallback
1826 }
1827 e.mu.Unlock()
1828
1829 emit(item.ctx, item.name, item.payload...)
1830 }
1831 }
1832
1833 func topicActivityStatusFromEvent(e event.Event) (string, bool) {
1834 switch e.Kind {
1835 case event.TurnStarted, event.Reasoning, event.ToolDispatch, event.ToolProgress, event.ToolResult, event.CompactionStarted, event.CompactionDone, event.Retrying:
1836 return topicStatusThinking, true
1837 case event.Text, event.Message:
1838 return topicStatusStreaming, true
1839 case event.ApprovalRequest, event.AskRequest:
1840 return topicStatusWaitingConfirmation, true
1841 case event.TurnDone:
1842 if e.Outcome == event.TurnOutcomeFinalReadiness || e.Outcome == event.TurnOutcomeRecoveryPaused {
1843 // The transcript presents this turn end as a recoverable delivery
1844 // pause with a continue action, so the sidebar must not flag it
1845 // as an error.
1846 return topicStatusPaused, true
1847 }
1848 if e.Err != nil {
1849 return topicStatusError, true
1850 }
1851 return "", true
1852 case event.Notice:
1853 if isBackgroundJobLifecycleNotice(e) {
1854 return "", true
1855 }
1856 return "", false
1857 default:
1858 return "", false
1859 }
1860 }
1861
1862 func isBackgroundJobLifecycleNotice(e event.Event) bool {
1863 if e.Kind != event.Notice {
1864 return false
1865 }
1866 text := strings.TrimSpace(e.Text)
1867 return strings.HasPrefix(text, "background ") &&
1868 (strings.Contains(text, " started: ") ||
1869 strings.Contains(text, " finished: ") ||
1870 strings.Contains(text, " failed: ") ||
1871 strings.Contains(text, " killed: "))
1872 }
1873
1874 // notifyTabRuntimeRebuilt tells the frontend a tab's controller was replaced
1875 // in place (model/effort/token-mode switch, clear-while-running). A rebuilt
1876 // controller restarts its approval/ask id counter at "1", so tab-scoped
1877 // frontend state keyed by prompt id (the attention-chime dedupe) must reset —
1878 // unlike agent:ready, this event carries no reload semantics, so emitting it
1879 // on every swap adds no hydration churn.
1880 //
1881 // Ordering matters: the reset must reach the frontend BEFORE the rebuilt
1882 // controller's first approval/ask event, or the stale key still mutes it. The
1883 // tab's agent events ride the tab sink's own async queue, so the notice goes
1884 // through THAT queue — same lane, FIFO, guaranteed to arrive first. The
1885 // App-level queue is only the fallback when the sink cannot deliver (no sink,
1886 // or its webview context is cleared); it cannot order against sink traffic,
1887 // but an unordered notice still beats none.
1888 func (a *App) notifyTabRuntimeRebuilt(tab *WorkspaceTab) {
1889 if tab == nil {
1890 return
1891 }
1892 a.mu.Lock()
1893 epoch := a.advanceSessionRuntimeEpochLocked(tab)
1894 a.mu.Unlock()
1895 a.notifyTabRuntimeRebuiltAtEpoch(tab, epoch)
1896 }
1897
1898 // notifyTabRuntimeRebuiltAtEpoch emits the rebuild fence for a transaction
1899 // that advanced its epoch inside the controller/path/lease commit. Keeping the
1900 // chosen epoch avoids a second generation bump after publication.
1901 func (a *App) notifyTabRuntimeRebuiltAtEpoch(tab *WorkspaceTab, epoch string) {
1902 if tab == nil {
1903 return
1904 }
1905 a.mu.RLock()
1906 sink := tab.sink
1907 tabID := tab.ID
1908 a.mu.RUnlock()
1909 if sink != nil && sink.context() != nil {
1910 sink.emitRuntimeEvent("runtime:rebuilt", tabID, epoch)
1911 return
1912 }
1913 a.emitRuntimeEvent("runtime:rebuilt", tabID, epoch)
1914 }
1915
1916 func (a *App) emitReady(ctx context.Context, tabID ...string) {
1917 a.mu.RLock()
1918 hook := a.readyHook
1919 a.mu.RUnlock()
1920 if hook != nil {
1921 hook()
1922 return
1923 }
1924 if ctx != nil {
1925 if len(tabID) > 0 && strings.TrimSpace(tabID[0]) != "" {
1926 a.runtimeEvents.Emit(ctx, "agent:ready", strings.TrimSpace(tabID[0]))
1927 return
1928 }
1929 a.runtimeEvents.Emit(ctx, "agent:ready")
1930 }
1931 }
1932
1933 func (s *tabEventSink) recordReadTelemetry(e event.Event) {
1934 tabID, app := s.binding()
1935 if app == nil {
1936 return
1937 }
1938 app.mu.RLock()
1939 tab := app.tabByEventSinkIDLocked(tabID)
1940 var ctrl control.SessionAPI
1941 if tab != nil {
1942 ctrl = tab.Ctrl
1943 }
1944 app.mu.RUnlock()
1945 if tab == nil {
1946 return
1947 }
1948 turn := 0
1949 if ctrl != nil {
1950 turn = ctrl.Turn()
1951 }
1952
1953 // Parse read_file args: {"path": "...", "offset": N, "limit": N}
1954 var args struct {
1955 Path string `json:"path"`
1956 Offset int `json:"offset"`
1957 Limit int `json:"limit"`
1958 }
1959 path := e.Tool.Args
1960 offset := 0
1961 limit := 0
1962 if err := json.Unmarshal([]byte(e.Tool.Args), &args); err == nil && args.Path != "" {
1963 path = args.Path
1964 offset = args.Offset
1965 limit = args.Limit
1966 }
1967
1968 truncated := e.Tool.Truncated || strings.Contains(e.Tool.Output, "truncated") ||
1969 strings.Contains(e.Tool.Output, "File truncated")
1970
1971 sp := ""
1972 if ctrl != nil {
1973 sp = ctrl.SessionPath()
1974 }
1975 if sp != "" {
1976 tab.syncTelemetryToSession(sp)
1977 }
1978 tab.recordReadFile(readFileRecord{
1979 Path: path,
1980 Turn: turn,
1981 Time: time.Now().UnixMilli(),
1982 Offset: offset,
1983 Limit: limit,
1984 Truncated: truncated,
1985 })
1986 if sp != "" {
1987 _ = saveTelemetry(sp+".telemetry.json", tab.telemetrySnapshot())
1988 }
1989 }
1990
1991 func (s *tabEventSink) recordTurnStarted() {
1992 tab, sp := s.telemetryTab()
1993 if tab == nil {
1994 return
1995 }
1996 if sp != "" {
1997 tab.syncTelemetryToSession(sp)
1998 }
1999 tab.recordTurnStarted(time.Now().UnixMilli())
2000 if sp != "" {
2001 _ = saveTelemetry(sp+".telemetry.json", tab.telemetrySnapshot())
2002 }
2003 }
2004
2005 func (s *tabEventSink) recordTurnDone() {
2006 tab, sp := s.telemetryTab()
2007 if tab == nil {
2008 return
2009 }
2010 if sp != "" {
2011 tab.syncTelemetryToSession(sp)
2012 }
2013 tab.recordTurnDone(time.Now().UnixMilli())
2014 if sp != "" {
2015 _ = saveTelemetry(sp+".telemetry.json", tab.telemetrySnapshot())
2016 }
2017 }
2018
2019 func (s *tabEventSink) recordUsageTelemetry(e event.Event) {
2020 tab, sp := s.telemetryTab()
2021 if tab == nil {
2022 return
2023 }
2024 if sp != "" {
2025 tab.syncTelemetryToSession(sp)
2026 }
2027 tab.recordUsage(e)
2028 if sp != "" {
2029 _ = saveTelemetry(sp+".telemetry.json", tab.telemetrySnapshot())
2030 }
2031 }
2032
2033 func (s *tabEventSink) resetDisplayTurn() {
2034 tab, _ := s.eventTabAndController()
2035 if tab != nil {
2036 tab.resetDisplayTurn()
2037 }
2038 }
2039
2040 func (s *tabEventSink) recordDisplay(e event.Event) {
2041 tab, _ := s.eventTabAndController()
2042 if tab != nil {
2043 tab.recordDisplayEvent(e)
2044 }
2045 }
2046
2047 func (s *tabEventSink) flushDisplay(cancelRequested bool) {
2048 tab, ctrl := s.eventTabAndController()
2049 if tab == nil || ctrl == nil {
2050 return
2051 }
2052 history := ctrl.History()
2053 keepExecutorDisplay := cancelRequested && (lastHistoryMessageIsUser(history) || hasPendingInterruptedRecovery(history))
2054 messages := tab.takeDisplayTurn(keepExecutorDisplay)
2055 if len(messages) == 0 {
2056 return
2057 }
2058 sessionPath := ctrl.SessionPath()
2059 if sessionPath == "" {
2060 return
2061 }
2062 userContent := lastUserMessageContent(history)
2063 if strings.TrimSpace(userContent) == "" {
2064 return
2065 }
2066 persistOrEnqueueDisplayWrite(tab.displayBufferState(), &pendingDisplayWrite{
2067 dir: controllerSessionDir(ctrl),
2068 sessionPath: sessionPath,
2069 userContent: userContent,
2070 messages: messages,
2071 persist: recordSessionPlannerDisplay,
2072 })
2073 }
2074
2075 func lastHistoryMessageIsUser(history []provider.Message) bool {
2076 return len(history) > 0 && history[len(history)-1].Role == provider.RoleUser
2077 }
2078
2079 func hasPendingInterruptedRecovery(history []provider.Message) bool {
2080 for i := len(history) - 1; i >= 0; i-- {
2081 m := history[i]
2082 if m.LocalOnly && m.InterruptedTurn != nil {
2083 return m.InterruptedTurn.Pending
2084 }
2085 if m.Role == provider.RoleUser {
2086 return false
2087 }
2088 }
2089 return false
2090 }
2091
2092 func (s *tabEventSink) eventTabAndController() (*WorkspaceTab, control.SessionAPI) {
2093 tabID, app := s.binding()
2094 if app == nil {
2095 return nil, nil
2096 }
2097 app.mu.RLock()
2098 defer app.mu.RUnlock()
2099 tab := app.tabByEventSinkIDLocked(tabID)
2100 if tab == nil {
2101 return nil, nil
2102 }
2103 return tab, tab.Ctrl
2104 }
2105
2106 func lastUserMessageContent(msgs []provider.Message) string {
2107 for i := len(msgs) - 1; i >= 0; i-- {
2108 if msgs[i].Role == provider.RoleUser {
2109 return agent.UserMessageText(msgs[i])
2110 }
2111 }
2112 return ""
2113 }
2114
2115 func (s *tabEventSink) telemetryTab() (*WorkspaceTab, string) {
2116 tabID, app := s.binding()
2117 if app == nil {
2118 return nil, ""
2119 }
2120 app.mu.RLock()
2121 tab := app.tabByEventSinkIDLocked(tabID)
2122 var ctrl control.SessionAPI
2123 if tab != nil {
2124 ctrl = tab.Ctrl
2125 }
2126 app.mu.RUnlock()
2127 if tab == nil {
2128 return nil, ""
2129 }
2130 if ctrl == nil {
2131 return tab, ""
2132 }
2133 sp := ctrl.SessionPath()
2134 if sp == "" {
2135 return tab, ""
2136 }
2137 return tab, sp
2138 }
2139
2140 // --- wire event with tab ----------------------------------------------------
2141
2142 func toWireTab(e event.Event, tabID string, runtimeEpoch ...string) wireEventTab {
2143 w := eventwire.ToWire(e)
2144 epoch := ""
2145 if len(runtimeEpoch) > 0 {
2146 epoch = runtimeEpoch[0]
2147 }
2148 return wireEventTab{
2149 Event: w,
2150 TabID: tabID,
2151 RuntimeEpoch: epoch,
2152 SessionHitTokens: e.SessionHit,
2153 SessionMissTokens: e.SessionMiss,
2154 SessionCost: 0, // filled by frontend accumulator per tab
2155 SessionCurrency: "",
2156 SessionCostUsd: 0, // deprecated compatibility alias
2157 }
2158 }
2159
2160 // wireEventTab extends the shared event wire with tab routing info. The frontend reducer
2161 // uses tabId to dispatch to the correct per-tab state.
2162 type wireEventTab struct {
2163 eventwire.Event
2164 TabID string `json:"tabId"`
2165 RuntimeEpoch string `json:"runtimeEpoch,omitempty"`
2166 // Session-cumulative tokens per tab.
2167 SessionHitTokens int `json:"sessionHitTokens,omitempty"`
2168 SessionMissTokens int `json:"sessionMissTokens,omitempty"`
2169 // SessionCost is filled by the frontend's per-tab accumulator.
2170 SessionCost float64 `json:"sessionCost,omitempty"`
2171 SessionCurrency string `json:"sessionCurrency,omitempty"`
2172 // SessionCostUsd is a deprecated compatibility alias. It mirrors
2173 // SessionCost and does not imply USD.
2174 SessionCostUsd float64 `json:"sessionCostUsd,omitempty"`
2175 }
2176
2177 // --- Tab management on App --------------------------------------------------
2178
2179 // TabMeta is the frontend-facing shape of one tab.
2180 type TabMeta struct {
2181 ID string `json:"id"`
2182 Scope string `json:"scope"`
2183 WorkspaceRoot string `json:"workspaceRoot"`
2184 WorkspaceName string `json:"workspaceName"`
2185 WorkspacePath string `json:"workspacePath,omitempty"`
2186 GitBranch string `json:"gitBranch,omitempty"`
2187 IsolatedWorktree bool `json:"isolatedWorktree,omitempty"`
2188 TopicID string `json:"topicId"`
2189 TopicTitle string `json:"topicTitle"`
2190 SessionPath string `json:"sessionPath,omitempty"`
2191 ReadOnly bool `json:"readOnly,omitempty"`
2192 ProjectColor string `json:"projectColor,omitempty"`
2193 Label string `json:"label"`
2194 Ready bool `json:"ready"`
2195 Runtime SessionRuntimeView `json:"runtime"`
2196 Running bool `json:"running"`
2197 PendingPrompt bool `json:"pendingPrompt,omitempty"`
2198 RemoteControlled bool `json:"remoteControlled,omitempty"`
2199 BackgroundJobs int `json:"backgroundJobs,omitempty"`
2200 CancelRequested bool `json:"cancelRequested,omitempty"`
2201 Cancellable bool `json:"cancellable"`
2202 Mode string `json:"mode"`
2203 CollaborationMode string `json:"collaborationMode"`
2204 ToolApprovalMode string `json:"toolApprovalMode"`
2205 TokenMode string `json:"tokenMode"`
2206 Goal string `json:"goal,omitempty"`
2207 GoalStatus string `json:"goalStatus,omitempty"`
2208 AutoResearch *AutoResearchCompactView `json:"autoResearch,omitempty"`
2209 Recovered bool `json:"recovered,omitempty"`
2210 RecoveryReason string `json:"recoveryReason,omitempty"`
2211 RecoveryDigest string `json:"recoveryDigest,omitempty"`
2212 RecoveryParentID string `json:"recoveryParentId,omitempty"`
2213 StartupErr string `json:"startupErr,omitempty"`
2214 Active bool `json:"active"`
2215 Cwd string `json:"cwd"`
2216 }
2217
2218 func enrichTabMeta(meta TabMeta) TabMeta {
2219 if meta.Active {
2220 meta.GitBranch = workspaceGitBranchForMeta(meta.WorkspaceRoot)
2221 }
2222 return meta
2223 }
2224
2225 func enrichTabMetas(metas []TabMeta) []TabMeta {
2226 for i := range metas {
2227 if metas[i].Active {
2228 metas[i].GitBranch = workspaceGitBranchForMeta(metas[i].WorkspaceRoot)
2229 }
2230 }
2231 return metas
2232 }
2233
2234 func (a *App) tabMeta(tab *WorkspaceTab, active bool) TabMeta {
2235 runtimeView := a.sessionRuntimeViewLocked(tab)
2236 m := TabMeta{
2237 ID: tab.ID,
2238 Scope: tab.Scope,
2239 WorkspaceRoot: tab.WorkspaceRoot,
2240 WorkspaceName: workspaceName(tab.WorkspaceRoot),
2241 WorkspacePath: tab.WorkspaceRoot,
2242 TopicID: tab.TopicID,
2243 TopicTitle: a.localizedTopicTitle(tab.TopicTitle, tab.topicTitleSource),
2244 SessionPath: tab.currentSessionPath(),
2245 ReadOnly: tab.ReadOnly,
2246 Label: tab.Label,
2247 Ready: runtimeView.Phase == sessionRuntimeReady && tab.Ctrl != nil,
2248 Runtime: runtimeView,
2249 Mode: currentTabMode(tab),
2250 CollaborationMode: currentTabCollaborationMode(tab),
2251 ToolApprovalMode: currentTabToolApprovalMode(tab),
2252 TokenMode: currentTabTokenMode(tab),
2253 Goal: currentTabGoal(tab),
2254 GoalStatus: currentTabGoalStatus(tab),
2255 AutoResearch: compactAutoResearch(tab),
2256 StartupErr: tab.StartupErr,
2257 Active: active,
2258 Cwd: tab.WorkspaceRoot,
2259 IsolatedWorktree: worktree.IsManagedPath(tab.WorkspaceRoot, config.DeliveryWorktreeDir()),
2260 }
2261 switch tab.Scope {
2262 case "global":
2263 m.ProjectColor = globalProjectColor()
2264 m.WorkspaceName = globalProjectTitle()
2265 case "project":
2266 m.ProjectColor = projectColor(tab.WorkspaceRoot)
2267 }
2268 if tab.Ctrl != nil {
2269 status := tab.Ctrl.RuntimeStatus()
2270 m.Running = status.Running || status.PendingPrompt || status.BackgroundJobs > 0
2271 m.PendingPrompt = status.PendingPrompt
2272 m.BackgroundJobs = status.BackgroundJobs
2273 m.CancelRequested = status.CancelRequested
2274 m.Cancellable = status.Cancellable
2275 }
2276 if a.botBridge != nil {
2277 m.RemoteControlled = a.botBridge.remoteControlledTabs()[tab.ID]
2278 }
2279 if meta, ok, err := agent.LoadBranchMeta(tab.currentSessionPath()); err == nil && ok && meta.Recovered {
2280 m.Recovered = true
2281 m.RecoveryReason = meta.RecoveryReason
2282 m.RecoveryDigest = meta.RecoveryDigest
2283 m.RecoveryParentID = string(meta.ParentID)
2284 }
2285 return m
2286 }
2287
2288 // ListTabs returns every open view container's metadata for the frontend chrome and sidebar.
2289 func (a *App) ListTabs() []TabMeta {
2290 a.mu.RLock()
2291 out := make([]TabMeta, 0, len(a.tabs))
2292 ordered, needsRepair := a.orderedTabIDsSnapshotLocked()
2293 for _, id := range ordered {
2294 if tab := a.tabs[id]; tab != nil {
2295 out = append(out, a.tabMeta(tab, tab.ID == a.activeTabID))
2296 }
2297 }
2298 a.mu.RUnlock()
2299 if !needsRepair {
2300 return enrichTabMetas(out)
2301 }
2302
2303 a.mu.Lock()
2304 out = make([]TabMeta, 0, len(a.tabs))
2305 for _, id := range a.orderedTabIDsLocked() {
2306 if tab := a.tabs[id]; tab != nil {
2307 out = append(out, a.tabMeta(tab, tab.ID == a.activeTabID))
2308 }
2309 }
2310 a.mu.Unlock()
2311 return enrichTabMetas(out)
2312 }
2313
2314 // syncTabWorkspaceRootSpellings repoints open project tabs at the registry's
2315 // canonical root spelling after a registry write may have rewritten it
2316 // (addProject and friends adopt the caller's spelling). Tabs, the project
2317 // tree, and persisted tab state then agree on a single string form of each
2318 // root, which the frontend compares exactly. Callers must not hold a.mu.
2319 func (a *App) syncTabWorkspaceRootSpellings() {
2320 projects := loadProjectsFile().Projects
2321 a.mu.Lock()
2322 changed := false
2323 for _, tab := range a.tabs {
2324 if tab == nil || tab.Scope != "project" {
2325 continue
2326 }
2327 i := projectIndexByRoot(projects, tab.WorkspaceRoot)
2328 if i < 0 || tab.WorkspaceRoot == projects[i].Root {
2329 continue
2330 }
2331 tab.WorkspaceRoot = projects[i].Root
2332 changed = true
2333 }
2334 if changed {
2335 a.saveTabsLocked()
2336 }
2337 a.mu.Unlock()
2338 if changed {
2339 a.emitProjectTreeMetadataChanged()
2340 }
2341 }
2342
2343 // registerProjectRoot indexes workspaceRoot in the project registry and
2344 // realigns open tabs when the registry adopted a new spelling of the root.
2345 func (a *App) registerProjectRoot(workspaceRoot string) {
2346 _ = addProject(workspaceRoot, "")
2347 a.syncTabWorkspaceRootSpellings()
2348 }
2349
2350 // OpenProjectTab builds a controller scoped to workspaceRoot and opens the
2351 // session selected by the given topic. Topic selection resolves to a concrete
2352 // session path first; the visible tab is then attached to that session runtime.
2353 func (a *App) OpenProjectTab(workspaceRoot, topicID string) (TabMeta, error) {
2354 return a.openProjectTab(workspaceRoot, topicID)
2355 }
2356
2357 func (a *App) openProjectTab(workspaceRoot, topicID string) (TabMeta, error) {
2358 if workspaceRoot == "" {
2359 return TabMeta{}, fmt.Errorf("workspaceRoot is required")
2360 }
2361 if abs, err := filepath.Abs(workspaceRoot); err == nil {
2362 workspaceRoot = abs
2363 }
2364 saveWorkspace(workspaceRoot)
2365 a.registerProjectRoot(workspaceRoot)
2366
2367 sessionPath, _ := a.findTopicSessionForTarget("project", workspaceRoot, topicID)
2368 return a.openTopicTab("project", workspaceRoot, topicID, sessionPath)
2369 }
2370
2371 func (a *App) openTopicTab(scope, workspaceRoot, topicID, sessionPath string) (TabMeta, error) {
2372 return a.openTopicTabWithActivation(scope, workspaceRoot, topicID, sessionPath, true)
2373 }
2374
2375 func (a *App) openProjectTabInactive(workspaceRoot, topicID string) (TabMeta, error) {
2376 if workspaceRoot == "" {
2377 return TabMeta{}, fmt.Errorf("workspaceRoot is required")
2378 }
2379 if abs, err := filepath.Abs(workspaceRoot); err == nil {
2380 workspaceRoot = abs
2381 }
2382 a.registerProjectRoot(workspaceRoot)
2383
2384 sessionPath, _ := a.findTopicSessionForTarget("project", workspaceRoot, topicID)
2385 return a.openTopicTabWithActivation("project", workspaceRoot, topicID, sessionPath, false)
2386 }
2387
2388 func (a *App) openGlobalTabInactive(topicID string) (TabMeta, error) {
2389 globalRoot := globalWorkspaceRoot()
2390 if err := os.MkdirAll(globalRoot, 0o755); err != nil {
2391 return TabMeta{}, fmt.Errorf("create global workspace: %w", err)
2392 }
2393
2394 sessionPath, _ := a.findTopicSessionForTarget("global", "", topicID)
2395 return a.openTopicTabWithActivation("global", "", topicID, sessionPath, false)
2396 }
2397
2398 func (a *App) openTopicTabWithActivation(scope, workspaceRoot, topicID, sessionPath string, activate bool) (TabMeta, error) {
2399 actualRoot := workspaceRoot
2400 if scope == "global" {
2401 actualRoot = globalWorkspaceRoot()
2402 }
2403 targetKey := sessionRuntimeKey(sessionPath)
2404
2405 a.mu.Lock()
2406 if targetKey != "" {
2407 for _, tab := range a.tabs {
2408 if tab == nil {
2409 continue
2410 }
2411 if sessionRuntimeKey(tab.currentSessionPath()) == targetKey {
2412 if activate {
2413 a.activeTabID = tab.ID
2414 }
2415 meta := a.tabMeta(tab, tab.ID == a.activeTabID)
2416 a.saveTabsLocked()
2417 a.mu.Unlock()
2418 return enrichTabMeta(meta), nil
2419 }
2420 }
2421 }
2422
2423 for _, tab := range a.tabs {
2424 if tabMatchesTopicTarget(tab, scope, workspaceRoot, topicID) {
2425 if activate {
2426 a.activeTabID = tab.ID
2427 }
2428 sameSession := targetKey == "" || sessionRuntimeKey(tab.currentSessionPath()) == targetKey
2429 meta := a.tabMeta(tab, tab.ID == a.activeTabID)
2430 a.saveTabsLocked()
2431 a.mu.Unlock()
2432 if sameSession {
2433 return enrichTabMeta(meta), nil
2434 }
2435 if err := a.rebindTabToSessionPath(tab, sessionPath); err != nil {
2436 return TabMeta{}, err
2437 }
2438 a.mu.RLock()
2439 meta = a.tabMeta(tab, tab.ID == a.activeTabID)
2440 a.mu.RUnlock()
2441 return enrichTabMeta(meta), nil
2442 }
2443 }
2444
2445 tabID := a.newUniqueTabIDLocked()
2446 topicTitle := topicTitleForTab(scope, workspaceRoot, topicID)
2447 if t, source, ok := topicTitleFallbackForOpen(workspaceRoot, topicID, sessionPath); ok {
2448 topicTitle = t
2449 _ = setTopicTitleWithSource(workspaceRoot, topicID, t, source)
2450 }
2451
2452 if sessionPath == "" {
2453 var err error
2454 sessionPath, err = createEmptySessionFile(desktopSessionDir(actualRoot), "")
2455 if err != nil {
2456 a.mu.Unlock()
2457 return TabMeta{}, err
2458 }
2459 if err := pinNewEmptySessionBranchMeta(sessionPath, scope, actualRoot, topicID, topicTitle); err != nil {
2460 a.mu.Unlock()
2461 return TabMeta{}, err
2462 }
2463 }
2464 profile := loadTabSessionProfile(sessionPath)
2465 tab := &WorkspaceTab{
2466 ID: tabID,
2467 Scope: scope,
2468 WorkspaceRoot: actualRoot,
2469 TopicID: topicID,
2470 TopicTitle: topicTitle,
2471 topicTitleSource: loadTopicTitleSource(topicTitleRoot(scope, workspaceRoot), topicID),
2472 SessionPath: sessionPath,
2473 disabledMCP: map[string]ServerView{},
2474 }
2475 applyTabSessionProfile(tab, profile)
2476 tab.sink = &tabEventSink{tabID: tabID, app: a}
2477
2478 a.tabs[tabID] = tab
2479 a.tabOrder = append(a.tabOrder, tabID)
2480 if activate {
2481 a.activeTabID = tabID
2482 }
2483 a.saveTabsLocked()
2484 meta := a.tabMeta(tab, tab.ID == a.activeTabID)
2485 a.mu.Unlock()
2486
2487 a.startTabControllerBuild(tab)
2488 if scope == "project" {
2489 a.emitProjectTreeChangedForSessionDirs(sessionListCacheDirForPath(sessionPath))
2490 }
2491 return enrichTabMeta(meta), nil
2492 }
2493
2494 // OpenGlobalTab opens a new global-scope tab (no project root). The global
2495 // workspace root is the reasonix user config directory.
2496 func (a *App) OpenGlobalTab(topicID string) (TabMeta, error) {
2497 return a.openGlobalTab(topicID)
2498 }
2499
2500 func (a *App) openGlobalTab(topicID string) (TabMeta, error) {
2501 globalRoot := globalWorkspaceRoot()
2502 if err := os.MkdirAll(globalRoot, 0o755); err != nil {
2503 return TabMeta{}, fmt.Errorf("create global workspace: %w", err)
2504 }
2505
2506 sessionPath, _ := a.findTopicSessionForTarget("global", "", topicID)
2507 return a.openTopicTab("global", "", topicID, sessionPath)
2508 }
2509
2510 // OpenTopicSession opens a concrete saved session from the sidebar. Unlike
2511 // OpenProjectTab/OpenGlobalTab, it does not resolve the topic to the latest
2512 // session first; sessionPath is the runtime identity being selected.
2513 func (a *App) OpenTopicSession(scope, workspaceRoot, topicID, sessionPath string) (TabMeta, error) {
2514 return a.openTopicSession(scope, workspaceRoot, topicID, sessionPath)
2515 }
2516
2517 func (a *App) openTopicSession(scope, workspaceRoot, topicID, sessionPath string) (TabMeta, error) {
2518 scope = strings.TrimSpace(scope)
2519 if scope != "project" {
2520 scope = "global"
2521 workspaceRoot = ""
2522 }
2523 if scope == "project" {
2524 workspaceRoot = normalizeProjectRoot(workspaceRoot)
2525 if workspaceRoot == "" {
2526 return TabMeta{}, fmt.Errorf("workspaceRoot is required")
2527 }
2528 saveWorkspace(workspaceRoot)
2529 a.registerProjectRoot(workspaceRoot)
2530 }
2531 _, validPath, err := a.sessionDirForPath(sessionPath)
2532 if err != nil {
2533 return TabMeta{}, err
2534 }
2535 return a.openTopicTab(scope, workspaceRoot, topicID, validPath)
2536 }
2537
2538 // ActivateTopic opens a topic into the single visible conversation surface used
2539 // by layouts without a tab strip. It delegates the actual open/reuse behavior to
2540 // the classic tab path, then prunes every non-active visible tab so historical
2541 // clicks do not accumulate hidden startup work.
2542 func (a *App) ActivateTopic(scope, workspaceRoot, topicID, sessionPath string) (TabMeta, error) {
2543 a.singleSurfaceMu.Lock()
2544 defer a.singleSurfaceMu.Unlock()
2545
2546 var meta TabMeta
2547 var err error
2548 if strings.TrimSpace(sessionPath) != "" {
2549 meta, err = a.openTopicSession(scope, workspaceRoot, topicID, sessionPath)
2550 } else if strings.TrimSpace(scope) == "project" {
2551 meta, err = a.openProjectTab(workspaceRoot, topicID)
2552 } else {
2553 meta, err = a.openGlobalTab(topicID)
2554 }
2555 if err != nil {
2556 return TabMeta{}, err
2557 }
2558 return a.keepOnlyVisibleTab(meta.ID)
2559 }
2560
2561 // EnsureBlankSurface mirrors EnsureBlankTab for no-tab-strip layouts: after
2562 // creating or reusing a blank session, it removes other visible tabs while
2563 // preserving running runtimes as detached background sessions.
2564 func (a *App) EnsureBlankSurface(scope, workspaceRoot string) (TabMeta, error) {
2565 return a.ensureBlankSurface(scope, workspaceRoot, "")
2566 }
2567
2568 func (a *App) ensureBlankSurface(scope, workspaceRoot, tokenMode string) (TabMeta, error) {
2569 a.singleSurfaceMu.Lock()
2570 defer a.singleSurfaceMu.Unlock()
2571
2572 meta, err := a.ensureBlankTab(scope, workspaceRoot, tokenMode)
2573 if err != nil {
2574 return TabMeta{}, err
2575 }
2576 return a.keepOnlyVisibleTab(meta.ID)
2577 }
2578
2579 func tabMatchesTopicTarget(tab *WorkspaceTab, scope, workspaceRoot, topicID string) bool {
2580 if tab == nil || tab.Scope != scope || tab.TopicID != topicID {
2581 return false
2582 }
2583 if scope == "global" {
2584 return true
2585 }
2586 return sameProjectRoot(tab.WorkspaceRoot, workspaceRoot)
2587 }
2588
2589 func tabInWorkspace(tab *WorkspaceTab, workspaceRoot string) bool {
2590 return tab != nil &&
2591 tab.Scope == "project" &&
2592 sameProjectRoot(tab.WorkspaceRoot, workspaceRoot)
2593 }
2594
2595 // EnsureBlankTab activates the existing blank tab for the target scope, or
2596 // creates one if none exists. Reusing a blank tab keeps repeated "new session"
2597 // clicks from piling up empty conversations.
2598 func (a *App) EnsureBlankTab(scope, workspaceRoot string) (TabMeta, error) {
2599 return a.ensureBlankTab(scope, workspaceRoot, "")
2600 }
2601
2602 func (a *App) ensureBlankTab(scope, workspaceRoot, forcedTokenMode string) (TabMeta, error) {
2603 scope = strings.TrimSpace(scope)
2604 if scope != "project" {
2605 scope = "global"
2606 }
2607
2608 globalRoot := ""
2609 if scope == "project" {
2610 workspaceRoot = strings.TrimSpace(workspaceRoot)
2611 if workspaceRoot == "" {
2612 return TabMeta{}, fmt.Errorf("workspaceRoot is required")
2613 }
2614 if abs, err := filepath.Abs(workspaceRoot); err == nil {
2615 workspaceRoot = abs
2616 }
2617 saveWorkspace(workspaceRoot)
2618 a.registerProjectRoot(workspaceRoot)
2619 } else {
2620 workspaceRoot = ""
2621 globalRoot = globalWorkspaceRoot()
2622 if err := os.MkdirAll(globalRoot, 0o755); err != nil {
2623 return TabMeta{}, fmt.Errorf("create global workspace: %w", err)
2624 }
2625 }
2626
2627 var created *WorkspaceTab
2628 // Compute actual root early — both the indexed-topic fallback and the
2629 // new-topic path need it when constructing the tab below.
2630 actualRoot := workspaceRoot
2631 if scope == "global" {
2632 actualRoot = globalRoot
2633 }
2634 defaultModel, defaultToolApprovalMode := desktopNewSessionDefaults(scope, actualRoot)
2635
2636 a.mu.Lock()
2637 var reusable *WorkspaceTab
2638 for _, id := range a.orderedTabIDsLocked() {
2639 tab := a.tabs[id]
2640 if a.blankTabMatchesTargetLocked(tab, scope, workspaceRoot) {
2641 if err := resetReusableBlankTabTitle(tab, scope, workspaceRoot); err != nil {
2642 a.mu.Unlock()
2643 return TabMeta{}, err
2644 }
2645 reusable = tab
2646 break
2647 }
2648 }
2649 if reusable != nil {
2650 a.mu.Unlock()
2651 if err := a.alignReusableBlankTabModel(reusable, defaultModel); err != nil {
2652 return TabMeta{}, err
2653 }
2654 a.mu.Lock()
2655 if reusable.removed || a.tabs[reusable.ID] != reusable {
2656 a.mu.Unlock()
2657 return TabMeta{}, fmt.Errorf("blank session changed while applying the default model; retry")
2658 }
2659 a.activeTabID = reusable.ID
2660 meta := a.tabMeta(reusable, true)
2661 a.saveTabsLocked()
2662 a.mu.Unlock()
2663 return enrichTabMeta(meta), nil
2664 }
2665
2666 // New blank sessions start from global session defaults for model and
2667 // Ask/Auto/YOLO approval posture. Keep the remaining execution-local settings
2668 // from the active tab so a new blank session preserves effort/token/MCP
2669 // continuity without letting the active tab override global defaults (#4019).
2670 inheritedModel := defaultModel
2671 var inheritedEffort *string
2672 inheritedTokenMode := boot.TokenModeFull
2673 inheritedMode := tabModeFromAxes(false, defaultToolApprovalMode == control.ToolApprovalYolo)
2674 inheritedToolApprovalMode := defaultToolApprovalMode
2675 inheritedDisabledMCP := map[string]ServerView{}
2676 var inheritedMCPOrder []string
2677 if active := a.activeTabLocked(); active != nil {
2678 inheritedEffort = cloneStringPtr(active.effort)
2679 inheritedTokenMode = currentTabTokenMode(active)
2680 inheritedDisabledMCP = cloneServerViewMap(active.disabledMCP)
2681 inheritedMCPOrder = append([]string(nil), active.mcpOrder...)
2682 }
2683 if strings.TrimSpace(forcedTokenMode) != "" {
2684 inheritedTokenMode = boot.NormalizeTokenMode(forcedTokenMode)
2685 }
2686
2687 if topicID := a.indexedBlankTopicIDLocked(scope, workspaceRoot); topicID != "" {
2688 // Reuse a previously-indexed but unused blank topic instead of
2689 // creating a new one. Build it inline (not via OpenProjectTab /
2690 // OpenGlobalTab) so it inherits settings from the active tab.
2691 if loadTopicCreatedAt(topicTitleRoot(scope, workspaceRoot), topicID) <= 0 {
2692 createdAt := topicIDCreatedAt(topicID)
2693 if createdAt <= 0 {
2694 createdAt = time.Now().UnixMilli()
2695 }
2696 _ = setTopicCreatedAt(topicTitleRoot(scope, workspaceRoot), topicID, createdAt)
2697 }
2698 tabID := a.newUniqueTabIDLocked()
2699 topicTitle := topicTitleForTab(scope, workspaceRoot, topicID)
2700 created = &WorkspaceTab{
2701 ID: tabID,
2702 Scope: scope,
2703 WorkspaceRoot: actualRoot,
2704 TopicID: topicID,
2705 TopicTitle: topicTitle,
2706 topicTitleSource: loadTopicTitleSource(topicTitleRoot(scope, workspaceRoot), topicID),
2707 model: inheritedModel,
2708 effort: inheritedEffort,
2709 tokenMode: inheritedTokenMode,
2710 mode: inheritedMode,
2711 toolApprovalMode: inheritedToolApprovalMode,
2712 disabledMCP: inheritedDisabledMCP,
2713 mcpOrder: inheritedMCPOrder,
2714 }
2715 created.sink = &tabEventSink{tabID: tabID, app: a}
2716 a.tabs[tabID] = created
2717 a.tabOrder = append(a.tabOrder, tabID)
2718 a.activeTabID = tabID
2719 prePath, err := createEmptySessionFile(desktopSessionDir(actualRoot), inheritedModel)
2720 if err != nil {
2721 delete(a.tabs, tabID)
2722 a.removeTabOrderLocked(tabID)
2723 a.mu.Unlock()
2724 return TabMeta{}, err
2725 }
2726 if err := pinNewEmptySessionBranchMeta(prePath, scope, actualRoot, topicID, topicTitle); err != nil {
2727 delete(a.tabs, tabID)
2728 a.removeTabOrderLocked(tabID)
2729 a.mu.Unlock()
2730 return TabMeta{}, err
2731 }
2732 created.SessionPath = prePath
2733 a.saveTabsLocked()
2734 meta := a.tabMeta(created, true)
2735 a.mu.Unlock()
2736
2737 a.startTabControllerBuild(created)
2738 a.emitProjectTreeChangedForSessionDirs(sessionListCacheDirForPath(prePath))
2739 return enrichTabMeta(meta), nil
2740 }
2741
2742 topicID := newTopicID()
2743 topicTitle := defaultTopicTitle
2744 createdAt := time.Now().UnixMilli()
2745 if err := setTopicTitleWithSource(workspaceRoot, topicID, topicTitle, topicTitleSourceAuto); err != nil {
2746 a.mu.Unlock()
2747 return TabMeta{}, err
2748 }
2749 if err := setTopicCreatedAt(workspaceRoot, topicID, createdAt); err != nil {
2750 a.mu.Unlock()
2751 return TabMeta{}, err
2752 }
2753 _ = prependTopicInProjectsFile(workspaceRoot, topicID, false)
2754
2755 tabID := a.newUniqueTabIDLocked()
2756 created = &WorkspaceTab{
2757 ID: tabID,
2758 Scope: scope,
2759 WorkspaceRoot: actualRoot,
2760 TopicID: topicID,
2761 TopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),
2762 topicTitleSource: topicTitleSourceAuto,
2763 model: inheritedModel,
2764 effort: inheritedEffort,
2765 tokenMode: inheritedTokenMode,
2766 mode: inheritedMode,
2767 toolApprovalMode: inheritedToolApprovalMode,
2768 disabledMCP: inheritedDisabledMCP,
2769 mcpOrder: inheritedMCPOrder,
2770 }
2771 created.sink = &tabEventSink{tabID: tabID, app: a}
2772 a.tabs[tabID] = created
2773 a.tabOrder = append(a.tabOrder, tabID)
2774 a.activeTabID = tabID
2775 prePath, err := createEmptySessionFile(desktopSessionDir(actualRoot), inheritedModel)
2776 if err != nil {
2777 delete(a.tabs, tabID)
2778 a.removeTabOrderLocked(tabID)
2779 a.mu.Unlock()
2780 return TabMeta{}, err
2781 }
2782 if err := pinNewEmptySessionBranchMeta(prePath, scope, actualRoot, topicID, topicTitle); err != nil {
2783 delete(a.tabs, tabID)
2784 a.removeTabOrderLocked(tabID)
2785 a.mu.Unlock()
2786 return TabMeta{}, err
2787 }
2788 created.SessionPath = prePath
2789 a.saveTabsLocked()
2790 meta := a.tabMeta(created, true)
2791 a.mu.Unlock()
2792
2793 a.startTabControllerBuild(created)
2794 a.emitProjectTreeChangedForSessionDirs(sessionListCacheDirForPath(prePath))
2795 return enrichTabMeta(meta), nil
2796 }
2797
2798 // alignReusableBlankTabModel makes a reused empty session obey the same
2799 // provider/model default as a newly-created session. Ready runtimes use the
2800 // normal failure-atomic model switch. A tab that is still starting has no
2801 // controller to swap, so invalidate its startup generation, update the empty
2802 // session's model metadata, and restart the build from the intended provider.
2803 func (a *App) alignReusableBlankTabModel(tab *WorkspaceTab, model string) error {
2804 model = strings.TrimSpace(model)
2805 if tab == nil || model == "" {
2806 return nil
2807 }
2808
2809 a.mu.RLock()
2810 if tab.removed || a.tabs[tab.ID] != tab {
2811 a.mu.RUnlock()
2812 return fmt.Errorf("blank session changed while applying the default model; retry")
2813 }
2814 currentModel := strings.TrimSpace(tab.model)
2815 ctrl := tab.Ctrl
2816 path := strings.TrimSpace(tab.SessionPath)
2817 a.mu.RUnlock()
2818
2819 storedModel, hasStoredModel := agent.LoadSessionModel(path)
2820 storedModelChanged := path != "" && (!hasStoredModel || strings.TrimSpace(storedModel) != model)
2821
2822 if ctrl != nil {
2823 if currentModel != model {
2824 if err := a.SetModelForTab(tab.ID, model); err != nil {
2825 return err
2826 }
2827 } else if storedModelChanged {
2828 return a.persistTabModelIfCurrent(tab, model)
2829 }
2830 return nil
2831 }
2832
2833 if currentModel == model && !storedModelChanged {
2834 return nil
2835 }
2836 if storedModelChanged {
2837 // With no published controller there is nothing to swap atomically. Fix
2838 // the empty session metadata first so the replacement startup cannot
2839 // prefer the outgoing provider over the corrected tab model.
2840 if err := agent.SetBranchModelPreserveUpdated(path, model); err != nil {
2841 return fmt.Errorf("persist default model for blank session: %w", err)
2842 }
2843 }
2844
2845 // A startup build may already have read the old sidecar model. Fence and
2846 // cancel that generation before publishing the corrected tab model, then
2847 // start a replacement build. The generation check prevents the cancelled
2848 // build from overwriting the replacement if it completes late.
2849 a.mu.Lock()
2850 if tab.removed || a.tabs[tab.ID] != tab {
2851 a.mu.Unlock()
2852 return fmt.Errorf("blank session changed while applying the default model; retry")
2853 }
2854 if tab.Ctrl != nil {
2855 a.mu.Unlock()
2856 return a.alignReusableBlankTabModel(tab, model)
2857 }
2858 a.supersedeTabBuildLocked(tab)
2859 tab.model = model
2860 tab.Label = model
2861 tab.Ready = false
2862 clearTabStartupError(tab)
2863 a.saveTabsLocked()
2864 a.mu.Unlock()
2865 a.startTabControllerBuild(tab)
2866 return nil
2867 }
2868
2869 // blankTabMatchesTargetLocked returns true if tab is a reusable blank tab
2870 // matching the given scope/project root — no running controller, no real history.
2871 func (a *App) blankTabMatchesTargetLocked(tab *WorkspaceTab, scope, workspaceRoot string) bool {
2872 if tab == nil || tab.Scope != scope {
2873 return false
2874 }
2875 if scope == "project" && !sameProjectRoot(tab.WorkspaceRoot, workspaceRoot) {
2876 return false
2877 }
2878 if tab.Ctrl == nil {
2879 return blankTabSessionPathHasNoContent(tab)
2880 }
2881 if tab.hasActiveRuntimeWork() {
2882 return false
2883 }
2884 return !messagesHaveConversationContent(tab.Ctrl.History())
2885 }
2886
2887 func createEmptySessionFile(dir, model string) (string, error) {
2888 dir = strings.TrimSpace(dir)
2889 if dir == "" {
2890 return "", fmt.Errorf("session dir is required")
2891 }
2892 if err := os.MkdirAll(dir, 0o755); err != nil {
2893 return "", err
2894 }
2895 for i := 0; i < 3; i++ {
2896 path := agent.NewSessionPath(dir, model)
2897 f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o644)
2898 if err == nil {
2899 if closeErr := f.Close(); closeErr != nil {
2900 return "", closeErr
2901 }
2902 // Ensure branch meta exists for topic ownership; Auto Guard no longer
2903 // stores a per-session toggle (it is built into Auto).
2904 _, _ = agent.EnsureBranchMeta(path)
2905 return path, nil
2906 }
2907 if os.IsExist(err) {
2908 continue
2909 }
2910 return "", err
2911 }
2912 return "", fmt.Errorf("create empty session file: exhausted filename retries")
2913 }
2914
2915 func pinNewEmptySessionBranchMeta(path, scope, workspaceRoot, topicID, topicTitle string) error {
2916 if err := pinSessionBranchMeta(path, scope, workspaceRoot, topicID, topicTitle); err != nil {
2917 pinErr := fmt.Errorf("pin empty session metadata: %w", err)
2918 if cleanupErr := removeDesktopSessionArtifacts(path); cleanupErr != nil {
2919 return errors.Join(pinErr, fmt.Errorf("clean up unbound empty session: %w", cleanupErr))
2920 }
2921 return pinErr
2922 }
2923 return nil
2924 }
2925
2926 // pinSessionBranchMeta stores the workspace scope, root, and topic on a newly
2927 // created session before a controller can reconcile the tab against it.
2928 func pinSessionBranchMeta(sessionPath, scope, workspaceRoot, topicID, topicTitle string) error {
2929 unlock := agent.LockSessionMetaPath(sessionPath)
2930 defer unlock()
2931 m, err := agent.EnsureBranchMeta(sessionPath)
2932 if err != nil {
2933 return err
2934 }
2935 if strings.TrimSpace(scope) == "project" {
2936 workspaceRoot = normalizeProjectRoot(workspaceRoot)
2937 if workspaceRoot == "" {
2938 return fmt.Errorf("project workspace root is required")
2939 }
2940 scope = "project"
2941 } else {
2942 scope = "global"
2943 workspaceRoot = ""
2944 }
2945 m.Scope = scope
2946 m.WorkspaceRoot = workspaceRoot
2947 m.TopicID = topicID
2948 m.TopicTitle = topicTitle
2949 return agent.SaveBranchMetaPreserveUpdated(sessionPath, m)
2950 }
2951
2952 func blankTabSessionPathHasNoContent(tab *WorkspaceTab) bool {
2953 if tab == nil {
2954 return false
2955 }
2956 if strings.TrimSpace(tab.SessionPath) == "" {
2957 return true
2958 }
2959 return sessionPathHasNoContent(tabSessionDir(tab), tab.SessionPath)
2960 }
2961
2962 func sessionPathHasNoContent(sessionDir, sessionPath string) bool {
2963 if strings.TrimSpace(sessionPath) == "" {
2964 return true
2965 }
2966 path, ok := pinnedTabSessionPath(sessionDir, sessionPath)
2967 if !ok {
2968 return false
2969 }
2970 info, err := os.Stat(path)
2971 if err != nil {
2972 return false
2973 }
2974 if info.IsDir() {
2975 return false
2976 }
2977 if info.Size() == 0 {
2978 return true
2979 }
2980 session, err := agent.LoadSession(path)
2981 if err != nil {
2982 return false
2983 }
2984 return !session.HasContent()
2985 }
2986
2987 func resetReusableBlankTabTitle(tab *WorkspaceTab, scope, workspaceRoot string) error {
2988 if tab == nil {
2989 return nil
2990 }
2991 topicID := strings.TrimSpace(tab.TopicID)
2992 if topicID == "" {
2993 return nil
2994 }
2995 titleRoot := topicTitleRoot(scope, workspaceRoot)
2996 if source := loadTopicTitleSource(titleRoot, topicID); source != topicTitleSourceAuto {
2997 return nil
2998 }
2999 if err := setTopicTitleWithSource(titleRoot, topicID, defaultTopicTitle, topicTitleSourceAuto); err != nil {
3000 return err
3001 }
3002 _ = deleteTopicAutoTitleMeta(titleRoot, topicID)
3003 tab.TopicTitle = defaultTopicTitle
3004 tab.topicTitleSource = topicTitleSourceAuto
3005 return nil
3006 }
3007
3008 // indexedBlankTopicIDLocked finds a blank topic ID that is indexed on disk
3009 // but not open in any tab — for reusing without creating a new topic.
3010 func (a *App) indexedBlankTopicIDLocked(scope, workspaceRoot string) string {
3011 titleRoot := topicTitleRoot(scope, workspaceRoot)
3012 titles := loadTopicTitles(titleRoot)
3013 f := loadProjectsFile()
3014
3015 var topicIDs []string
3016 if scope == "global" {
3017 topicIDs = orderedTopicIDs(f.GlobalTopics, titles)
3018 } else if i := projectIndexByRoot(f.Projects, workspaceRoot); i >= 0 {
3019 topicIDs = orderedTopicIDs(f.Projects[i].Topics, titles)
3020 }
3021 if len(topicIDs) == 0 {
3022 return ""
3023 }
3024 // Blank-tab reuse is an automatic write path: the reused ID flows into
3025 // ensureTopicIndexed, whose intentional single-topic prepend clears delete
3026 // tombstones. Picking a tombstoned topic here (its default title can
3027 // linger title-only after a delete raced a scan save) would therefore
3028 // fully resurrect a topic the user removed — skip them.
3029 deletedTopics := make(map[string]bool, len(f.DeletedTopics))
3030 for _, id := range f.DeletedTopics {
3031 deletedTopics[id] = true
3032 }
3033
3034 openTopics := map[string]bool{}
3035 for _, tab := range a.tabs {
3036 if tab == nil || tab.Scope != scope || strings.TrimSpace(tab.TopicID) == "" {
3037 continue
3038 }
3039 if scope == "project" && !sameProjectRoot(tab.WorkspaceRoot, workspaceRoot) {
3040 continue
3041 }
3042 openTopics[tab.TopicID] = true
3043 }
3044 seenSessionDirs := map[string]bool{}
3045 sessionIndexes := []topicSessionDirIndex{}
3046 addSessionIndex := func(dir string) {
3047 dir = cleanDesktopPath(dir)
3048 if dir == "" {
3049 return
3050 }
3051 if seenSessionDirs[dir] {
3052 return
3053 }
3054 seenSessionDirs[dir] = true
3055 if index, err := topicSessionIndexForDir(dir); err == nil {
3056 sessionIndexes = append(sessionIndexes, index)
3057 }
3058 }
3059 if scope == "project" {
3060 addSessionIndex(desktopSessionDir(workspaceRoot))
3061 } else {
3062 addSessionIndex(config.SessionDir())
3063 addSessionIndex(desktopSessionDir(globalWorkspaceRoot()))
3064 }
3065 for _, topicID := range topicIDs {
3066 if deletedTopics[topicID] || openTopics[topicID] {
3067 continue
3068 }
3069 if topicTitleForTab(scope, workspaceRoot, topicID) != defaultTopicTitle {
3070 continue
3071 }
3072 hasSession := false
3073 leaseHeld := false
3074 for _, index := range sessionIndexes {
3075 if topicSessionIndexHasContentTopic(index, topicID) {
3076 hasSession = true
3077 break
3078 }
3079 if topicSessionIndexHasForeignLeaseTopic(index, topicID) {
3080 leaseHeld = true
3081 }
3082 }
3083 if hasSession || leaseHeld {
3084 continue
3085 }
3086 return topicID
3087 }
3088 return ""
3089 }
3090
3091 // SetActiveTab switches the frontend's active tab. A no-op when tabID is
3092 // already active or unknown.
3093 func (a *App) SetActiveTab(tabID string) error {
3094 a.mu.RLock()
3095 _, ok := a.tabs[tabID]
3096 alreadyActive := a.activeTabID == tabID
3097 a.mu.RUnlock()
3098 if !ok {
3099 return fmt.Errorf("tab %q not found", tabID)
3100 }
3101 if alreadyActive {
3102 return nil
3103 }
3104 a.mu.RLock()
3105 active := a.tabs[a.activeTabID]
3106 a.mu.RUnlock()
3107 if err := a.snapshotTabForAction(active, "switching tabs"); err != nil {
3108 return err
3109 }
3110
3111 a.mu.Lock()
3112 if _, ok := a.tabs[tabID]; !ok {
3113 a.mu.Unlock()
3114 return fmt.Errorf("tab %q not found", tabID)
3115 }
3116 if a.activeTabID == tabID {
3117 a.mu.Unlock()
3118 return nil
3119 }
3120 a.activeTabID = tabID
3121 dir, entries, activeID, version := a.saveTabsCollectLocked()
3122 a.mu.Unlock()
3123
3124 // I/O outside the lock — disk writes can block for hundreds of ms on
3125 // Windows when antivirus or the search indexer briefly locks the file.
3126 a.saveTabsWrite(dir, entries, activeID, version)
3127 a.kickDeferredRebuildRetry()
3128 return nil
3129 }
3130
3131 // ReorderTabs persists the frontend's manual tab order. The submitted order must
3132 // contain every currently open tab exactly once.
3133 func (a *App) ReorderTabs(tabIDs []string) error {
3134 a.mu.Lock()
3135 defer a.mu.Unlock()
3136 if len(tabIDs) != len(a.tabs) {
3137 return fmt.Errorf("tab order length mismatch")
3138 }
3139 seen := make(map[string]bool, len(tabIDs))
3140 next := make([]string, 0, len(tabIDs))
3141 for _, id := range tabIDs {
3142 if _, ok := a.tabs[id]; !ok {
3143 return fmt.Errorf("tab %q not found", id)
3144 }
3145 if seen[id] {
3146 return fmt.Errorf("duplicate tab %q", id)
3147 }
3148 seen[id] = true
3149 next = append(next, id)
3150 }
3151 a.tabOrder = next
3152 a.saveTabsLocked()
3153 return nil
3154 }
3155
3156 // CloseTab removes a visible tab. If the tab's session still has foreground or
3157 // background work, the controller is detached so closing a view does not destroy
3158 // the session runtime.
3159 func (a *App) CloseTab(tabID string) error {
3160 return a.closeTab(tabID, true)
3161 }
3162
3163 func (a *App) closeTab(tabID string, allowDetach bool) error {
3164 defer a.lockRuntimeMutation("close-tab")()
3165 a.sessionRemovalMu.Lock()
3166 defer a.sessionRemovalMu.Unlock()
3167 // The runtime mutation barrier is acquired before sessionRemovalMu. This waits
3168 // for a turn whose admission is already in progress, blocks later turns/builds,
3169 // and leaves the tab visible until an earlier MCP Host-wide gate completes.
3170
3171 a.mu.Lock()
3172 tab, ok := a.tabs[tabID]
3173 if !ok {
3174 a.mu.Unlock()
3175 return fmt.Errorf("tab %q not found", tabID)
3176 }
3177 if len(a.tabs) <= 1 {
3178 a.mu.Unlock()
3179 return fmt.Errorf("cannot close the last tab")
3180 }
3181 a.mu.Unlock()
3182
3183 // Snapshot while the tab binding is still present, but outside a.mu because
3184 // snapshot recovery can re-enter App and acquire a.mu. sessionRemovalMu keeps
3185 // DeleteSession/topic/workspace removal from trashing the same files while
3186 // this save is in flight.
3187 if err := a.snapshotTab(tab); err != nil {
3188 slog.Warn("desktop: snapshot before closing tab failed", "tab", tabID, "err", err)
3189 return fmt.Errorf("save current session before closing tab: %w", err)
3190 }
3191 if err := a.saveTabSessionMetaForCurrentSession(tab); err != nil {
3192 slog.Warn("desktop: session metadata before closing tab failed", "tab", tabID, "err", err)
3193 return fmt.Errorf("save current session metadata before closing tab: %w", err)
3194 }
3195 // A terminal belongs to the visible chat tab, even when another tab points
3196 // at the same project. Reap its PTY before removing the tab binding.
3197 if a.terminals != nil {
3198 a.terminals.closeForTab(tabID)
3199 }
3200
3201 a.mu.Lock()
3202 if current := a.tabs[tabID]; current != tab {
3203 a.mu.Unlock()
3204 if current == nil {
3205 return fmt.Errorf("tab %q not found", tabID)
3206 }
3207 return fmt.Errorf("tab %q changed while closing", tabID)
3208 }
3209 if len(a.tabs) <= 1 {
3210 a.mu.Unlock()
3211 return fmt.Errorf("cannot close the last tab")
3212 }
3213 if !allowDetach && tab.hasActiveRuntimeWork() {
3214 a.mu.Unlock()
3215 return fmt.Errorf("task still has active work")
3216 }
3217 if tab.Ctrl == nil || !tab.hasActiveRuntimeWork() {
3218 a.markTabRemovedLocked(tab)
3219 }
3220
3221 ordered := a.orderedTabIDsLocked()
3222 closedIndex := -1
3223 for i, id := range ordered {
3224 if id == tabID {
3225 closedIndex = i
3226 break
3227 }
3228 }
3229 delete(a.tabs, tabID)
3230 a.removeTabOrderLocked(tabID)
3231 wasActive := a.activeTabID == tabID
3232 if wasActive {
3233 a.activeTabID = ""
3234 if len(a.tabOrder) > 0 {
3235 nextIndex := closedIndex
3236 if nextIndex < 0 {
3237 nextIndex = 0
3238 }
3239 if nextIndex >= len(a.tabOrder) {
3240 nextIndex = len(a.tabOrder) - 1
3241 }
3242 a.activeTabID = a.tabOrder[nextIndex]
3243 }
3244 }
3245 a.saveTabsLocked()
3246 // Snapshot the teardown targets while still holding the lock: the tab is
3247 // no longer reachable from a.tabs after this section, but locked writers
3248 // holding stale pointers (rememberTabSessionPath, applySessionBindingToTab)
3249 // can still write its fields under a.mu.
3250 closeCtrl := tab.Ctrl
3251 closeSink := tab.sink
3252 a.mu.Unlock()
3253
3254 // Tear down outside App.mu while retaining the lifecycle barrier acquired
3255 // before the tab binding was removed.
3256 discardPath, discardTransientBlank := a.transientBlankSessionArtifactPath(tab)
3257 if closeCtrl != nil {
3258 if allowDetach && controllerHasActiveRuntimeWork(closeCtrl) && a.detachSessionRuntime(tab) {
3259 // Detached runtimes keep running and must keep saving: do not
3260 // clear the path or drain for them.
3261 return nil
3262 }
3263 closeCtrl.SetSessionPath("") // future snapshots become no-ops
3264 a.quiesceTabAutosave(tab) // wait for any in-flight snapshot to finish
3265 closeCtrl.Cancel()
3266 closeCtrl.Close()
3267 // Release the shared plugin host reference. The host stays alive as
3268 // long as any other tab for the same workspace root holds a reference;
3269 // on the last release the host is closed and its subprocesses exit.
3270 a.releaseTabSharedHost(tab)
3271 tab.releaseSessionLease()
3272 }
3273 if closeSink != nil {
3274 closeSink.clearContext() // stop further emissions (nil ctx -> Emit becomes no-op)
3275 }
3276 if discardTransientBlank {
3277 discardTransientBlankSessionArtifacts(discardPath)
3278 }
3279 return nil
3280 }
3281
3282 func (a *App) keepOnlyVisibleTab(tabID string) (TabMeta, error) {
3283 type pruneCandidate struct {
3284 id string
3285 tab *WorkspaceTab
3286 }
3287
3288 // sessionRemovalMu covers snapshotting, pruning the hidden bindings, and
3289 // closing the removed runtimes (a detached runtime must finish its
3290 // in-flight autosave before DeleteSession can see the files). The
3291 // project-tree event stays outside so a listener can never re-enter a
3292 // removal path while the lock is held.
3293 meta, err := func() (TabMeta, error) {
3294 defer a.lockRuntimeMutation("prune-visible-tabs")()
3295 a.sessionRemovalMu.Lock()
3296 defer a.sessionRemovalMu.Unlock()
3297
3298 a.mu.Lock()
3299 active := a.tabs[tabID]
3300 if active == nil {
3301 a.mu.Unlock()
3302 return TabMeta{}, fmt.Errorf("tab %q not found", tabID)
3303 }
3304 candidates := make([]pruneCandidate, 0, len(a.tabs)-1)
3305 for id, tab := range a.tabs {
3306 if id == tabID {
3307 continue
3308 }
3309 candidates = append(candidates, pruneCandidate{id: id, tab: tab})
3310 }
3311 a.mu.Unlock()
3312
3313 // Keep tab bindings in a.tabs while saving so DeleteSession still sees
3314 // them, but do not hold a.mu: Snapshot can run recovery callbacks that
3315 // re-enter App and need the same lock.
3316 snapshotted := make(map[string]*WorkspaceTab, len(candidates))
3317 for _, candidate := range candidates {
3318 id, tab := candidate.id, candidate.tab
3319 snapshotted[id] = tab
3320 if err := a.snapshotTab(tab); err != nil {
3321 slog.Warn("desktop: snapshot before pruning hidden tab failed", "tab", id, "err", err)
3322 return TabMeta{}, fmt.Errorf("save current session before switching tabs: %w", err)
3323 }
3324 if err := a.saveTabSessionMetaForCurrentSession(tab); err != nil {
3325 slog.Warn("desktop: session metadata before pruning hidden tab failed", "tab", id, "err", err)
3326 return TabMeta{}, fmt.Errorf("save current session metadata before switching tabs: %w", err)
3327 }
3328 }
3329
3330 a.mu.Lock()
3331 active = a.tabs[tabID]
3332 if active == nil {
3333 a.mu.Unlock()
3334 return TabMeta{}, fmt.Errorf("tab %q not found", tabID)
3335 }
3336 for id, tab := range a.tabs {
3337 if id != tabID && snapshotted[id] != tab {
3338 a.mu.Unlock()
3339 return TabMeta{}, fmt.Errorf("visible tabs changed while switching; retry")
3340 }
3341 }
3342 a.activeTabID = tabID
3343 removed := make([]*WorkspaceTab, 0, len(candidates))
3344 for _, candidate := range candidates {
3345 id, tab := candidate.id, candidate.tab
3346 if tab == nil || a.tabs[id] != tab {
3347 continue
3348 }
3349 if tab.Ctrl == nil || !tab.hasActiveRuntimeWork() {
3350 a.markTabRemovedLocked(tab)
3351 }
3352 removed = append(removed, tab)
3353 delete(a.tabs, id)
3354 a.removeTabOrderLocked(id)
3355 }
3356 a.tabOrder = []string{tabID}
3357 a.saveTabsLocked()
3358 meta := a.tabMeta(active, true)
3359 a.mu.Unlock()
3360
3361 for _, tab := range removed {
3362 a.removeVisibleTabRuntimeAdmissionHeld(tab)
3363 }
3364 return meta, nil
3365 }()
3366 if err != nil {
3367 return TabMeta{}, err
3368 }
3369 a.emitProjectTreeChanged()
3370 return enrichTabMeta(meta), nil
3371 }
3372
3373 func (a *App) applySingleSurfaceTabPolicy() error {
3374 a.singleSurfaceMu.Lock()
3375 defer a.singleSurfaceMu.Unlock()
3376
3377 a.mu.RLock()
3378 tabID := a.activeTabID
3379 if tabID == "" || a.tabs[tabID] == nil {
3380 for _, id := range a.tabOrder {
3381 if a.tabs[id] != nil {
3382 tabID = id
3383 break
3384 }
3385 }
3386 if tabID == "" {
3387 for id := range a.tabs {
3388 tabID = id
3389 break
3390 }
3391 }
3392 }
3393 a.mu.RUnlock()
3394 if tabID == "" {
3395 return nil
3396 }
3397 _, err := a.keepOnlyVisibleTab(tabID)
3398 return err
3399 }
3400
3401 func (a *App) removeVisibleTabRuntimeAdmissionHeld(tab *WorkspaceTab) {
3402 if tab == nil {
3403 return
3404 }
3405 if err := a.snapshotTab(tab); err != nil {
3406 slog.Warn("desktop: snapshot before removing visible tab runtime failed", "tab", tab.ID, "err", err)
3407 }
3408 discardPath, discardTransientBlank := a.transientBlankSessionArtifactPath(tab)
3409 a.mu.RLock()
3410 ctrl := tab.Ctrl
3411 a.mu.RUnlock()
3412 if ctrl != nil && controllerHasActiveRuntimeWork(ctrl) && a.detachSessionRuntime(tab) {
3413 return
3414 }
3415 a.markTabRemoved(tab)
3416 a.closeTabRuntimeAdmissionHeld(tab)
3417 if discardTransientBlank {
3418 discardTransientBlankSessionArtifacts(discardPath)
3419 }
3420 }
3421
3422 // transientBlankSessionArtifactPath reports the artifact path to discard when
3423 // closing a still-blank tab. It snapshots the racy tab fields under a.mu and
3424 // keeps the file probe (sessionPathHasNoContent) outside the lock. Callers
3425 // must not hold a.mu.
3426 func (a *App) transientBlankSessionArtifactPath(tab *WorkspaceTab) (string, bool) {
3427 if tab == nil {
3428 return "", false
3429 }
3430 snap := a.tabRuntimeSnapshot(tab)
3431 if snap.readOnly || strings.TrimSpace(snap.topicID) != "" || controllerHasActiveRuntimeWork(snap.ctrl) {
3432 return "", false
3433 }
3434 if strings.TrimSpace(snap.sessionPath) == "" {
3435 return "", false
3436 }
3437 dir := sessionDirForSnapshot(snap)
3438 if !sessionPathHasNoContent(dir, snap.sessionPath) {
3439 return "", false
3440 }
3441 path, ok := pinnedTabSessionPath(dir, snap.sessionPath)
3442 if !ok {
3443 return "", false
3444 }
3445 return path, true
3446 }
3447
3448 func discardTransientBlankSessionArtifacts(path string) {
3449 if strings.TrimSpace(path) == "" {
3450 return
3451 }
3452 if err := removeDesktopSessionArtifacts(path); err != nil {
3453 slog.Warn("desktop: discard transient blank session artifacts failed", "path", path, "err", err)
3454 }
3455 }
3456
3457 func (a *App) markTabRemoved(tab *WorkspaceTab) {
3458 a.mu.Lock()
3459 a.markTabRemovedLocked(tab)
3460 a.mu.Unlock()
3461 }
3462
3463 func (a *App) markTabRemovedLocked(tab *WorkspaceTab) {
3464 if tab == nil {
3465 return
3466 }
3467 tab.removed = true
3468 if tab.buildCancel != nil {
3469 tab.buildCancel()
3470 tab.buildCancel = nil
3471 }
3472 }
3473
3474 // tabBuildSupersededLocked reports whether an in-flight build lost ownership
3475 // of its tab: the tab was removed/replaced, or a session rebind bumped
3476 // buildGeneration to invalidate it. Generation 0 marks the synchronous
3477 // rebuild paths, which serialize through runtimeRebuildMu instead and are
3478 // never superseded by generation bumps. Callers must hold a.mu.
3479 func (a *App) tabBuildSupersededLocked(tab *WorkspaceTab, generation uint64) bool {
3480 if tab == nil || tab.removed || a.tabs[tab.ID] != tab {
3481 return true
3482 }
3483 return generation != 0 && tab.buildGeneration != generation
3484 }
3485
3486 func (a *App) tabBuildSuperseded(tab *WorkspaceTab, generation uint64) bool {
3487 if tab == nil {
3488 return true
3489 }
3490 a.mu.RLock()
3491 defer a.mu.RUnlock()
3492 return a.tabBuildSupersededLocked(tab, generation)
3493 }
3494
3495 // supersedeTabBuildLocked invalidates any in-flight startup build and cancels
3496 // its context. A synchronous rebuild (model/effort/token switch) that has
3497 // already installed its controller calls this so a slower blank-session build
3498 // cannot finish afterward, overwrite tab.Ctrl, and release or steal the
3499 // session lease the switch just bound. Callers must hold a.mu.
3500 func (a *App) supersedeTabBuildLocked(tab *WorkspaceTab) {
3501 if tab == nil {
3502 return
3503 }
3504 tab.buildGeneration++
3505 if tab.buildCancel != nil {
3506 tab.buildCancel()
3507 tab.buildCancel = nil
3508 }
3509 }
3510
3511 // abandonSupersededBuild cleans up after a build that lost tab ownership
3512 // mid-flight (removed tab, or a session rebind bumped the generation). It
3513 // releases only what THIS build acquired — its controller, its own
3514 // shared-host reference (rootKey), and the session lease bound to its own
3515 // path (leaseKey) — and never reads or clears the tab's SharedHostKey or
3516 // lease outright: on a live rebound tab the replacement build may already
3517 // have published its own key and lease there, and taking those would leak
3518 // the new runtime's host reference (or close a host still in use) and strip
3519 // the new session's lease. Callers must not hold a.mu.
3520 func (a *App) abandonSupersededBuild(tab *WorkspaceTab, ctrl control.SessionAPI, rootKey, leaseKey string) {
3521 if ctrl != nil {
3522 ctrl.Close()
3523 }
3524 if rootKey != "" {
3525 a.releaseSharedHost(rootKey)
3526 }
3527 tab.releaseSessionLeaseForKey(leaseKey)
3528 }
3529
3530 func (a *App) clearTabBuildCancel(tab *WorkspaceTab, generation uint64, cancel context.CancelFunc, keepContext bool) {
3531 if cancel == nil {
3532 return
3533 }
3534 if !keepContext {
3535 defer cancel()
3536 }
3537 if tab == nil {
3538 return
3539 }
3540 a.mu.Lock()
3541 if tab.buildGeneration == generation {
3542 tab.buildCancel = nil
3543 }
3544 a.mu.Unlock()
3545 }
3546
3547 func (a *App) closeTabRuntimeAdmissionHeld(tab *WorkspaceTab) {
3548 if tab == nil {
3549 return
3550 }
3551 a.mu.RLock()
3552 ctrl := tab.Ctrl
3553 sink := tab.sink
3554 a.mu.RUnlock()
3555 if ctrl != nil {
3556 ctrl.SetSessionPath("") // future snapshots become no-ops
3557 a.quiesceTabAutosave(tab)
3558 ctrl.Cancel()
3559 ctrl.Close()
3560 a.releaseTabSharedHost(tab)
3561 }
3562 if sink != nil {
3563 sink.clearContext()
3564 }
3565 tab.releaseSessionLease()
3566 a.mu.Lock()
3567 a.releaseSessionRuntimeLocked(tab)
3568 a.mu.Unlock()
3569 }
3570
3571 // buildTabController assembles a controller for a tab in the background, the
3572 // same way buildController works for the single-controller App. On success it
3573 // wires the controller and flips Ready; on failure it stores StartupErr.
3574 func (a *App) startTabControllerBuild(tab *WorkspaceTab) {
3575 buildCtx, cancel := context.WithCancel(a.bootContext())
3576 a.mu.Lock()
3577 if tab == nil || tab.removed {
3578 a.mu.Unlock()
3579 cancel()
3580 return
3581 }
3582 tab.buildGeneration++
3583 generation := tab.buildGeneration
3584 tab.buildCancel = cancel
3585 a.mu.Unlock()
3586 if a.ctx == nil {
3587 a.buildTabControllerWithContext(tab, loadedTabSession{}, buildCtx, generation, cancel)
3588 return
3589 }
3590 go a.buildTabControllerWithContext(tab, loadedTabSession{}, buildCtx, generation, cancel)
3591 }
3592
3593 func (a *App) buildTabController(tab *WorkspaceTab) {
3594 a.buildTabControllerWithLoadedSession(tab, loadedTabSession{})
3595 }
3596
3597 func (a *App) buildTabControllerAdmissionHeld(tab *WorkspaceTab) {
3598 a.buildTabControllerWithLoadedSessionAdmissionHeld(tab, loadedTabSession{})
3599 }
3600
3601 type loadedTabSession struct {
3602 Path string
3603 Session *agent.Session
3604 }
3605
3606 func (s loadedTabSession) matches(path string) bool {
3607 return s.Session != nil && sessionRuntimeKey(s.Path) != "" && sessionRuntimeKey(s.Path) == sessionRuntimeKey(path)
3608 }
3609
3610 func (a *App) buildTabControllerWithLoadedSession(tab *WorkspaceTab, loadedSession loadedTabSession) {
3611 a.buildTabControllerWithContext(tab, loadedSession, a.bootContext(), 0, nil)
3612 }
3613
3614 func (a *App) buildTabControllerWithLoadedSessionAdmissionHeld(tab *WorkspaceTab, loadedSession loadedTabSession) {
3615 a.buildTabControllerWithContextAdmissionHeld(tab, loadedSession, a.bootContext(), 0, nil)
3616 }
3617
3618 func (a *App) desktopNotificationSender() notify.Sender {
3619 if a == nil {
3620 return notify.NewPlatformSender()
3621 }
3622 a.notificationSenderOnce.Do(func() {
3623 if a.notificationSender == nil {
3624 a.notificationSender = notify.NewPlatformSender()
3625 }
3626 })
3627 return a.notificationSender
3628 }
3629
3630 func (a *App) desktopControllerSink(inner event.Sink, cfg config.NotificationsConfig) event.Sink {
3631 if !cfg.Enabled {
3632 return inner
3633 }
3634 sender := a.desktopNotificationSender()
3635 if sender == nil {
3636 return inner
3637 }
3638 return notify.NewSink(inner, sender, cfg)
3639 }
3640
3641 func setTabStartupError(tab *WorkspaceTab, err error) bool {
3642 if tab == nil {
3643 return false
3644 }
3645 tab.StartupErr = userFacingSessionLeaseError("", err).Error()
3646 tab.StartupErrLeaseHeld = errors.Is(err, agent.ErrSessionLeaseHeld)
3647 return tab.StartupErrLeaseHeld
3648 }
3649
3650 func clearTabStartupError(tab *WorkspaceTab) {
3651 if tab == nil {
3652 return
3653 }
3654 tab.StartupErr = ""
3655 tab.StartupErrLeaseHeld = false
3656 }
3657
3658 func (a *App) recordTabStartupFailure(tab *WorkspaceTab, buildGeneration uint64, wailsCtx context.Context, err error) {
3659 leaseHeld := false
3660 a.mu.Lock()
3661 if a.tabBuildSupersededLocked(tab, buildGeneration) {
3662 a.mu.Unlock()
3663 return
3664 }
3665 leaseHeld = setTabStartupError(tab, err)
3666 tab.Ready = false
3667 if leaseHeld {
3668 a.setSessionRuntimePhaseLocked(tab, sessionRuntimeLeaseBlocked, err)
3669 } else {
3670 a.setSessionRuntimePhaseLocked(tab, sessionRuntimeFailed, err)
3671 }
3672 tab.releaseSessionLease()
3673 a.mu.Unlock()
3674 if leaseHeld {
3675 a.scheduleDeferredStartupBuild(tab.ID)
3676 }
3677 a.emitReady(wailsCtx, tab.ID)
3678 }
3679
3680 func (a *App) buildTabControllerWithContext(tab *WorkspaceTab, loadedSession loadedTabSession, buildCtx context.Context, buildGeneration uint64, buildCancel context.CancelFunc) {
3681 a.runtimeAdmissionMu.RLock()
3682 defer a.runtimeAdmissionMu.RUnlock()
3683 a.buildTabControllerWithContextAdmissionHeld(tab, loadedSession, buildCtx, buildGeneration, buildCancel)
3684 }
3685
3686 // buildTabControllerWithContextAdmissionHeld is the build core for callers
3687 // that already hold either side of runtimeAdmissionMu. Keeping acquisition in
3688 // the outer wrapper prevents recursive RLock deadlocks when foreground turn
3689 // admission repairs a stale workspace while an MCP lifecycle writer is queued.
3690 func (a *App) buildTabControllerWithContextAdmissionHeld(tab *WorkspaceTab, loadedSession loadedTabSession, buildCtx context.Context, buildGeneration uint64, buildCancel context.CancelFunc) {
3691 defer a.recoverToPending("buildTabController")
3692 keepBuildContext := false
3693 defer func() {
3694 a.clearTabBuildCancel(tab, buildGeneration, buildCancel, keepBuildContext)
3695 }()
3696 wailsCtx := a.ctx
3697 if a.tabBuildSuperseded(tab, buildGeneration) {
3698 return
3699 }
3700 a.mu.Lock()
3701 if !tab.removed && tab.Ctrl == nil {
3702 tab.Ready = false
3703 clearTabStartupError(tab)
3704 a.setSessionRuntimePhaseLocked(tab, sessionRuntimeStarting, nil)
3705 }
3706 a.mu.Unlock()
3707
3708 a.reconcileTabWithPinnedSessionMeta(tab)
3709
3710 // Snapshot the identity/profile fields under a.mu before the off-lock
3711 // stretch: session rebinding, recovery, and topic assignment write them
3712 // under the lock while this goroutine builds.
3713 a.mu.RLock()
3714 tabWorkspaceRoot := tab.WorkspaceRoot
3715 tabScope := tab.Scope
3716 tabTopicID := tab.TopicID
3717 tabSessionPath := tab.SessionPath
3718 tabModel := tab.model
3719 tabSink := tab.sink
3720 a.mu.RUnlock()
3721
3722 root := tabWorkspaceRoot
3723 if root == "" {
3724 if wd, err := os.Getwd(); err == nil {
3725 root = wd
3726 }
3727 }
3728
3729 // Load config for this tab's workspace root.
3730 _ = config.MigrateLegacyCredentialsForRoot(root)
3731 cfg, err := config.LoadForRoot(root)
3732 if err != nil {
3733 a.recordTabStartupFailure(tab, buildGeneration, wailsCtx, err)
3734 return
3735 }
3736
3737 if a.tabBuildSuperseded(tab, buildGeneration) {
3738 return
3739 }
3740 if tabSink != nil {
3741 tabSink.setContext(wailsCtx)
3742 }
3743
3744 sessionDir := desktopSessionDir(root)
3745 topicID := strings.TrimSpace(tabTopicID)
3746
3747 // Assign Global topics to legacy sessions in the global session dir so
3748 // imported history appears in the project tree regardless of which tab
3749 // triggered the build (the migration now sends everything to global).
3750 migratedGlobalTopics := migrateLegacySessionsIntoGlobalTopics(config.SessionDir())
3751 if len(migratedGlobalTopics) > 0 {
3752 a.emitProjectTreeChangedForSessionDirs(config.SessionDir())
3753 }
3754 if tabScope == "global" && topicID == "" && len(migratedGlobalTopics) > 0 {
3755 topicID = migratedGlobalTopics[0]
3756 topicTitle := topicTitleForTab("global", "", topicID)
3757 topicSource := loadTopicTitleSource("", topicID)
3758 a.mu.Lock()
3759 if a.tabBuildSupersededLocked(tab, buildGeneration) {
3760 a.mu.Unlock()
3761 return
3762 }
3763 if strings.TrimSpace(tab.TopicID) == "" {
3764 tab.TopicID = topicID
3765 tab.TopicTitle = topicTitle
3766 tab.topicTitleSource = topicSource
3767 a.saveTabsLocked()
3768 } else {
3769 topicID = strings.TrimSpace(tab.TopicID)
3770 }
3771 a.mu.Unlock()
3772 }
3773 if topicID != "" {
3774 if _, dir := a.findTopicSessionForTarget(tabScope, tabWorkspaceRoot, topicID); dir != "" {
3775 sessionDir = dir
3776 }
3777 }
3778 startupSessionPath := ""
3779 if pinnedPath, ok := pinnedTabSessionPath(sessionDir, tabSessionPath); ok {
3780 if !agent.IsCleanupPending(pinnedPath) {
3781 startupSessionPath = pinnedPath
3782 }
3783 } else if topicID != "" {
3784 startupSessionPath = findTopicSession(sessionDir, topicID)
3785 }
3786
3787 model := strings.TrimSpace(tabModel)
3788 if sessionModel, ok := agent.LoadSessionModel(startupSessionPath); ok {
3789 config.NormalizeLegacyMimoCustomProvidersForRefs(cfg, sessionModel)
3790 if _, ok := cfg.ResolveModel(sessionModel); ok {
3791 model = sessionModel
3792 }
3793 }
3794 if model == "" {
3795 if def := strings.TrimSpace(cfg.DefaultModel); providerext.PluginRefOwner(def) != "" {
3796 // A plugin-namespaced default_model belongs to an extension
3797 // sidecar: the config catalog can never resolve it, but boot's
3798 // merged resolver can. Pass it through untouched.
3799 model = def
3800 } else {
3801 resolved, _, ok := cfg.ResolveDesktopNewSessionModel()
3802 if !ok {
3803 a.recordTabStartupFailure(tab, buildGeneration, wailsCtx, errNoDesktopChatModel)
3804 return
3805 }
3806 model = resolved
3807 }
3808 }
3809 config.NormalizeLegacyMimoCustomProvidersForRefs(cfg, model)
3810 requestedModel := model
3811 if providerext.PluginRefOwner(model) == "" {
3812 // Plugin refs skip the config fallback: rerouting an unavailable
3813 // extension model onto a config provider would silently change the
3814 // session; boot's unknown-model error is the honest failure.
3815 if resolved, fallback, ok := cfg.ResolveModelWithFallback(model); ok {
3816 if fallback && strings.TrimSpace(tabModel) != "" {
3817 a.noticeForTab(tab.ID, fmt.Sprintf("model %q is no longer available; switched to %s", requestedModel, resolved))
3818 }
3819 model = resolved
3820 }
3821 }
3822
3823 // Acquire a shared plugin host for this workspace root so MCP processes
3824 // are launched once per root, not once per tab. SharedHostKey is an a.mu-
3825 // guarded field (takeTabSharedHostKey reads it under the lock during
3826 // teardown), so publish it under the lock alongside the model. Capture the
3827 // tab-local runtime profile here too: bound methods (SetModeForTab,
3828 // SetGoalForTab, SetEffortForTab, ...) write these under a.mu, so the
3829 // off-lock boot.Build below must read a locked snapshot, not the live tab.
3830 rootKey := tabWorkspaceRoot
3831 if rootKey == "" {
3832 rootKey = "__global__" // stable key for global workspace tabs
3833 }
3834 a.mu.Lock()
3835 if a.tabBuildSupersededLocked(tab, buildGeneration) {
3836 a.mu.Unlock()
3837 return
3838 }
3839 tab.model = model
3840 tab.Label = model
3841 tab.SharedHostKey = rootKey
3842 buildEffort := cloneStringPtr(tab.effort)
3843 buildTokenMode := boot.NormalizeTokenMode(tab.tokenMode)
3844 buildMode := tab.mode
3845 buildToolApprovalMode := tab.toolApprovalMode
3846 buildGoal := tab.goal
3847 buildSink := tab.sink
3848 a.saveTabsLocked()
3849 a.mu.Unlock()
3850 buildRuntime := (tabRuntimeSnapshot{
3851 tokenMode: buildTokenMode,
3852 mode: buildMode,
3853 goal: buildGoal,
3854 toolApprovalMode: buildToolApprovalMode,
3855 }).normalizedRuntime()
3856
3857 sharedHost := a.acquireSharedHost(rootKey)
3858 sink := a.desktopControllerSink(buildSink, cfg.Notifications)
3859
3860 ctrl, err := boot.Build(buildCtx, boot.Options{
3861 Model: model,
3862 RequireKey: false,
3863 AutoPricingCurrency: a.desktopAutoPricingCurrency(),
3864 StatsSource: "desktop",
3865 Sink: sink,
3866 WorkspaceRoot: root,
3867 SessionDir: sessionDir,
3868 EffortOverride: cloneStringPtr(buildEffort),
3869 TokenMode: buildTokenMode,
3870 SharedHost: sharedHost,
3871 CleanupPendingReconciler: reconcileDesktopCleanupPending,
3872 SubagentParentLive: a.subagentParentProbeForBuild(tab),
3873 SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab),
3874 OnSessionRecovered: a.handleTabSessionRecovered(tab),
3875 })
3876 if err != nil {
3877 leaseHeld := false
3878 a.mu.Lock()
3879 if a.tabBuildSupersededLocked(tab, buildGeneration) {
3880 a.mu.Unlock()
3881 a.abandonSupersededBuild(tab, nil, rootKey, "")
3882 return
3883 }
3884 leaseHeld = setTabStartupError(tab, err)
3885 tab.Ready = false
3886 if leaseHeld {
3887 a.setSessionRuntimePhaseLocked(tab, sessionRuntimeLeaseBlocked, err)
3888 } else {
3889 a.setSessionRuntimePhaseLocked(tab, sessionRuntimeFailed, err)
3890 }
3891 hostKey := takeTabSharedHostKey(tab)
3892 tab.releaseSessionLease()
3893 a.mu.Unlock()
3894 if hostKey != "" {
3895 a.releaseSharedHost(hostKey)
3896 }
3897 if leaseHeld {
3898 a.scheduleDeferredStartupBuild(tab.ID)
3899 }
3900 a.emitReady(wailsCtx, tab.ID)
3901 return
3902 }
3903 if a.tabBuildSuperseded(tab, buildGeneration) {
3904 a.abandonSupersededBuild(tab, ctrl, rootKey, "")
3905 return
3906 }
3907
3908 a.bindControllerDisplayRecorder(ctrl)
3909 configureControllerRuntime(ctrl, nil, buildRuntime)
3910
3911 acquiredLeaseKey := ""
3912 restoredRuntime := buildRuntime
3913 if dir := ctrl.SessionDir(); dir != "" {
3914 migratedTopics := migrateLegacySessionsIntoGlobalTopics(dir)
3915 if len(migratedTopics) > 0 {
3916 a.emitProjectTreeChangedForSessionDirs(dir)
3917 }
3918 // Refresh the topic/session locals under the lock: a rebind or the
3919 // recovery callback may have rewritten them since the early snapshot.
3920 a.mu.RLock()
3921 tabTopicID = strings.TrimSpace(tab.TopicID)
3922 tabSessionPath = tab.SessionPath
3923 a.mu.RUnlock()
3924 if tabScope == "global" && tabTopicID == "" && len(migratedTopics) > 0 {
3925 topicID := migratedTopics[0]
3926 topicTitle := topicTitleForTab("global", "", topicID)
3927 topicSource := loadTopicTitleSource("", topicID)
3928 a.mu.Lock()
3929 if !a.tabBuildSupersededLocked(tab, buildGeneration) && strings.TrimSpace(tab.TopicID) == "" {
3930 tab.TopicID = topicID
3931 tab.TopicTitle = topicTitle
3932 tab.topicTitleSource = topicSource
3933 tabTopicID = topicID
3934 a.saveTabsLocked()
3935 } else {
3936 tabTopicID = strings.TrimSpace(tab.TopicID)
3937 }
3938 a.mu.Unlock()
3939 }
3940 var path string
3941 var resumeSession *agent.Session
3942 var resumeLoadErr error
3943 // Prefer the exact session file persisted for this tab. Topic lookup is a
3944 // compatibility fallback for older desktop-tabs.json files that only stored
3945 // topicId and could pick the wrong session when one topic had multiple files.
3946 if loaded, pinnedPath, ok, loadErr := loadPinnedTabSessionWithPreload(dir, tabSessionPath, loadedSession); loadErr != nil {
3947 resumeLoadErr = loadErr
3948 } else if ok {
3949 path = pinnedPath
3950 resumeSession = loaded
3951 }
3952 if resumeLoadErr == nil && path == "" && tabTopicID != "" {
3953 existingPath := findTopicSession(dir, tabTopicID)
3954 if existingPath != "" {
3955 if loaded, err := loadResumableSession(existingPath); err == nil {
3956 path = existingPath
3957 resumeSession = loaded
3958 } else {
3959 resumeLoadErr = err
3960 }
3961 }
3962 }
3963 if resumeLoadErr != nil {
3964 resumeLoadErr = friendlySessionLoadError(resumeLoadErr)
3965 leaseHeld := false
3966 a.mu.Lock()
3967 if a.tabBuildSupersededLocked(tab, buildGeneration) {
3968 a.mu.Unlock()
3969 a.abandonSupersededBuild(tab, ctrl, rootKey, "")
3970 return
3971 }
3972 leaseHeld = setTabStartupError(tab, resumeLoadErr)
3973 tab.Ready = false
3974 if leaseHeld {
3975 a.setSessionRuntimePhaseLocked(tab, sessionRuntimeLeaseBlocked, resumeLoadErr)
3976 } else {
3977 a.setSessionRuntimePhaseLocked(tab, sessionRuntimeFailed, resumeLoadErr)
3978 }
3979 hostKey := takeTabSharedHostKey(tab)
3980 tab.releaseSessionLease()
3981 a.mu.Unlock()
3982 ctrl.Close()
3983 if hostKey != "" {
3984 a.releaseSharedHost(hostKey)
3985 }
3986 if leaseHeld {
3987 a.scheduleDeferredStartupBuild(tab.ID)
3988 }
3989 a.emitReady(wailsCtx, tab.ID)
3990 return
3991 }
3992 if path == "" {
3993 path = agent.NewSessionPath(dir, ctrl.Label())
3994 }
3995 // Write/update scope/session meta.
3996 if path != "" {
3997 if a.claimSessionRuntime(tab, path, buildCtx) {
3998 ctrl.Close()
3999 a.releaseSharedHost(rootKey)
4000 a.emitReady(wailsCtx, tab.ID)
4001 return
4002 }
4003 preLeaseKey := tab.sessionLeaseRuntimeKey()
4004 if err := a.ensureTabSessionLeaseForRebuild(tab, path, ""); err != nil {
4005 leaseHeld := false
4006 a.mu.Lock()
4007 if a.tabBuildSupersededLocked(tab, buildGeneration) {
4008 a.mu.Unlock()
4009 a.abandonSupersededBuild(tab, ctrl, rootKey, "")
4010 return
4011 }
4012 leaseHeld = setTabStartupError(tab, err)
4013 tab.Ready = false
4014 if leaseHeld {
4015 a.setSessionRuntimePhaseLocked(tab, sessionRuntimeLeaseBlocked, err)
4016 } else {
4017 a.setSessionRuntimePhaseLocked(tab, sessionRuntimeFailed, err)
4018 }
4019 hostKey := takeTabSharedHostKey(tab)
4020 // Release only a lease bound to THIS build's session: a failed
4021 // ensure leaves any prior lease untouched, and that lease may
4022 // belong to a runtime a concurrent switch just installed.
4023 tab.releaseSessionLeaseForKey(sessionRuntimeKey(path))
4024 a.mu.Unlock()
4025 ctrl.Close()
4026 if hostKey != "" {
4027 a.releaseSharedHost(hostKey)
4028 }
4029 if leaseHeld {
4030 a.scheduleDeferredStartupBuild(tab.ID)
4031 }
4032 a.emitReady(wailsCtx, tab.ID)
4033 return
4034 }
4035 // Remember which lease THIS build bound: if the build is later
4036 // superseded, only a lease still carrying this key may be
4037 // released (see abandonSupersededBuild). A fast-path reuse means
4038 // the lease existed before this build (bound by a concurrent
4039 // switch or recovery) — it is not ours to release.
4040 if key := sessionRuntimeKey(path); key != preLeaseKey {
4041 acquiredLeaseKey = key
4042 }
4043 // Re-check ownership right after the (potentially slow) lease
4044 // bind: a rebind that superseded this build while ensure was in
4045 // flight has already retargeted the tab, and continuing into
4046 // Resume/persistTabSessionPath would write the stale session
4047 // path back onto the rebound tab.
4048 if a.tabBuildSuperseded(tab, buildGeneration) {
4049 a.abandonSupersededBuild(tab, ctrl, rootKey, acquiredLeaseKey)
4050 return
4051 }
4052 var restoreErr error
4053 restoredRuntime, restoreErr = resumeControllerRuntimeWithSession(ctrl, resumeSession, path, buildRuntime)
4054 if restoreErr != nil {
4055 leaseHeld := false
4056 a.mu.Lock()
4057 if a.tabBuildSupersededLocked(tab, buildGeneration) {
4058 a.mu.Unlock()
4059 a.abandonSupersededBuild(tab, ctrl, rootKey, acquiredLeaseKey)
4060 return
4061 }
4062 leaseHeld = setTabStartupError(tab, restoreErr)
4063 tab.Ready = false
4064 if leaseHeld {
4065 a.setSessionRuntimePhaseLocked(tab, sessionRuntimeLeaseBlocked, restoreErr)
4066 } else {
4067 a.setSessionRuntimePhaseLocked(tab, sessionRuntimeFailed, restoreErr)
4068 }
4069 hostKey := takeTabSharedHostKey(tab)
4070 tab.releaseSessionLeaseForKey(sessionRuntimeKey(path))
4071 a.mu.Unlock()
4072 ctrl.Close()
4073 if hostKey != "" {
4074 a.releaseSharedHost(hostKey)
4075 }
4076 if leaseHeld {
4077 a.scheduleDeferredStartupBuild(tab.ID)
4078 }
4079 a.emitReady(wailsCtx, tab.ID)
4080 return
4081 }
4082 a.persistTabSessionPath(tab, path)
4083 a.mu.RLock()
4084 indexScope := tab.Scope
4085 indexRoot := tab.WorkspaceRoot
4086 indexTopicID := strings.TrimSpace(tab.TopicID)
4087 indexTopicTitle := tab.TopicTitle
4088 a.mu.RUnlock()
4089 if indexTopicID != "" {
4090 if err := ensureTopicIndexed(indexScope, indexRoot, indexTopicID, indexTopicTitle, loadTopicTitleSource(topicTitleRoot(indexScope, indexRoot), indexTopicID)); err == nil {
4091 a.emitProjectTreeChangedForSessionDirs(ctrl.SessionDir())
4092 }
4093 }
4094 // Key telemetry to the session this build binds: restore its
4095 // persisted sidecar, or start from zero when none exists (fresh
4096 // session, CLI-created session, pre-telemetry session). Keeping
4097 // the previous session's totals here made 会话费用 accumulate
4098 // across sessions and persisted the stale totals into the new
4099 // session's sidecar on the next event (#5850).
4100 snapshot := loadTelemetry(path + ".telemetry.json")
4101 tab.telemMu.Lock()
4102 tab.readTelemetry = snapshot.ReadFiles
4103 tab.usageTelemetry = snapshot.Usage
4104 tab.telemetrySessionKey = sessionRuntimeKey(path)
4105 tab.telemMu.Unlock()
4106 }
4107 }
4108
4109 a.mu.Lock()
4110 if a.tabBuildSupersededLocked(tab, buildGeneration) {
4111 a.mu.Unlock()
4112 a.abandonSupersededBuild(tab, ctrl, rootKey, acquiredLeaseKey)
4113 return
4114 }
4115 tab.Ctrl = ctrl
4116 tab.Label = ctrl.Label()
4117 applyNormalizedRuntimeToTabLocked(tab, restoredRuntime)
4118 tab.Ready = true
4119 clearTabStartupError(tab)
4120 a.bindSessionRuntimeKeyLocked(tab, tab.currentSessionPath())
4121 a.advanceSessionRuntimeEpochLocked(tab)
4122 keepBuildContext = true
4123 a.mu.Unlock()
4124 a.emitReady(wailsCtx, tab.ID)
4125 }
4126
4127 type sessionBinding struct {
4128 path string
4129 scope string
4130 workspaceRoot string
4131 topicID string
4132 topicTitle string
4133 hasMeta bool
4134 meta agent.BranchMeta
4135 }
4136
4137 func (a *App) reconcileTabWithPinnedSessionMeta(tab *WorkspaceTab) (string, bool) {
4138 if tab == nil {
4139 return "", false
4140 }
4141 a.mu.RLock()
4142 current := a.tabs[tab.ID]
4143 path := strings.TrimSpace(tab.SessionPath)
4144 ctrl := tab.Ctrl
4145 scope := tab.Scope
4146 workspaceRoot := tab.WorkspaceRoot
4147 a.mu.RUnlock()
4148 if current != tab {
4149 return "", false
4150 }
4151 if path != "" {
4152 if resolved, ok := a.reconcileTabWithSessionPath(tab, path); ok {
4153 return resolved, true
4154 }
4155 }
4156 if ctrl == nil {
4157 return "", false
4158 }
4159 path = strings.TrimSpace(ctrl.SessionPath())
4160 if path == "" {
4161 return "", false
4162 }
4163 binding, ok := a.resolveSessionBinding(path)
4164 if !ok {
4165 return "", false
4166 }
4167 if scope == "project" && binding.scope != "project" && normalizeProjectRoot(workspaceRoot) != "" {
4168 if root, ok := safeControllerWorkspaceRoot(ctrl); ok && sameProjectRoot(root, workspaceRoot) {
4169 return "", false
4170 }
4171 }
4172 a.applySessionBindingToTab(tab, binding)
4173 return binding.path, true
4174 }
4175
4176 func (a *App) reconcileTabWithSessionPath(tab *WorkspaceTab, sessionPath string) (string, bool) {
4177 if tab == nil || strings.TrimSpace(sessionPath) == "" {
4178 return "", false
4179 }
4180 binding, ok := a.resolveSessionBinding(sessionPath)
4181 if !ok {
4182 return "", false
4183 }
4184 a.applySessionBindingToTab(tab, binding)
4185 return binding.path, true
4186 }
4187
4188 func (a *App) applySessionBindingToTab(tab *WorkspaceTab, binding sessionBinding) {
4189 if tab == nil || binding.path == "" {
4190 return
4191 }
4192 var terminalSessions []*terminalSession
4193 reopenTerminalGate := false
4194 scope := binding.scope
4195 workspaceRoot := binding.workspaceRoot
4196 if scope == "" {
4197 scope = "global"
4198 }
4199 if scope == "project" {
4200 workspaceRoot = normalizeProjectRoot(workspaceRoot)
4201 if workspaceRoot == "" {
4202 return
4203 }
4204 a.registerProjectRoot(workspaceRoot)
4205 } else {
4206 scope = "global"
4207 workspaceRoot = globalTabWorkspaceRoot()
4208 }
4209 topicID := strings.TrimSpace(binding.topicID)
4210 topicTitle := strings.TrimSpace(binding.topicTitle)
4211 if topicTitle == "" && topicID != "" {
4212 topicTitle = topicTitleForTab(scope, workspaceRoot, topicID)
4213 }
4214 topicSource := ""
4215 if topicID != "" {
4216 topicSource = loadTopicTitleSource(topicTitleRoot(scope, workspaceRoot), topicID)
4217 }
4218
4219 a.mu.Lock()
4220 current := a.tabs[tab.ID]
4221 if current != nil && current != tab {
4222 a.mu.Unlock()
4223 return
4224 }
4225 oldScope := tab.Scope
4226 oldWorkspaceRoot := tab.WorkspaceRoot
4227 changed := tab.Scope != scope ||
4228 tab.WorkspaceRoot != workspaceRoot ||
4229 canonicalTabSessionPath(tab.SessionPath) != canonicalTabSessionPath(binding.path)
4230 // Spelling-only root updates still persist above, but an equivalent root is
4231 // the same workspace — do not warn the user about a switch.
4232 workspaceChanged := tab.Scope != scope || !sameProjectRoot(tab.WorkspaceRoot, workspaceRoot)
4233 if workspaceChanged && current == tab && a.terminals != nil {
4234 // A session binding can move a visible tab to another project. Invalidate
4235 // the old terminal scope before publishing the new root so an in-flight
4236 // shell start cannot register against the old workspace after this
4237 // transition. Reopen only after the new binding is visible.
4238 terminalSessions = a.terminals.detachForTab(tab.ID)
4239 reopenTerminalGate = !tab.ReadOnly && !tab.removed
4240 }
4241 tab.Scope = scope
4242 tab.WorkspaceRoot = workspaceRoot
4243 tab.SessionPath = canonicalTabSessionPath(binding.path)
4244 if topicID != "" {
4245 changed = changed || tab.TopicID != topicID
4246 tab.TopicID = topicID
4247 tab.topicTitleSource = topicSource
4248 }
4249 if topicTitle != "" {
4250 changed = changed || tab.TopicTitle != topicTitle
4251 tab.TopicTitle = topicTitle
4252 }
4253 if changed && current == tab {
4254 a.saveTabsLocked()
4255 }
4256 sink := tab.sink
4257 a.mu.Unlock()
4258 if reopenTerminalGate {
4259 a.terminals.reopenForTab(tab.ID)
4260 }
4261 if len(terminalSessions) > 0 {
4262 a.terminals.closeSessions(terminalSessions)
4263 }
4264 if workspaceChanged && sink != nil {
4265 sink.Emit(event.Event{
4266 Kind: event.Notice,
4267 Level: event.LevelWarn,
4268 Text: sessionBindingWorkspaceNotice(oldScope, oldWorkspaceRoot, scope, workspaceRoot),
4269 })
4270 }
4271 }
4272
4273 func sessionBindingWorkspaceNotice(oldScope, oldWorkspaceRoot, scope, workspaceRoot string) string {
4274 return "Session belongs to " + describeSessionBindingWorkspace(scope, workspaceRoot) +
4275 "; switched tab from " + describeSessionBindingWorkspace(oldScope, oldWorkspaceRoot) +
4276 " to match the saved session."
4277 }
4278
4279 func describeSessionBindingWorkspace(scope, workspaceRoot string) string {
4280 if strings.TrimSpace(scope) == "project" && strings.TrimSpace(workspaceRoot) != "" {
4281 // %q escapes Windows separators, which turns a user-facing path into
4282 // C:\\Users\\... in the notice. Preserve native separators while escaping
4283 // only the delimiters that can appear in a Unix path.
4284 root := strings.ReplaceAll(strings.TrimSpace(workspaceRoot), `"`, `\"`)
4285 return `project workspace "` + root + `"`
4286 }
4287 return "global workspace"
4288 }
4289
4290 func (a *App) resolveSessionBinding(sessionPath string) (sessionBinding, bool) {
4291 sessionPath = strings.TrimSpace(sessionPath)
4292 if sessionPath == "" {
4293 return sessionBinding{}, false
4294 }
4295 for _, dir := range a.knownSessionDirs() {
4296 if binding, ok := sessionBindingInDir(dir, sessionPath); ok {
4297 return binding, true
4298 }
4299 }
4300 if !filepath.IsAbs(sessionPath) {
4301 return sessionBinding{}, false
4302 }
4303 path, err := filepath.Abs(sessionPath)
4304 if err != nil {
4305 return sessionBinding{}, false
4306 }
4307 meta, ok, err := agent.LoadBranchMeta(path)
4308 if err != nil || !ok {
4309 return sessionBinding{}, false
4310 }
4311 for _, dir := range sessionBindingCandidateDirs(meta) {
4312 if binding, ok := sessionBindingInDir(dir, path); ok {
4313 return binding, true
4314 }
4315 }
4316 return sessionBindingFromMeta(path, meta)
4317 }
4318
4319 func sessionBindingCandidateDirs(meta agent.BranchMeta) []string {
4320 if meta.DefaultScope() == "project" {
4321 if root := normalizeProjectRoot(meta.WorkspaceRoot); root != "" {
4322 return []string{desktopSessionDir(root)}
4323 }
4324 return nil
4325 }
4326 return []string{desktopSessionDir(globalWorkspaceRoot()), config.SessionDir()}
4327 }
4328
4329 func sessionBindingInDir(dir, sessionPath string) (sessionBinding, bool) {
4330 path, ok := pinnedTabSessionPath(dir, sessionPath)
4331 if !ok {
4332 return sessionBinding{}, false
4333 }
4334 meta, hasMeta, err := agent.LoadBranchMeta(path)
4335 if err != nil {
4336 return sessionBinding{}, false
4337 }
4338 scope, workspaceRoot, _, ownerOK := legacyMigrationTargetForDir(dir)
4339 if !ownerOK {
4340 if !hasMeta {
4341 return sessionBinding{}, false
4342 }
4343 return sessionBindingFromMeta(path, meta)
4344 }
4345 if scope == "global" {
4346 if !hasMeta {
4347 return sessionBinding{}, false
4348 }
4349 return sessionBindingFromMeta(path, meta)
4350 }
4351 binding := sessionBinding{
4352 path: path,
4353 scope: scope,
4354 workspaceRoot: workspaceRoot,
4355 hasMeta: hasMeta,
4356 meta: meta,
4357 }
4358 if hasMeta {
4359 binding.topicID = strings.TrimSpace(meta.TopicID)
4360 binding.topicTitle = strings.TrimSpace(meta.TopicTitle)
4361 }
4362 if binding.scope == "project" {
4363 binding.workspaceRoot = normalizeProjectRoot(binding.workspaceRoot)
4364 }
4365 return binding, true
4366 }
4367
4368 func sessionBindingFromMeta(path string, meta agent.BranchMeta) (sessionBinding, bool) {
4369 scope := meta.DefaultScope()
4370 workspaceRoot := ""
4371 if scope == "project" {
4372 workspaceRoot = normalizeProjectRoot(meta.WorkspaceRoot)
4373 if workspaceRoot == "" {
4374 return sessionBinding{}, false
4375 }
4376 } else {
4377 scope = "global"
4378 workspaceRoot = globalTabWorkspaceRoot()
4379 }
4380 return sessionBinding{
4381 path: path,
4382 scope: scope,
4383 workspaceRoot: workspaceRoot,
4384 topicID: strings.TrimSpace(meta.TopicID),
4385 topicTitle: strings.TrimSpace(meta.TopicTitle),
4386 hasMeta: true,
4387 meta: meta,
4388 }, true
4389 }
4390
4391 // --- active tab helpers -----------------------------------------------------
4392
4393 // activeTab returns the currently active tab (nil when there are no tabs).
4394 // Self-locking; safe to call from any goroutine without external lock.
4395 func (a *App) activeTab() *WorkspaceTab {
4396 a.mu.RLock()
4397 defer a.mu.RUnlock()
4398 if a.activeTabID == "" {
4399 return nil
4400 }
4401 return a.tabs[a.activeTabID]
4402 }
4403
4404 // activeTabLocked is like activeTab but assumes the caller already holds a.mu
4405 // (either RLock or Lock). Use this inside critical sections that already own
4406 // the lock to avoid double-locking a write-lock holder.
4407 func (a *App) activeTabLocked() *WorkspaceTab {
4408 if a.activeTabID == "" {
4409 return nil
4410 }
4411 return a.tabs[a.activeTabID]
4412 }
4413
4414 // activeCtrl returns the controller of the active tab, or nil.
4415 // Self-locking; safe to call from any goroutine without external lock.
4416 func (a *App) activeCtrl() control.SessionAPI {
4417 a.mu.RLock()
4418 defer a.mu.RUnlock()
4419 return a.activeCtrlLocked()
4420 }
4421
4422 // activeCtrlLocked is like activeCtrl but assumes the caller already holds a.mu.
4423 func (a *App) activeCtrlLocked() control.SessionAPI {
4424 t := a.activeTabLocked()
4425 if t == nil {
4426 return nil
4427 }
4428 return t.Ctrl
4429 }
4430
4431 func (a *App) tabByID(tabID string) *WorkspaceTab {
4432 a.mu.RLock()
4433 defer a.mu.RUnlock()
4434 return a.tabByIDLocked(tabID)
4435 }
4436
4437 func (a *App) tabByIDLocked(tabID string) *WorkspaceTab {
4438 if strings.TrimSpace(tabID) == "" {
4439 return a.activeTabLocked()
4440 }
4441 return a.tabs[tabID]
4442 }
4443
4444 func (a *App) ctrlByTabID(tabID string) control.SessionAPI {
4445 a.mu.RLock()
4446 defer a.mu.RUnlock()
4447 tab := a.tabByIDLocked(tabID)
4448 if tab == nil {
4449 return nil
4450 }
4451 return tab.Ctrl
4452 }
4453
4454 // --- autosave per tab -------------------------------------------------------
4455
4456 const maxTabSnapshotFailureRetries = 2
4457
4458 // autosaveWarnInterval rate-limits the user-facing autosave-failure notice
4459 // per tab; slog keeps recording every failure regardless.
4460 const autosaveWarnInterval = 5 * time.Minute
4461
4462 func tabSnapshotRetryDelay(failures int) time.Duration {
4463 switch {
4464 case failures <= 1:
4465 return 100 * time.Millisecond
4466 case failures == 2:
4467 return 250 * time.Millisecond
4468 default:
4469 return 500 * time.Millisecond
4470 }
4471 }
4472
4473 func (a *App) scheduleTabSnapshot(tabID string) {
4474 a.mu.RLock()
4475 tab := a.tabByEventSinkIDLocked(tabID)
4476 a.mu.RUnlock()
4477 if tab == nil {
4478 return
4479 }
4480 tab.saveMu.Lock()
4481 defer tab.saveMu.Unlock()
4482 if tab.closing {
4483 // Tab is being torn down: don't start new snapshot work that could
4484 // race DeleteSession and resurrect a trashed session file (#4384).
4485 return
4486 }
4487 if tab.saving {
4488 tab.saveAgain = true
4489 return
4490 }
4491 tab.saving = true
4492 tab.saveFailures = 0
4493 go a.tabSnapshotLoop(tab)
4494 }
4495
4496 // quiesceTabAutosave marks the tab as closing and blocks until any in-flight
4497 // tabSnapshotLoop has finished its current (and final) write. After it returns,
4498 // no background goroutine can call Snapshot on this tab's controller again, so
4499 // a subsequent DeleteSession cannot race a late write. Safe to call after the
4500 // controller's session path has been cleared: the loop's Snapshot becomes a
4501 // no-op and it exits on its next iteration.
4502 func (a *App) quiesceTabAutosave(tab *WorkspaceTab) {
4503 if tab == nil {
4504 return
4505 }
4506 tab.saveMu.Lock()
4507 if tab.saveCond == nil {
4508 // saveCond is lazily initialized on first snapshot; if it was never
4509 // set there is no loop to wait for.
4510 tab.closing = true
4511 tab.saveMu.Unlock()
4512 return
4513 }
4514 tab.closing = true
4515 for tab.saving {
4516 tab.saveCond.Wait()
4517 }
4518 tab.saveMu.Unlock()
4519 }
4520
4521 func (a *App) tabSnapshotLoop(tab *WorkspaceTab) {
4522 defer a.recoverToPending("tabSnapshotLoop")
4523 for {
4524 var snapshotErr error
4525 a.mu.RLock()
4526 ctrl := tab.Ctrl
4527 a.mu.RUnlock()
4528 if ctrl != nil {
4529 if err := a.snapshotTab(tab); err == nil {
4530 if !a.maybeAutoTitleTopic(tab) {
4531 a.emitProjectTreeChangedForSessionDirs(ctrl.SessionDir())
4532 }
4533 } else {
4534 snapshotErr = err
4535 }
4536 }
4537 tab.saveMu.Lock()
4538 if tab.saveCond == nil {
4539 tab.saveCond = sync.NewCond(&tab.saveMu)
4540 }
4541 if snapshotErr == nil {
4542 tab.saveFailures = 0
4543 } else {
4544 tab.saveFailures++
4545 }
4546 if tab.closing {
4547 // Tab is being torn down: stop without picking up saveAgain work.
4548 tab.saving = false
4549 tab.saveCond.Broadcast()
4550 tab.saveMu.Unlock()
4551 if snapshotErr != nil {
4552 slog.Warn("desktop: session autosave failed during teardown", "tab", tab.ID, "err", snapshotErr)
4553 }
4554 return
4555 }
4556 if tab.saveAgain {
4557 tab.saveAgain = false
4558 tab.saveMu.Unlock()
4559 if snapshotErr != nil {
4560 slog.Warn("desktop: session autosave failed; newer snapshot queued", "tab", tab.ID, "err", snapshotErr)
4561 }
4562 continue
4563 }
4564 if snapshotErr != nil && tab.saveFailures <= maxTabSnapshotFailureRetries {
4565 delay := tabSnapshotRetryDelay(tab.saveFailures)
4566 attempt := tab.saveFailures
4567 tab.saveMu.Unlock()
4568 // Retries are routine (transient AV/indexer holds); tell the user
4569 // only when the whole burst gives up, not once per attempt.
4570 slog.Warn("desktop: session autosave failed; retrying", "tab", tab.ID, "attempt", attempt, "err", snapshotErr)
4571 time.Sleep(delay)
4572 continue
4573 }
4574 exhausted := snapshotErr
4575 tab.saving = false
4576 tab.saveCond.Broadcast()
4577 tab.saveMu.Unlock()
4578 if exhausted != nil {
4579 a.reportTabSnapshotError(tab, "autosave", exhausted)
4580 }
4581 return
4582 }
4583 }
4584
4585 func (a *App) maybeAutoTitleTopic(tab *WorkspaceTab) bool {
4586 if tab == nil {
4587 return false
4588 }
4589 // Runs on the autosave goroutine; TopicID/Scope/WorkspaceRoot/Ctrl are
4590 // written under a.mu by session switches and recovery.
4591 a.mu.RLock()
4592 topicID := strings.TrimSpace(tab.TopicID)
4593 titleRoot := tab.WorkspaceRoot
4594 if tab.Scope == "global" {
4595 titleRoot = ""
4596 }
4597 ctrl := tab.Ctrl
4598 a.mu.RUnlock()
4599 if topicID == "" || ctrl == nil {
4600 return false
4601 }
4602 if source := loadTopicTitleSource(titleRoot, topicID); source != topicTitleSourceAuto {
4603 return false
4604 }
4605 sessionPath := ctrl.SessionPath()
4606 if sessionPath == "" {
4607 return false
4608 }
4609 if sessionHasManualDisplayTitle(sessionPath) {
4610 return false
4611 }
4612 nextTitle, updated := autoTitleTopicFromSession(titleRoot, topicID, sessionPath)
4613 if !updated {
4614 return false
4615 }
4616 a.updateOpenTopicTitle(topicID, nextTitle, topicTitleSourceAuto)
4617 changedDirs := a.updateTopicSessionTitles(topicID, nextTitle)
4618 if len(changedDirs) > 0 {
4619 a.emitProjectTreeChangedForSessionDirs(changedDirs...)
4620 } else {
4621 a.emitProjectTreeMetadataChanged()
4622 }
4623 return true
4624 }
4625
4626 func autoTitleTopicFromSession(workspaceRoot, topicID, sessionPath string) (string, bool) {
4627 if source := loadTopicTitleSource(workspaceRoot, topicID); source != topicTitleSourceAuto {
4628 return "", false
4629 }
4630 if sessionHasManualDisplayTitle(sessionPath) {
4631 return "", false
4632 }
4633 proposal := autoTopicTitleProposalFromSession(sessionPath)
4634 if proposal.Title == "" {
4635 return "", false
4636 }
4637 if !shouldApplyAutoTopicTitle(workspaceRoot, topicID, proposal) {
4638 return "", false
4639 }
4640 nextTitle := proposal.Title
4641 if nextTitle == strings.TrimSpace(loadTopicTitle(workspaceRoot, topicID)) {
4642 _ = recordTopicAutoTitleMeta(workspaceRoot, topicID, proposal)
4643 return "", false
4644 }
4645 if err := setTopicTitleWithSource(workspaceRoot, topicID, nextTitle, topicTitleSourceAuto); err != nil {
4646 return "", false
4647 }
4648 _ = recordTopicAutoTitleMeta(workspaceRoot, topicID, proposal)
4649 return nextTitle, true
4650 }
4651
4652 type autoTopicTitleProposal struct {
4653 Title string
4654 Stage int
4655 UserTurns int
4656 BasisHash string
4657 }
4658
4659 func autoTopicTitleProposalFromSession(path string) autoTopicTitleProposal {
4660 users := topicTitleUserTurnsFromSession(path)
4661 if len(users) == 0 {
4662 return autoTopicTitleProposal{}
4663 }
4664 stage := 1
4665 if len(users) >= 3 {
4666 stage = 3
4667 }
4668 basis := users
4669 if len(basis) > stage {
4670 basis = basis[:stage]
4671 }
4672 title := topicTitleFromUserTurns(basis)
4673 if title == "" {
4674 return autoTopicTitleProposal{}
4675 }
4676 sum := sha256.Sum256([]byte(fmt.Sprintf("%d\x00%s", stage, strings.Join(basis, "\x00"))))
4677 return autoTopicTitleProposal{
4678 Title: title,
4679 Stage: stage,
4680 UserTurns: len(users),
4681 BasisHash: hex.EncodeToString(sum[:8]),
4682 }
4683 }
4684
4685 func shouldApplyAutoTopicTitle(workspaceRoot, topicID string, proposal autoTopicTitleProposal) bool {
4686 if proposal.Stage <= 0 || proposal.BasisHash == "" {
4687 return false
4688 }
4689 meta := loadTopicAutoTitleMeta(workspaceRoot)[topicID]
4690 if meta.Stage > proposal.Stage {
4691 return false
4692 }
4693 if meta.Stage == proposal.Stage && meta.BasisHash == proposal.BasisHash {
4694 return false
4695 }
4696 return true
4697 }
4698
4699 func sessionHasManualDisplayTitle(sessionPath string) bool {
4700 sessionPath = strings.TrimSpace(sessionPath)
4701 if sessionPath == "" {
4702 return false
4703 }
4704 if meta, ok, err := agent.LoadBranchMeta(sessionPath); err == nil && ok {
4705 if strings.TrimSpace(meta.CustomTitle) != "" {
4706 return true
4707 }
4708 }
4709 dir := filepath.Dir(sessionPath)
4710 if dir == "." || dir == string(filepath.Separator) {
4711 return false
4712 }
4713 return strings.TrimSpace(loadSessionTitles(dir)[filepath.Base(sessionPath)]) != ""
4714 }
4715
4716 func topicTitleFallbackForOpen(workspaceRoot, topicID, sessionPath string) (string, string, bool) {
4717 topicID = strings.TrimSpace(topicID)
4718 sessionPath = strings.TrimSpace(sessionPath)
4719 if topicID == "" || sessionPath == "" {
4720 return "", "", false
4721 }
4722 storedTitle := strings.TrimSpace(loadTopicTitle(workspaceRoot, topicID))
4723 storedSource := strings.TrimSpace(loadTopicTitleSource(workspaceRoot, topicID))
4724 if storedTitle != "" {
4725 if storedSource == topicTitleSourceManual || !isDefaultTopicTitle(storedTitle) {
4726 return "", "", false
4727 }
4728 }
4729
4730 if storedTitle == "" {
4731 dir := filepath.Dir(sessionPath)
4732 if meta, ok, err := agent.LoadBranchMeta(sessionPath); err == nil && ok {
4733 if title := storedSessionTopicTitle(dir, sessionPath, meta); title != "" {
4734 return title, topicTitleSourceManual, true
4735 }
4736 } else if title := topicTitleFromText(loadSessionTitles(dir)[filepath.Base(sessionPath)]); title != "" {
4737 return title, topicTitleSourceManual, true
4738 }
4739 }
4740
4741 if storedSource == topicTitleSourceManual {
4742 return "", "", false
4743 }
4744 if storedSource == "" || storedSource == topicTitleSourceAuto {
4745 if title := topicTitleFromSession(sessionPath); title != "" {
4746 return title, topicTitleSourceAuto, true
4747 }
4748 }
4749 return "", "", false
4750 }
4751
4752 func topicTitleFromSession(path string) string {
4753 users := topicTitleUserTurnsFromSession(path)
4754 if len(users) == 0 {
4755 return ""
4756 }
4757 return topicTitleFromText(users[0])
4758 }
4759
4760 func topicTitleUserTurnsFromSession(path string) []string {
4761 // Event-log aware: decoding the .jsonl checkpoint directly would stop
4762 // seeing user turns after the first save, silently disabling the ≥3-turn
4763 // title upgrade.
4764 msgs, err := agent.LoadSessionUserMessages(path)
4765 if err != nil {
4766 return nil
4767 }
4768 var users []string
4769 for _, msg := range msgs {
4770 // Host-injected synthetic turns (readiness nudges, recovery retries) and
4771 // mid-turn steers are persisted as role "user" but are not user-authored:
4772 // counting them inflated userTurns past the stage-3 threshold and let
4773 // "Host final-answer readiness check failed…" become a topic title.
4774 if !agent.IsUserAuthoredTurn(msg.Text) {
4775 continue
4776 }
4777 // UserPreviewText is the canonical user-authored view: it unwraps
4778 // memory-compiler execution contracts and strips transient blocks
4779 // (and runs HandoffTask), so internal wrappers can never become a
4780 // title basis (#5666).
4781 content := control.StripComposePrefixes(agent.UserPreviewText(msg.Text))
4782 content = control.StripReferencedContextPrefix(content)
4783 if strings.TrimSpace(content) != "" {
4784 users = append(users, content)
4785 }
4786 }
4787 return users
4788 }
4789
4790 func topicTitleFromUserTurns(users []string) string {
4791 type candidate struct {
4792 title string
4793 score int
4794 }
4795 best := candidate{score: -1}
4796 for i, text := range users {
4797 title := topicTitleFromText(text)
4798 if title == "" || lowSignalTopicTitle(title) {
4799 continue
4800 }
4801 runes := len([]rune(title))
4802 score := runes
4803 if score > 24 {
4804 score = 24
4805 }
4806 if i == 0 {
4807 score += 3
4808 }
4809 if runes < 5 {
4810 score -= 6
4811 }
4812 if score > best.score {
4813 best = candidate{title: title, score: score}
4814 }
4815 }
4816 if best.title != "" {
4817 return best.title
4818 }
4819 if len(users) > 0 {
4820 return topicTitleFromText(users[0])
4821 }
4822 return ""
4823 }
4824
4825 func lowSignalTopicTitle(title string) bool {
4826 normalized := strings.ToLower(strings.TrimSpace(title))
4827 normalized = strings.Trim(normalized, " \t\r\n,。!?;:、,.!?;:\"'`“”‘’()()[]【】")
4828 switch normalized {
4829 case "", "好", "好的", "好啊", "可以", "嗯", "对", "是的", "继续", "继续吧", "采纳建议", "采用建议", "收到", "明白", "ok", "okay", "yes", "yep", "go on", "continue", "thanks", "thank you":
4830 return true
4831 default:
4832 return false
4833 }
4834 }
4835
4836 func topicTitleFromText(text string) string {
4837 text = strings.TrimSpace(text)
4838 if text == "" {
4839 return ""
4840 }
4841 text = strings.Join(strings.Fields(text), " ")
4842 text = strings.Trim(text, " \t\r\n,。!?;:、,.!?;:\"'`“”‘’()()[]【】")
4843 if text == "" {
4844 return ""
4845 }
4846 const maxRunes = 18
4847 runes := []rune(text)
4848 if len(runes) > maxRunes {
4849 text = strings.TrimRightFunc(string(runes[:maxRunes]), unicode.IsPunct) + "…"
4850 }
4851 if isDefaultTopicTitle(text) {
4852 return ""
4853 }
4854 return text
4855 }
4856
4857 // --- persistence: desktop-projects.json -------------------------------------
4858
4859 const desktopProjectsFile = "desktop-projects.json"
4860 const tabsFileName = "desktop-tabs.json"
4861 const desktopGlobalOrderToken = "__global__"
4862 const legacyProjectSidebarRecoveryMarker = "desktop-projects-legacy-recovered"
4863
4864 var desktopProjectsFileMu sync.Mutex
4865
4866 type desktopProject struct {
4867 Root string `json:"root"`
4868 Title string `json:"title,omitempty"`
4869 Color string `json:"color,omitempty"`
4870 Topics []string `json:"topics"` // ordered topic IDs
4871 PinnedTopics []string `json:"pinnedTopics,omitempty"`
4872 }
4873
4874 type desktopProjectFile struct {
4875 GlobalTitle string `json:"globalTitle,omitempty"`
4876 GlobalColor string `json:"globalColor,omitempty"`
4877 GlobalTopics []string `json:"globalTopics,omitempty"`
4878 GlobalPinnedTopics []string `json:"globalPinnedTopics,omitempty"`
4879 DeletedTopics []string `json:"deletedTopics,omitempty"`
4880 PinnedProjects []string `json:"pinnedProjects,omitempty"`
4881 SidebarOrder []string `json:"sidebarOrder,omitempty"`
4882 Projects []desktopProject `json:"projects"`
4883 }
4884
4885 type desktopTabEntry struct {
4886 ID string `json:"id"`
4887 Scope string `json:"scope"`
4888 WorkspaceRoot string `json:"workspaceRoot"`
4889 TopicID string `json:"topicId"`
4890 SessionPath string `json:"sessionPath,omitempty"`
4891 ReadOnly bool `json:"readOnly,omitempty"`
4892 Model string `json:"model,omitempty"`
4893 Effort *string `json:"effort,omitempty"`
4894 TokenMode string `json:"tokenMode,omitempty"`
4895 Mode string `json:"mode,omitempty"`
4896 Goal string `json:"goal,omitempty"`
4897 ToolApprovalMode string `json:"toolApprovalMode,omitempty"`
4898 }
4899
4900 type desktopTabsFile struct {
4901 Tabs []desktopTabEntry `json:"tabs"`
4902 ActiveTab string `json:"activeTab"`
4903 }
4904
4905 func singleSurfaceLayoutStyle(style string) bool {
4906 switch strings.ToLower(strings.TrimSpace(style)) {
4907 case "workbench", "creation":
4908 return true
4909 default:
4910 return false
4911 }
4912 }
4913
4914 func singleSurfaceTabsFile(f desktopTabsFile) desktopTabsFile {
4915 if len(f.Tabs) <= 1 {
4916 return f
4917 }
4918 chosen := f.Tabs[0]
4919 if active := strings.TrimSpace(f.ActiveTab); active != "" {
4920 for _, entry := range f.Tabs {
4921 if entry.ID == active {
4922 chosen = entry
4923 break
4924 }
4925 }
4926 }
4927 return desktopTabsFile{Tabs: []desktopTabEntry{chosen}, ActiveTab: chosen.ID}
4928 }
4929
4930 func desktopConfigDir() string {
4931 return config.ReasonixHomeDir()
4932 }
4933
4934 func (a *App) saveTabsLocked() {
4935 dir, entries, activeID, version := a.saveTabsCollectLocked()
4936 a.saveTabsWrite(dir, entries, activeID, version)
4937 }
4938
4939 // saveTabsCollectLocked gathers the tab-snapshot data under the caller's lock
4940 // (it calls orderedTabIDsLocked which requires a.mu). Returns the config dir,
4941 // the serializable entries, the active tab ID, and a monotonic snapshot version.
4942 // The write can happen outside the lock to avoid blocking the UI with disk I/O.
4943 func (a *App) saveTabsCollectLocked() (string, []desktopTabEntry, string, uint64) {
4944 dir := desktopConfigDir()
4945 var entries []desktopTabEntry
4946 for _, id := range a.orderedTabIDsLocked() {
4947 if tab := a.tabs[id]; tab != nil {
4948 entries = append(entries, desktopTabEntry{
4949 ID: tab.ID,
4950 Scope: tab.Scope,
4951 WorkspaceRoot: tab.WorkspaceRoot,
4952 TopicID: tab.TopicID,
4953 SessionPath: tab.currentSessionPath(),
4954 ReadOnly: tab.ReadOnly,
4955 Model: tab.model,
4956 Effort: cloneStringPtr(tab.effort),
4957 TokenMode: persistedTabTokenMode(currentTabTokenMode(tab)),
4958 Mode: persistedTabMode(currentTabMode(tab)),
4959 Goal: persistedTabGoal(tab),
4960 ToolApprovalMode: persistedToolApprovalMode(currentTabToolApprovalMode(tab)),
4961 })
4962 }
4963 }
4964 a.tabsSaveVersion++
4965 return dir, entries, a.activeTabID, a.tabsSaveVersion
4966 }
4967
4968 // saveTabsWrite writes the tab-snapshot to disk. It does not require a.mu, but
4969 // writes must be serialized because every save uses the same destination and
4970 // fixed .tmp path.
4971 func (a *App) saveTabsWrite(dir string, entries []desktopTabEntry, activeID string, version uint64) {
4972 a.tabsSaveMu.Lock()
4973 defer a.tabsSaveMu.Unlock()
4974 if version < a.tabsLastWrittenVersion {
4975 return
4976 }
4977
4978 if err := os.MkdirAll(dir, 0o755); err != nil {
4979 return
4980 }
4981 f := desktopTabsFile{Tabs: entries, ActiveTab: activeID}
4982 b, err := json.MarshalIndent(f, "", " ")
4983 if err != nil {
4984 return
4985 }
4986 path := filepath.Join(dir, tabsFileName)
4987 tmp := path + ".tmp"
4988 if err := os.WriteFile(tmp, b, 0o644); err != nil {
4989 return
4990 }
4991 if err := fileutil.ReplaceFile(tmp, path); err != nil {
4992 return
4993 }
4994 a.tabsLastWrittenVersion = version
4995 }
4996
4997 func (a *App) orderedTabIDsLocked() []string {
4998 ordered, needsRepair := a.orderedTabIDsSnapshotLocked()
4999 if needsRepair {
5000 a.tabOrder = append([]string(nil), ordered...)
5001 }
5002 return ordered
5003 }
5004
5005 func (a *App) orderedTabIDsSnapshotLocked() ([]string, bool) {
5006 seen := make(map[string]bool, len(a.tabs))
5007 ordered := make([]string, 0, len(a.tabs))
5008 for _, id := range a.tabOrder {
5009 if _, ok := a.tabs[id]; ok && !seen[id] {
5010 ordered = append(ordered, id)
5011 seen[id] = true
5012 }
5013 }
5014 var missing []string
5015 for id := range a.tabs {
5016 if !seen[id] {
5017 missing = append(missing, id)
5018 }
5019 }
5020 sort.Strings(missing)
5021 ordered = append(ordered, missing...)
5022 return ordered, len(ordered) != len(a.tabOrder) || len(missing) > 0
5023 }
5024
5025 func (a *App) removeTabOrderLocked(tabID string) {
5026 next := a.tabOrder[:0]
5027 for _, id := range a.tabOrder {
5028 if id != tabID {
5029 next = append(next, id)
5030 }
5031 }
5032 a.tabOrder = next
5033 }
5034
5035 func loadTabsFile() desktopTabsFile {
5036 path := filepath.Join(desktopConfigDir(), tabsFileName)
5037 b, err := readFileUTF8(path)
5038 if err != nil {
5039 return desktopTabsFile{}
5040 }
5041 var f desktopTabsFile
5042 _ = json.Unmarshal(b, &f)
5043 return f
5044 }
5045
5046 func desktopMCPMigrationRoots(tabs desktopTabsFile) []string {
5047 seen := map[string]bool{}
5048 var roots []string
5049 add := func(root string) {
5050 root = normalizeProjectRoot(root)
5051 key := projectRootKey(root)
5052 if root == "" || seen[key] {
5053 return
5054 }
5055 seen[key] = true
5056 roots = append(roots, root)
5057 }
5058 if cur := loadWorkspace(); cur != "" {
5059 add(cur)
5060 }
5061 for _, root := range loadWorkspaces() {
5062 add(root)
5063 }
5064 for _, entry := range tabs.Tabs {
5065 if entry.Scope == "project" {
5066 add(entry.WorkspaceRoot)
5067 }
5068 }
5069 for _, project := range loadProjectsFile().Projects {
5070 add(project.Root)
5071 }
5072 return roots
5073 }
5074
5075 func recoverLegacyProjectSidebarRoots(tabs desktopTabsFile) (bool, error) {
5076 markerPath := filepath.Join(desktopConfigDir(), legacyProjectSidebarRecoveryMarker)
5077 if _, err := os.Stat(markerPath); err == nil {
5078 return false, nil
5079 }
5080
5081 changed := false
5082 err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
5083 seen := map[string]bool{}
5084 for _, project := range f.Projects {
5085 root := normalizeProjectRoot(project.Root)
5086 if root != "" {
5087 seen[projectRootKey(root)] = true
5088 }
5089 }
5090
5091 add := func(root string) {
5092 root = normalizeProjectRoot(root)
5093 key := projectRootKey(root)
5094 if root == "" || seen[key] || !existingDirectory(root) {
5095 return
5096 }
5097 seen[key] = true
5098 f.Projects = append(f.Projects, desktopProject{Root: root})
5099 changed = true
5100 }
5101 if cur := loadWorkspace(); cur != "" {
5102 add(cur)
5103 }
5104 for _, root := range loadWorkspaces() {
5105 add(root)
5106 }
5107 for _, entry := range tabs.Tabs {
5108 if entry.Scope == "project" {
5109 add(entry.WorkspaceRoot)
5110 }
5111 }
5112 return changed, nil
5113 })
5114 if err != nil {
5115 return false, err
5116 }
5117 return changed, writeLegacyProjectSidebarRecoveryMarker(markerPath)
5118 }
5119
5120 func existingDirectory(path string) bool {
5121 info, err := os.Stat(path)
5122 return err == nil && info.IsDir()
5123 }
5124
5125 func writeLegacyProjectSidebarRecoveryMarker(path string) error {
5126 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
5127 return err
5128 }
5129 return os.WriteFile(path, []byte("ok\n"), 0o644)
5130 }
5131
5132 func loadProjectsFile() desktopProjectFile {
5133 path := filepath.Join(desktopConfigDir(), desktopProjectsFile)
5134 b, err := readFileUTF8(path)
5135 if err != nil {
5136 return desktopProjectFile{}
5137 }
5138 var f desktopProjectFile
5139 _ = json.Unmarshal(b, &f)
5140 return normalizeProjectsFile(f)
5141 }
5142
5143 func saveProjectsFile(f desktopProjectFile) error {
5144 dir := desktopConfigDir()
5145 if err := os.MkdirAll(dir, 0o755); err != nil {
5146 return err
5147 }
5148 f = normalizeProjectsFile(f)
5149 b, err := json.MarshalIndent(f, "", " ")
5150 if err != nil {
5151 return err
5152 }
5153 path := filepath.Join(dir, desktopProjectsFile)
5154 tmp := path + ".tmp"
5155 if err := os.WriteFile(tmp, b, 0o644); err != nil {
5156 return err
5157 }
5158 return fileutil.ReplaceFile(tmp, path)
5159 }
5160
5161 func updateProjectsFile(mutator func(*desktopProjectFile) (bool, error)) error {
5162 desktopProjectsFileMu.Lock()
5163 defer desktopProjectsFileMu.Unlock()
5164
5165 f := loadProjectsFile()
5166 changed, err := mutator(&f)
5167 if err != nil {
5168 return err
5169 }
5170 if !changed {
5171 return nil
5172 }
5173 return saveProjectsFile(f)
5174 }
5175
5176 func prependTopicInProjectsFile(workspaceRoot, topicID string, ensureProject bool) error {
5177 // Single-topic prepends are intentional writes (topic creation, a live tab
5178 // indexing its session, restore from trash): they clear any delete
5179 // tombstone so the topic fully returns instead of landing in a half-state
5180 // where only its title resurfaces.
5181 return prependTopicsInProjectsFileOpts(workspaceRoot, []string{topicID}, ensureProject, false)
5182 }
5183
5184 func prependTopicsInProjectsFile(workspaceRoot string, topicIDs []string, ensureProject bool) error {
5185 // Batch prepends come from the legacy migration and index-repair scans:
5186 // they must respect delete tombstones so a scan never resurrects a topic
5187 // the user removed.
5188 return prependTopicsInProjectsFileOpts(workspaceRoot, topicIDs, ensureProject, true)
5189 }
5190
5191 func prependTopicsInProjectsFileOpts(workspaceRoot string, topicIDs []string, ensureProject, respectTombstones bool) error {
5192 workspaceRoot = normalizeProjectRoot(workspaceRoot)
5193 topicIDs = uniqueStrings(topicIDs)
5194 if len(topicIDs) == 0 {
5195 return nil
5196 }
5197 return updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
5198 // Tombstones are checked under the projects-file lock: a DeleteTopic
5199 // that lands between a scan reading DeletedTopics and this write must
5200 // not be resurrected by the stale batch.
5201 live := topicIDs
5202 changed := false
5203 if respectTombstones {
5204 live = make([]string, 0, len(topicIDs))
5205 for _, id := range topicIDs {
5206 if !containsDesktopString(f.DeletedTopics, id) {
5207 live = append(live, id)
5208 }
5209 }
5210 if len(live) == 0 {
5211 return false, nil
5212 }
5213 } else {
5214 for _, id := range topicIDs {
5215 if next := removeString(f.DeletedTopics, id); !sameStringList(next, f.DeletedTopics) {
5216 f.DeletedTopics = next
5217 changed = true
5218 }
5219 }
5220 }
5221 if workspaceRoot == "" {
5222 next := uniqueStrings(append(append([]string(nil), live...), f.GlobalTopics...))
5223 if sameStringList(next, f.GlobalTopics) {
5224 return changed, nil
5225 }
5226 f.GlobalTopics = next
5227 return true, nil
5228 }
5229 for i, p := range f.Projects {
5230 if !sameProjectRoot(p.Root, workspaceRoot) {
5231 continue
5232 }
5233 next := uniqueStrings(append(append([]string(nil), live...), p.Topics...))
5234 if sameStringList(next, p.Topics) {
5235 return changed, nil
5236 }
5237 f.Projects[i].Topics = next
5238 return true, nil
5239 }
5240 if !ensureProject {
5241 return changed, nil
5242 }
5243 f.Projects = append(f.Projects, desktopProject{Root: workspaceRoot, Topics: live})
5244 return true, nil
5245 })
5246 }
5247
5248 func removeTopicFromProjectsFile(topicID string) error {
5249 topicID = strings.TrimSpace(topicID)
5250 if topicID == "" {
5251 return nil
5252 }
5253 return updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
5254 changed := false
5255 if next := removeString(f.GlobalTopics, topicID); !sameStringList(next, f.GlobalTopics) {
5256 f.GlobalTopics = next
5257 changed = true
5258 }
5259 if next := removeString(f.GlobalPinnedTopics, topicID); !sameStringList(next, f.GlobalPinnedTopics) {
5260 f.GlobalPinnedTopics = next
5261 changed = true
5262 }
5263 if next := prependUniqueString(f.DeletedTopics, topicID); !sameStringList(next, f.DeletedTopics) {
5264 f.DeletedTopics = next
5265 changed = true
5266 }
5267 for i, p := range f.Projects {
5268 if next := removeString(p.Topics, topicID); !sameStringList(next, p.Topics) {
5269 f.Projects[i].Topics = next
5270 changed = true
5271 }
5272 if next := removeString(p.PinnedTopics, topicID); !sameStringList(next, p.PinnedTopics) {
5273 f.Projects[i].PinnedTopics = next
5274 changed = true
5275 }
5276 }
5277 return changed, nil
5278 })
5279 }
5280
5281 func normalizeProjectRoot(root string) string {
5282 root = strings.TrimSpace(root)
5283 if root == "" {
5284 return ""
5285 }
5286 if abs, err := filepath.Abs(root); err == nil {
5287 return abs
5288 }
5289 return root
5290 }
5291
5292 func sameProjectRoot(a, b string) bool {
5293 return sameDesktopPath(normalizeProjectRoot(a), normalizeProjectRoot(b))
5294 }
5295
5296 func projectIndexByRoot(projects []desktopProject, root string) int {
5297 root = normalizeProjectRoot(root)
5298 if root == "" {
5299 return -1
5300 }
5301 for i, project := range projects {
5302 if sameProjectRoot(project.Root, root) {
5303 return i
5304 }
5305 }
5306 return -1
5307 }
5308
5309 func projectRootInList(roots []string, root string) bool {
5310 root = normalizeProjectRoot(root)
5311 if root == "" {
5312 return false
5313 }
5314 for _, candidate := range roots {
5315 if sameProjectRoot(candidate, root) {
5316 return true
5317 }
5318 }
5319 return false
5320 }
5321
5322 func normalizeProjectsFile(f desktopProjectFile) desktopProjectFile {
5323 out := desktopProjectFile{
5324 GlobalTitle: strings.TrimSpace(f.GlobalTitle),
5325 GlobalColor: normalizeProjectColor(f.GlobalColor),
5326 GlobalTopics: uniqueStrings(f.GlobalTopics),
5327 GlobalPinnedTopics: uniqueStrings(f.GlobalPinnedTopics),
5328 DeletedTopics: uniqueStrings(f.DeletedTopics),
5329 }
5330 for _, p := range f.Projects {
5331 root := normalizeProjectRoot(p.Root)
5332 if root == "" {
5333 continue
5334 }
5335 p.Root = root
5336 p.Title = strings.TrimSpace(p.Title)
5337 p.Color = normalizeProjectColor(p.Color)
5338 p.Topics = uniqueStrings(p.Topics)
5339 p.PinnedTopics = uniqueStrings(p.PinnedTopics)
5340 if i := projectIndexByRoot(out.Projects, root); i >= 0 {
5341 if out.Projects[i].Title == "" && p.Title != "" {
5342 out.Projects[i].Title = p.Title
5343 }
5344 if out.Projects[i].Color == "" && p.Color != "" {
5345 out.Projects[i].Color = p.Color
5346 }
5347 out.Projects[i].Topics = uniqueStrings(append(out.Projects[i].Topics, p.Topics...))
5348 out.Projects[i].PinnedTopics = uniqueStrings(append(out.Projects[i].PinnedTopics, p.PinnedTopics...))
5349 continue
5350 }
5351 out.Projects = append(out.Projects, p)
5352 }
5353 for _, root := range uniqueStrings(f.PinnedProjects) {
5354 root = normalizeProjectRoot(root)
5355 if i := projectIndexByRoot(out.Projects, root); i >= 0 && !projectRootInList(out.PinnedProjects, out.Projects[i].Root) {
5356 out.PinnedProjects = append(out.PinnedProjects, out.Projects[i].Root)
5357 }
5358 }
5359 out.SidebarOrder = normalizeSidebarOrder(f.SidebarOrder, out.Projects)
5360 return out
5361 }
5362
5363 func normalizeSidebarOrder(order []string, projects []desktopProject) []string {
5364 seenGlobal := false
5365 // Dedupe roots against a roots-only list: out also holds the global order
5366 // token, which must never be path-compared against project roots.
5367 var seenRoots []string
5368 out := make([]string, 0, len(order))
5369 for _, value := range order {
5370 value = strings.TrimSpace(value)
5371 if value == desktopGlobalOrderToken {
5372 if !seenGlobal {
5373 seenGlobal = true
5374 out = append(out, value)
5375 }
5376 continue
5377 }
5378 root := normalizeProjectRoot(value)
5379 i := projectIndexByRoot(projects, root)
5380 if i < 0 {
5381 continue
5382 }
5383 root = projects[i].Root
5384 if projectRootInList(seenRoots, root) {
5385 continue
5386 }
5387 seenRoots = append(seenRoots, root)
5388 out = append(out, root)
5389 }
5390 return out
5391 }
5392
5393 func sameProjectOrder(a, b []desktopProject) bool {
5394 if len(a) != len(b) {
5395 return false
5396 }
5397 for i := range a {
5398 if a[i].Root != b[i].Root {
5399 return false
5400 }
5401 }
5402 return true
5403 }
5404
5405 func uniqueStrings(values []string) []string {
5406 seen := make(map[string]bool, len(values))
5407 out := make([]string, 0, len(values))
5408 for _, value := range values {
5409 value = strings.TrimSpace(value)
5410 if value == "" || seen[value] {
5411 continue
5412 }
5413 seen[value] = true
5414 out = append(out, value)
5415 }
5416 return out
5417 }
5418
5419 func prependUniqueString(values []string, value string) []string {
5420 value = strings.TrimSpace(value)
5421 if value == "" {
5422 return uniqueStrings(values)
5423 }
5424 return uniqueStrings(append([]string{value}, values...))
5425 }
5426
5427 func removeString(values []string, value string) []string {
5428 value = strings.TrimSpace(value)
5429 if value == "" {
5430 return uniqueStrings(values)
5431 }
5432 out := make([]string, 0, len(values))
5433 for _, item := range uniqueStrings(values) {
5434 if item != value {
5435 out = append(out, item)
5436 }
5437 }
5438 return out
5439 }
5440
5441 func containsDesktopString(values []string, value string) bool {
5442 value = strings.TrimSpace(value)
5443 if value == "" {
5444 return false
5445 }
5446 for _, item := range uniqueStrings(values) {
5447 if item == value {
5448 return true
5449 }
5450 }
5451 return false
5452 }
5453
5454 func pinnedTopicIDs(topicIDs []string, pinned []string) []string {
5455 if len(topicIDs) == 0 || len(pinned) == 0 {
5456 return topicIDs
5457 }
5458 available := make(map[string]bool, len(topicIDs))
5459 for _, tid := range topicIDs {
5460 available[tid] = true
5461 }
5462 out := make([]string, 0, len(topicIDs))
5463 seen := make(map[string]bool, len(topicIDs))
5464 for _, tid := range uniqueStrings(pinned) {
5465 if available[tid] && !seen[tid] {
5466 out = append(out, tid)
5467 seen[tid] = true
5468 }
5469 }
5470 for _, tid := range topicIDs {
5471 if !seen[tid] {
5472 out = append(out, tid)
5473 }
5474 }
5475 return out
5476 }
5477
5478 func orderedTopicIDs(explicit []string, titleMap map[string]string) []string {
5479 seen := map[string]bool{}
5480 out := make([]string, 0, len(explicit)+len(titleMap))
5481 for _, tid := range explicit {
5482 tid = strings.TrimSpace(tid)
5483 if tid == "" || seen[tid] {
5484 continue
5485 }
5486 seen[tid] = true
5487 out = append(out, tid)
5488 }
5489 var remaining []string
5490 for tid := range titleMap {
5491 if !seen[tid] {
5492 remaining = append(remaining, tid)
5493 }
5494 }
5495 sort.Strings(remaining)
5496 return append(out, remaining...)
5497 }
5498
5499 func projectTreeOrderKey(node ProjectNode) string {
5500 switch node.Kind {
5501 case "global_folder":
5502 return desktopGlobalOrderToken
5503 case "project":
5504 return normalizeProjectRoot(node.Root)
5505 default:
5506 return ""
5507 }
5508 }
5509
5510 func applyProjectTreeOrder(nodes []ProjectNode, order []string) []ProjectNode {
5511 if len(order) == 0 {
5512 return nodes
5513 }
5514 byKey := make(map[string]ProjectNode, len(nodes))
5515 for _, node := range nodes {
5516 key := projectTreeOrderKey(node)
5517 if key != "" {
5518 byKey[key] = node
5519 }
5520 }
5521 seen := make(map[string]bool, len(nodes))
5522 out := make([]ProjectNode, 0, len(nodes))
5523 for _, value := range order {
5524 key := strings.TrimSpace(value)
5525 if key != desktopGlobalOrderToken {
5526 key = normalizeProjectRoot(key)
5527 }
5528 if key == "" || seen[key] {
5529 continue
5530 }
5531 node, ok := byKey[key]
5532 if !ok {
5533 continue
5534 }
5535 seen[key] = true
5536 out = append(out, node)
5537 }
5538 for _, node := range nodes {
5539 key := projectTreeOrderKey(node)
5540 if key != "" && seen[key] {
5541 continue
5542 }
5543 if key != "" {
5544 seen[key] = true
5545 }
5546 out = append(out, node)
5547 }
5548 return out
5549 }
5550
5551 func applyPinnedProjectOrder(nodes []ProjectNode, pinnedRoots []string) []ProjectNode {
5552 pinnedRoots = uniqueStrings(pinnedRoots)
5553 if len(pinnedRoots) == 0 {
5554 return nodes
5555 }
5556 byRoot := make(map[string]ProjectNode, len(nodes))
5557 for _, node := range nodes {
5558 if node.Kind == "project" && node.Root != "" {
5559 byRoot[normalizeProjectRoot(node.Root)] = node
5560 }
5561 }
5562 seen := make(map[string]bool, len(pinnedRoots))
5563 out := make([]ProjectNode, 0, len(nodes))
5564 for _, root := range pinnedRoots {
5565 root = normalizeProjectRoot(root)
5566 node, ok := byRoot[root]
5567 if !ok || seen[root] {
5568 continue
5569 }
5570 seen[root] = true
5571 out = append(out, node)
5572 }
5573 for _, node := range nodes {
5574 if node.Kind == "project" && node.Root != "" && seen[normalizeProjectRoot(node.Root)] {
5575 continue
5576 }
5577 out = append(out, node)
5578 }
5579 return out
5580 }
5581
5582 func projectDisplayName(p desktopProject) string {
5583 if title := strings.TrimSpace(p.Title); title != "" {
5584 return title
5585 }
5586 return workspaceName(p.Root)
5587 }
5588
5589 func normalizeProjectColor(color string) string {
5590 switch strings.TrimSpace(strings.ToLower(color)) {
5591 case "red", "orange", "amber", "green", "teal", "blue", "purple", "pink":
5592 return strings.TrimSpace(strings.ToLower(color))
5593 default:
5594 return ""
5595 }
5596 }
5597
5598 func projectColor(root string) string {
5599 root = normalizeProjectRoot(root)
5600 if root == "" {
5601 return globalProjectColor()
5602 }
5603 for _, p := range loadProjectsFile().Projects {
5604 if sameProjectRoot(p.Root, root) {
5605 return normalizeProjectColor(p.Color)
5606 }
5607 }
5608 return ""
5609 }
5610
5611 func globalProjectColor() string {
5612 return normalizeProjectColor(loadProjectsFile().GlobalColor)
5613 }
5614
5615 func globalProjectTitle() string {
5616 if title := strings.TrimSpace(loadProjectsFile().GlobalTitle); title != "" {
5617 return title
5618 }
5619 return "Global"
5620 }
5621
5622 func addProject(root, title string) error {
5623 root = normalizeProjectRoot(root)
5624 if root == "" {
5625 return fmt.Errorf("project root is required")
5626 }
5627 title = strings.TrimSpace(title)
5628 return updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
5629 for i, p := range f.Projects {
5630 if sameProjectRoot(p.Root, root) {
5631 changed := false
5632 if f.Projects[i].Root != root {
5633 f.Projects[i].Root = root
5634 changed = true
5635 }
5636 if title != "" && f.Projects[i].Title != title {
5637 f.Projects[i].Title = title
5638 changed = true
5639 }
5640 if !changed {
5641 return false, nil
5642 }
5643 return true, nil
5644 }
5645 }
5646 f.Projects = append(f.Projects, desktopProject{Root: root, Title: title})
5647 return true, nil
5648 })
5649 }
5650
5651 func renameProject(root, title string) error {
5652 title = strings.TrimSpace(title)
5653 root = normalizeProjectRoot(root)
5654 return updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
5655 if root == "" {
5656 if f.GlobalTitle == title {
5657 return false, nil
5658 }
5659 f.GlobalTitle = title
5660 return true, nil
5661 }
5662 for i, p := range f.Projects {
5663 if sameProjectRoot(p.Root, root) {
5664 if f.Projects[i].Root == root && f.Projects[i].Title == title {
5665 return false, nil
5666 }
5667 f.Projects[i].Root = root
5668 f.Projects[i].Title = title
5669 return true, nil
5670 }
5671 }
5672 f.Projects = append(f.Projects, desktopProject{Root: root, Title: title})
5673 return true, nil
5674 })
5675 }
5676
5677 func setProjectColor(root, color string) error {
5678 root = normalizeProjectRoot(root)
5679 color = normalizeProjectColor(color)
5680 return updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
5681 if root == "" {
5682 if f.GlobalColor == color {
5683 return false, nil
5684 }
5685 f.GlobalColor = color
5686 return true, nil
5687 }
5688 for i, p := range f.Projects {
5689 if sameProjectRoot(p.Root, root) {
5690 if f.Projects[i].Root == root && f.Projects[i].Color == color {
5691 return false, nil
5692 }
5693 f.Projects[i].Root = root
5694 f.Projects[i].Color = color
5695 return true, nil
5696 }
5697 }
5698 f.Projects = append(f.Projects, desktopProject{Root: root, Color: color})
5699 return true, nil
5700 })
5701 }
5702
5703 func removeProject(root string) error {
5704 root = normalizeProjectRoot(root)
5705 return updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
5706 projects := make([]desktopProject, 0, len(f.Projects))
5707 for _, p := range f.Projects {
5708 if !sameProjectRoot(p.Root, root) {
5709 projects = append(projects, p)
5710 }
5711 }
5712 if len(projects) == len(f.Projects) {
5713 return false, nil
5714 }
5715 f.Projects = projects
5716 return true, nil
5717 })
5718 }
5719
5720 // --- topic helpers ----------------------------------------------------------
5721
5722 const (
5723 topicTitlesFile = "desktop-topic-titles.json"
5724 topicTitleSourcesFile = "desktop-topic-title-sources.json"
5725 topicCreatedAtsFile = "desktop-topic-created-at.json"
5726 topicAutoTitlesFile = "desktop-topic-auto-title-meta.json"
5727 defaultTopicTitle = "新的会话"
5728 defaultTopicTitleEn = "New session"
5729 defaultTopicTitleZhTW = "新的會話"
5730 topicTitleSourceAuto = "auto"
5731 topicTitleSourceManual = "manual"
5732 )
5733
5734 const (
5735 desktopLocaleUnknown int32 = iota
5736 desktopLocaleEn
5737 desktopLocaleZh
5738 desktopLocaleZhTW
5739 )
5740
5741 func (a *App) setDesktopLocale(locale string) {
5742 normalized := strings.ToLower(strings.TrimSpace(locale))
5743 switch {
5744 case strings.HasPrefix(normalized, "zh-tw"), strings.HasPrefix(normalized, "zh-hant"):
5745 a.desktopLocale.Store(desktopLocaleZhTW)
5746 case strings.HasPrefix(normalized, "zh"):
5747 a.desktopLocale.Store(desktopLocaleZh)
5748 default:
5749 a.desktopLocale.Store(desktopLocaleEn)
5750 }
5751 }
5752
5753 func (a *App) desktopAutoPricingCurrency() string {
5754 if a != nil {
5755 switch a.desktopLocale.Load() {
5756 case desktopLocaleZh, desktopLocaleZhTW:
5757 return "CNY"
5758 }
5759 }
5760 return "USD"
5761 }
5762
5763 func (a *App) localizedDefaultTopicTitle() string {
5764 switch a.desktopLocale.Load() {
5765 case desktopLocaleZh:
5766 return defaultTopicTitle
5767 case desktopLocaleZhTW:
5768 return defaultTopicTitleZhTW
5769 case desktopLocaleEn:
5770 return defaultTopicTitleEn
5771 default:
5772 return defaultTopicTitle
5773 }
5774 }
5775
5776 func isDefaultTopicTitle(title string) bool {
5777 switch strings.TrimSpace(title) {
5778 case defaultTopicTitle, defaultTopicTitleEn, defaultTopicTitleZhTW:
5779 return true
5780 default:
5781 return false
5782 }
5783 }
5784
5785 func (a *App) localizedTopicTitle(title, source string) string {
5786 if strings.TrimSpace(source) == topicTitleSourceAuto && isDefaultTopicTitle(title) {
5787 return a.localizedDefaultTopicTitle()
5788 }
5789 return title
5790 }
5791
5792 func topicTitlesPath(workspaceRoot string) string {
5793 if workspaceRoot == "" {
5794 return filepath.Join(desktopConfigDir(), "global", topicTitlesFile)
5795 }
5796 return filepath.Join(workspaceRoot, ".reasonix", topicTitlesFile)
5797 }
5798
5799 func topicTitleSourcesPath(workspaceRoot string) string {
5800 if workspaceRoot == "" {
5801 return filepath.Join(desktopConfigDir(), "global", topicTitleSourcesFile)
5802 }
5803 return filepath.Join(workspaceRoot, ".reasonix", topicTitleSourcesFile)
5804 }
5805
5806 func topicCreatedAtsPath(workspaceRoot string) string {
5807 if workspaceRoot == "" {
5808 return filepath.Join(desktopConfigDir(), "global", topicCreatedAtsFile)
5809 }
5810 return filepath.Join(workspaceRoot, ".reasonix", topicCreatedAtsFile)
5811 }
5812
5813 func topicAutoTitleMetaPath(workspaceRoot string) string {
5814 if workspaceRoot == "" {
5815 return filepath.Join(desktopConfigDir(), "global", topicAutoTitlesFile)
5816 }
5817 return filepath.Join(workspaceRoot, ".reasonix", topicAutoTitlesFile)
5818 }
5819
5820 const topicFileReadTimeout = 200 * time.Millisecond
5821
5822 var readFileWithTimeoutSlots = make(chan struct{}, 16)
5823
5824 func readFileWithTimeout(path string, timeout time.Duration) ([]byte, error) {
5825 if timeout <= 0 {
5826 return readFileUTF8(path)
5827 }
5828 select {
5829 case readFileWithTimeoutSlots <- struct{}{}:
5830 default:
5831 return nil, fmt.Errorf("too many pending file reads")
5832 }
5833 type result struct {
5834 data []byte
5835 err error
5836 }
5837 ch := make(chan result, 1)
5838 go func() {
5839 data, err := readFileUTF8(path)
5840 <-readFileWithTimeoutSlots
5841 ch <- result{data: data, err: err}
5842 }()
5843 timer := time.NewTimer(timeout)
5844 defer timer.Stop()
5845 select {
5846 case r := <-ch:
5847 return r.data, r.err
5848 case <-timer.C:
5849 return nil, fmt.Errorf("timed out after %v reading %s", timeout, filepath.Base(path))
5850 }
5851 }
5852
5853 func loadTopicTitles(workspaceRoot string) map[string]string {
5854 m := map[string]string{}
5855 b, err := readFileWithTimeout(topicTitlesPath(workspaceRoot), topicFileReadTimeout)
5856 if err != nil {
5857 return m
5858 }
5859 _ = json.Unmarshal(b, &m)
5860 // Same read-boundary cleaning as loadSessionTitles: older builds could
5861 // persist titles carrying internal wrappers (#5666).
5862 for key, title := range m {
5863 m[key] = agent.UserPreviewText(title)
5864 }
5865 return m
5866 }
5867
5868 func loadTopicTitleSources(workspaceRoot string) map[string]string {
5869 m := map[string]string{}
5870 b, err := readFileWithTimeout(topicTitleSourcesPath(workspaceRoot), topicFileReadTimeout)
5871 if err != nil {
5872 return m
5873 }
5874 _ = json.Unmarshal(b, &m)
5875 return m
5876 }
5877
5878 func loadTopicCreatedAts(workspaceRoot string) map[string]int64 {
5879 m := map[string]int64{}
5880 b, err := readFileWithTimeout(topicCreatedAtsPath(workspaceRoot), topicFileReadTimeout)
5881 if err != nil {
5882 return m
5883 }
5884 _ = json.Unmarshal(b, &m)
5885 return m
5886 }
5887
5888 type topicAutoTitleMeta struct {
5889 Stage int `json:"stage,omitempty"`
5890 UserTurns int `json:"userTurns,omitempty"`
5891 BasisHash string `json:"basisHash,omitempty"`
5892 UpdatedAt int64 `json:"updatedAt,omitempty"`
5893 }
5894
5895 func loadTopicAutoTitleMeta(workspaceRoot string) map[string]topicAutoTitleMeta {
5896 m := map[string]topicAutoTitleMeta{}
5897 b, err := readFileWithTimeout(topicAutoTitleMetaPath(workspaceRoot), topicFileReadTimeout)
5898 if err != nil {
5899 return m
5900 }
5901 _ = json.Unmarshal(b, &m)
5902 return m
5903 }
5904
5905 func loadStringMapForUpdate(path string) (map[string]string, error) {
5906 m := map[string]string{}
5907 b, err := readFileUTF8(path)
5908 if err != nil {
5909 if errors.Is(err, os.ErrNotExist) {
5910 return m, nil
5911 }
5912 return nil, err
5913 }
5914 if err := json.Unmarshal(b, &m); err != nil || m == nil {
5915 return map[string]string{}, nil
5916 }
5917 return m, nil
5918 }
5919
5920 func loadTopicAutoTitleMetaForUpdate(workspaceRoot string) (map[string]topicAutoTitleMeta, error) {
5921 m := map[string]topicAutoTitleMeta{}
5922 path := topicAutoTitleMetaPath(workspaceRoot)
5923 b, err := readFileUTF8(path)
5924 if err != nil {
5925 if errors.Is(err, os.ErrNotExist) {
5926 return m, nil
5927 }
5928 return nil, err
5929 }
5930 if err := json.Unmarshal(b, &m); err != nil || m == nil {
5931 return map[string]topicAutoTitleMeta{}, nil
5932 }
5933 return m, nil
5934 }
5935
5936 func loadInt64MapForUpdate(path string) (map[string]int64, error) {
5937 m := map[string]int64{}
5938 b, err := readFileUTF8(path)
5939 if err != nil {
5940 if errors.Is(err, os.ErrNotExist) {
5941 return m, nil
5942 }
5943 return nil, err
5944 }
5945 if err := json.Unmarshal(b, &m); err != nil || m == nil {
5946 return map[string]int64{}, nil
5947 }
5948 return m, nil
5949 }
5950
5951 func loadTopicTitlesForUpdate(workspaceRoot string) (map[string]string, error) {
5952 return loadStringMapForUpdate(topicTitlesPath(workspaceRoot))
5953 }
5954
5955 func loadTopicTitleSourcesForUpdate(workspaceRoot string) (map[string]string, error) {
5956 return loadStringMapForUpdate(topicTitleSourcesPath(workspaceRoot))
5957 }
5958
5959 func loadTopicCreatedAtsForUpdate(workspaceRoot string) (map[string]int64, error) {
5960 return loadInt64MapForUpdate(topicCreatedAtsPath(workspaceRoot))
5961 }
5962
5963 // ensureTopicStateDir prepares the directory holding a topic-state file. A
5964 // project file lives under the workspace root, so the directory is only created
5965 // while that root still exists — otherwise deleting the folder outside Reasonix
5966 // resurrects it on the next launch (#4566).
5967 func ensureTopicStateDir(workspaceRoot, path string) error {
5968 if root := strings.TrimSpace(workspaceRoot); root != "" && !existingDirectory(root) {
5969 return fmt.Errorf("workspace root %q no longer exists", root)
5970 }
5971 return os.MkdirAll(filepath.Dir(path), 0o755)
5972 }
5973
5974 func saveTopicTitles(workspaceRoot string, m map[string]string) error {
5975 b, err := json.MarshalIndent(m, "", " ")
5976 if err != nil {
5977 return err
5978 }
5979 path := topicTitlesPath(workspaceRoot)
5980 if err := ensureTopicStateDir(workspaceRoot, path); err != nil {
5981 return err
5982 }
5983 tmp := path + ".tmp"
5984 if err := os.WriteFile(tmp, b, 0o644); err != nil {
5985 return err
5986 }
5987 return fileutil.ReplaceFile(tmp, path)
5988 }
5989
5990 func saveTopicTitleSources(workspaceRoot string, m map[string]string) error {
5991 b, err := json.MarshalIndent(m, "", " ")
5992 if err != nil {
5993 return err
5994 }
5995 path := topicTitleSourcesPath(workspaceRoot)
5996 if err := ensureTopicStateDir(workspaceRoot, path); err != nil {
5997 return err
5998 }
5999 tmp := path + ".tmp"
6000 if err := os.WriteFile(tmp, b, 0o644); err != nil {
6001 return err
6002 }
6003 return fileutil.ReplaceFile(tmp, path)
6004 }
6005
6006 func saveTopicCreatedAts(workspaceRoot string, m map[string]int64) error {
6007 b, err := json.MarshalIndent(m, "", " ")
6008 if err != nil {
6009 return err
6010 }
6011 path := topicCreatedAtsPath(workspaceRoot)
6012 if err := ensureTopicStateDir(workspaceRoot, path); err != nil {
6013 return err
6014 }
6015 tmp := path + ".tmp"
6016 if err := os.WriteFile(tmp, b, 0o644); err != nil {
6017 return err
6018 }
6019 return fileutil.ReplaceFile(tmp, path)
6020 }
6021
6022 func saveTopicAutoTitleMeta(workspaceRoot string, m map[string]topicAutoTitleMeta) error {
6023 b, err := json.MarshalIndent(m, "", " ")
6024 if err != nil {
6025 return err
6026 }
6027 path := topicAutoTitleMetaPath(workspaceRoot)
6028 if err := ensureTopicStateDir(workspaceRoot, path); err != nil {
6029 return err
6030 }
6031 tmp := path + ".tmp"
6032 if err := os.WriteFile(tmp, b, 0o644); err != nil {
6033 return err
6034 }
6035 return fileutil.ReplaceFile(tmp, path)
6036 }
6037
6038 func loadTopicTitle(workspaceRoot, topicID string) string {
6039 return loadTopicTitles(workspaceRoot)[topicID]
6040 }
6041
6042 func loadTopicTitleSource(workspaceRoot, topicID string) string {
6043 return loadTopicTitleSources(workspaceRoot)[topicID]
6044 }
6045
6046 func loadTopicCreatedAt(workspaceRoot, topicID string) int64 {
6047 return loadTopicCreatedAts(workspaceRoot)[topicID]
6048 }
6049
6050 func topicIDCreatedAt(topicID string) int64 {
6051 topicID = strings.TrimSpace(topicID)
6052 for _, prefix := range []string{"topic_", "legacy_"} {
6053 if !strings.HasPrefix(topicID, prefix) {
6054 continue
6055 }
6056 stamp := strings.TrimPrefix(topicID, prefix)
6057 if len(stamp) < len("20060102-150405") {
6058 continue
6059 }
6060 stamp = stamp[:len("20060102-150405")]
6061 t, err := time.ParseInLocation("20060102-150405", stamp, time.UTC)
6062 if err != nil {
6063 continue
6064 }
6065 return t.UnixMilli()
6066 }
6067 return 0
6068 }
6069
6070 func topicCreatedAtForTree(createdAts map[string]int64, topicID string) int64 {
6071 if createdAt := createdAts[topicID]; createdAt > 0 {
6072 return createdAt
6073 }
6074 return topicIDCreatedAt(topicID)
6075 }
6076
6077 func topicTitleForTab(scope, workspaceRoot, topicID string) string {
6078 titleRoot := topicTitleRoot(scope, workspaceRoot)
6079 if title := strings.TrimSpace(loadTopicTitle(titleRoot, topicID)); title != "" {
6080 return title
6081 }
6082 if scope == "global" {
6083 return "Global"
6084 }
6085 return defaultTopicTitle
6086 }
6087
6088 func topicTitleRoot(scope, workspaceRoot string) string {
6089 if scope == "global" {
6090 return ""
6091 }
6092 return workspaceRoot
6093 }
6094
6095 func (a *App) forkTopicTitle(title string) string {
6096 base := strings.TrimSpace(title)
6097 if base == "" || isDefaultTopicTitle(base) || base == "Global" {
6098 switch a.desktopLocale.Load() {
6099 case desktopLocaleEn:
6100 return "Forked session"
6101 case desktopLocaleZhTW:
6102 return "分叉會話"
6103 default:
6104 return "分叉会话"
6105 }
6106 }
6107 if strings.HasSuffix(base, " · 分叉") || strings.HasSuffix(base, " · fork") {
6108 return base
6109 }
6110 if a.desktopLocale.Load() == desktopLocaleEn {
6111 return base + " · fork"
6112 }
6113 return base + " · 分叉"
6114 }
6115
6116 type sessionRecoveryEvent struct {
6117 OriginalPath string `json:"originalPath,omitempty"`
6118 RecoveryPath string `json:"recoveryPath"`
6119 Scope string `json:"scope,omitempty"`
6120 WorkspaceRoot string `json:"workspaceRoot,omitempty"`
6121 TopicID string `json:"topicId,omitempty"`
6122 TopicTitle string `json:"topicTitle,omitempty"`
6123 RecoveryReason string `json:"recoveryReason,omitempty"`
6124 RecoveryDigest string `json:"recoveryDigest,omitempty"`
6125 RecoveryParentID string `json:"recoveryParentId,omitempty"`
6126 Existing bool `json:"existing,omitempty"`
6127 }
6128
6129 type sessionRecoveryFailedEvent struct {
6130 Reason string `json:"reason,omitempty"`
6131 }
6132
6133 func (a *App) tabSessionRecoveryMeta(tab *WorkspaceTab) func(control.SessionRecoveryRequest) agent.BranchMeta {
6134 return func(req control.SessionRecoveryRequest) agent.BranchMeta {
6135 if tab == nil {
6136 return agent.BranchMeta{Name: agent.RecoveryBranchDefaultName}
6137 }
6138 // This runs on the snapshot-recovery path, which can fire from the
6139 // controller's autosave goroutine; snapshot the tab fields under a.mu so
6140 // we don't read them mid-mutation. Recovery callbacks never hold a.mu, so
6141 // taking it here can't deadlock. Controller reads happen off-lock.
6142 a.mu.RLock()
6143 ctrl := tab.Ctrl
6144 scope := strings.TrimSpace(tab.Scope)
6145 workspaceRoot := strings.TrimSpace(tab.WorkspaceRoot)
6146 topicID := tab.TopicID
6147 topicTitle := tab.TopicTitle
6148 model := strings.TrimSpace(tab.model)
6149 tokenMode := persistedTabTokenMode(boot.NormalizeTokenMode(tab.tokenMode))
6150 mode := normalizeTabMode(tab.mode)
6151 toolApprovalMode := normalizeToolApprovalMode(tab.toolApprovalMode)
6152 goal := strings.TrimSpace(tab.goal)
6153 a.mu.RUnlock()
6154 if ctrl != nil {
6155 mode = tabModeFromAxes(ctrl.PlanMode(), ctrl.AutoApproveTools())
6156 toolApprovalMode = normalizeToolApprovalMode(ctrl.ToolApprovalMode())
6157 if g := strings.TrimSpace(ctrl.Goal()); g != "" && ctrl.GoalStatus() == control.GoalStatusRunning {
6158 goal = g
6159 } else {
6160 goal = ""
6161 }
6162 }
6163 if scope != "project" {
6164 scope = "global"
6165 }
6166 if scope == "global" {
6167 workspaceRoot = ""
6168 }
6169 return agent.BranchMeta{
6170 Name: agent.RecoveryBranchDefaultName,
6171 Scope: scope,
6172 WorkspaceRoot: workspaceRoot,
6173 TopicID: topicID,
6174 TopicTitle: topicTitle,
6175 Model: model,
6176 TokenMode: tokenMode,
6177 Mode: persistedTabMode(mode),
6178 ToolApprovalMode: persistedToolApprovalMode(toolApprovalMode),
6179 Goal: goal,
6180 }
6181 }
6182 }
6183
6184 func (a *App) handleTabSessionRecovered(tab *WorkspaceTab) func(control.SessionRecoveryInfo) error {
6185 return func(info control.SessionRecoveryInfo) error {
6186 if strings.TrimSpace(info.RecoveryPath) == "" {
6187 return nil
6188 }
6189 if tab != nil && !tab.ReadOnly {
6190 if err := a.ensureTabSessionLeaseForRebuild(tab, info.RecoveryPath, ""); err != nil {
6191 slog.Warn("desktop: acquire recovery session lease", "path", info.RecoveryPath, "err", err)
6192 reason := "lease_unavailable"
6193 if errors.Is(err, agent.ErrSessionLeaseHeld) {
6194 reason = "lease_held"
6195 }
6196 a.emitRuntimeEvent("session:recovery-failed", sessionRecoveryFailedEvent{Reason: reason})
6197 // This error propagates out through ctrl.Snapshot() into chat
6198 // notices and bridge returns (reportTabSnapshotError). The raw
6199 // path/holder id stayed in the slog line above; keep it out of
6200 // the surfaced message.
6201 return fmt.Errorf("acquire recovery session lease: %w",
6202 userFacingSessionLeaseError("", err))
6203 }
6204 }
6205 meta := info.Meta
6206 scope := strings.TrimSpace(meta.Scope)
6207 if scope != "project" {
6208 scope = "global"
6209 }
6210 workspaceRoot := strings.TrimSpace(meta.WorkspaceRoot)
6211 if scope == "global" {
6212 workspaceRoot = ""
6213 }
6214 invalidateTopicSessionIndexForPath(info.RecoveryPath)
6215 a.mu.Lock()
6216 if tab != nil && !tab.removed {
6217 oldKey := sessionRuntimeKey(info.OriginalPath)
6218 newKey := sessionRuntimeKey(info.RecoveryPath)
6219 if oldKey != "" && newKey != "" && a.detachedSessions[oldKey] == tab {
6220 delete(a.detachedSessions, oldKey)
6221 a.ensureDetachedSessionsLocked()
6222 a.detachedSessions[newKey] = tab
6223 }
6224 tab.SessionPath = canonicalTabSessionPath(info.RecoveryPath)
6225 if a.tabs[tab.ID] == tab {
6226 a.saveTabsLocked()
6227 }
6228 }
6229 a.mu.Unlock()
6230 // The fork continues the same conversation, so its cost history moves
6231 // with it: re-key the in-memory telemetry from the original session to
6232 // the recovery path and persist the sidecar right away. Without this
6233 // the fork's sidecar stays empty until the next usage event (cost
6234 // shows 0 after a restart), and the sync-by-key reload would wipe the
6235 // carried totals as "another session's" numbers.
6236 if tab != nil && !tab.removed {
6237 origKey := sessionRuntimeKey(info.OriginalPath)
6238 newKey := sessionRuntimeKey(info.RecoveryPath)
6239 carried := false
6240 if newKey != "" {
6241 tab.telemMu.Lock()
6242 if tab.telemetrySessionKey == origKey || tab.telemetrySessionKey == "" {
6243 tab.telemetrySessionKey = newKey
6244 carried = true
6245 }
6246 tab.telemMu.Unlock()
6247 }
6248 if carried {
6249 _ = saveTelemetry(info.RecoveryPath+".telemetry.json", tab.telemetrySnapshot())
6250 }
6251 }
6252 a.emitProjectTreeChangedForSessionDirs(sessionListCacheDirForPath(info.RecoveryPath))
6253 a.emitRuntimeEvent("session:recovered", sessionRecoveryEvent{
6254 OriginalPath: info.OriginalPath,
6255 RecoveryPath: info.RecoveryPath,
6256 Scope: scope,
6257 WorkspaceRoot: workspaceRoot,
6258 TopicID: meta.TopicID,
6259 TopicTitle: meta.TopicTitle,
6260 RecoveryReason: meta.RecoveryReason,
6261 RecoveryDigest: meta.RecoveryDigest,
6262 RecoveryParentID: string(meta.ParentID),
6263 Existing: info.Existing,
6264 })
6265 a.invalidatePromptHistoryCache()
6266 return nil
6267 }
6268 }
6269
6270 func setTopicTitle(workspaceRoot, topicID, title string) error {
6271 return setTopicTitleWithSource(workspaceRoot, topicID, title, topicTitleSourceManual)
6272 }
6273
6274 func setTopicTitleWithSource(workspaceRoot, topicID, title, source string) error {
6275 m, err := loadTopicTitlesForUpdate(workspaceRoot)
6276 if err != nil {
6277 return err
6278 }
6279 if strings.TrimSpace(title) == "" {
6280 delete(m, topicID)
6281 } else {
6282 m[topicID] = strings.TrimSpace(title)
6283 }
6284 if err := saveTopicTitles(workspaceRoot, m); err != nil {
6285 return err
6286 }
6287
6288 sources, err := loadTopicTitleSourcesForUpdate(workspaceRoot)
6289 if err != nil {
6290 return err
6291 }
6292 if strings.TrimSpace(title) == "" || strings.TrimSpace(source) == "" {
6293 delete(sources, topicID)
6294 } else {
6295 sources[topicID] = strings.TrimSpace(source)
6296 }
6297 if err := saveTopicTitleSources(workspaceRoot, sources); err != nil {
6298 return err
6299 }
6300 if strings.TrimSpace(source) == topicTitleSourceManual ||
6301 (strings.TrimSpace(source) == topicTitleSourceAuto && isDefaultTopicTitle(title)) {
6302 _ = deleteTopicAutoTitleMeta(workspaceRoot, topicID)
6303 }
6304 return nil
6305 }
6306
6307 func recordTopicAutoTitleMeta(workspaceRoot, topicID string, proposal autoTopicTitleProposal) error {
6308 topicID = strings.TrimSpace(topicID)
6309 if topicID == "" || proposal.Stage <= 0 || proposal.BasisHash == "" {
6310 return nil
6311 }
6312 m, err := loadTopicAutoTitleMetaForUpdate(workspaceRoot)
6313 if err != nil {
6314 return err
6315 }
6316 m[topicID] = topicAutoTitleMeta{
6317 Stage: proposal.Stage,
6318 UserTurns: proposal.UserTurns,
6319 BasisHash: proposal.BasisHash,
6320 UpdatedAt: time.Now().UnixMilli(),
6321 }
6322 return saveTopicAutoTitleMeta(workspaceRoot, m)
6323 }
6324
6325 func deleteTopicAutoTitleMeta(workspaceRoot, topicID string) error {
6326 topicID = strings.TrimSpace(topicID)
6327 if topicID == "" {
6328 return nil
6329 }
6330 m, err := loadTopicAutoTitleMetaForUpdate(workspaceRoot)
6331 if err != nil {
6332 return err
6333 }
6334 if _, ok := m[topicID]; !ok {
6335 return nil
6336 }
6337 delete(m, topicID)
6338 return saveTopicAutoTitleMeta(workspaceRoot, m)
6339 }
6340
6341 func setTopicCreatedAt(workspaceRoot, topicID string, createdAt int64) error {
6342 created, err := loadTopicCreatedAtsForUpdate(workspaceRoot)
6343 if err != nil {
6344 return err
6345 }
6346 topicID = strings.TrimSpace(topicID)
6347 if topicID == "" || createdAt <= 0 {
6348 delete(created, topicID)
6349 } else {
6350 created[topicID] = createdAt
6351 }
6352 return saveTopicCreatedAts(workspaceRoot, created)
6353 }
6354
6355 func deleteTopicCreatedAt(workspaceRoot, topicID string) error {
6356 created, err := loadTopicCreatedAtsForUpdate(workspaceRoot)
6357 if err != nil {
6358 return err
6359 }
6360 if _, ok := created[topicID]; !ok {
6361 return nil
6362 }
6363 delete(created, topicID)
6364 return saveTopicCreatedAts(workspaceRoot, created)
6365 }
6366
6367 // topicIndexMu serializes recovery writes to desktop-projects.json and topic
6368 // title indexes. Startup builds restored tabs concurrently, and each tab may
6369 // repair its missing index.
6370 var topicIndexMu sync.Mutex
6371
6372 func ensureTopicIndexed(scope, workspaceRoot, topicID, title, source string) error {
6373 topicID = strings.TrimSpace(topicID)
6374 if topicID == "" {
6375 return fmt.Errorf("topicID is required")
6376 }
6377 topicIndexMu.Lock()
6378 defer topicIndexMu.Unlock()
6379 if strings.TrimSpace(scope) == "global" {
6380 workspaceRoot = ""
6381 } else {
6382 workspaceRoot = normalizeProjectRoot(workspaceRoot)
6383 }
6384 title = strings.TrimSpace(title)
6385 if title == "" {
6386 title = defaultTopicTitle
6387 }
6388 source = strings.TrimSpace(source)
6389 if source == "" {
6390 source = topicTitleSourceManual
6391 }
6392 if err := setTopicTitleWithSource(workspaceRoot, topicID, title, source); err != nil {
6393 return err
6394 }
6395 return prependTopicInProjectsFile(workspaceRoot, topicID, true)
6396 }
6397
6398 // --- telemetry --------------------------------------------------------------
6399
6400 func saveTelemetry(path string, snapshot tabTelemetrySnapshot) error {
6401 if snapshot.Version == 0 {
6402 snapshot.Version = 2
6403 }
6404 if snapshot.ReadFiles == nil {
6405 snapshot.ReadFiles = []readFileRecord{}
6406 }
6407 b, err := json.MarshalIndent(snapshot, "", " ")
6408 if err != nil {
6409 return err
6410 }
6411 tmp := path + ".tmp"
6412 if err := os.WriteFile(tmp, b, 0o644); err != nil {
6413 return err
6414 }
6415 return fileutil.ReplaceFile(tmp, path)
6416 }
6417
6418 func loadTelemetry(path string) tabTelemetrySnapshot {
6419 b, err := readFileUTF8(path)
6420 if err != nil {
6421 return tabTelemetrySnapshot{Version: 2, ReadFiles: []readFileRecord{}}
6422 }
6423 var snapshot tabTelemetrySnapshot
6424 if err := json.Unmarshal(b, &snapshot); err == nil && (snapshot.Version > 0 || snapshot.ReadFiles != nil) {
6425 if snapshot.ReadFiles == nil {
6426 snapshot.ReadFiles = []readFileRecord{}
6427 }
6428 if snapshot.Usage.SessionCost == 0 && snapshot.Usage.SessionCostUsd > 0 {
6429 snapshot.Usage.SessionCost = snapshot.Usage.SessionCostUsd
6430 }
6431 return snapshot
6432 }
6433 var records []readFileRecord
6434 if err := json.Unmarshal(b, &records); err != nil || records == nil {
6435 records = []readFileRecord{}
6436 }
6437 return tabTelemetrySnapshot{Version: 1, ReadFiles: records}
6438 }
6439
6440 // --- project tree -----------------------------------------------------------
6441
6442 // ProjectNode is one node in the sidebar project tree (a project folder or a
6443 // topic leaf).
6444 type ProjectNode struct {
6445 Key string `json:"key"` // stable key for React
6446 Kind string `json:"kind"` // "project" | "topic" | "session" | "global_folder" | "global_topic" | "global_session"
6447 Label string `json:"label"`
6448 Root string `json:"root,omitempty"` // project workspace root
6449 TopicID string `json:"topicId,omitempty"`
6450 SessionPath string `json:"sessionPath,omitempty"`
6451 ProjectColor string `json:"projectColor,omitempty"`
6452 Turns int `json:"turns,omitempty"`
6453 CreatedAt int64 `json:"createdAt,omitempty"`
6454 LastActivityAt int64 `json:"lastActivityAt,omitempty"`
6455 Open bool `json:"open,omitempty"`
6456 Running bool `json:"running,omitempty"`
6457 Status string `json:"status,omitempty"`
6458 Pinned bool `json:"pinned,omitempty"`
6459 Recovered bool `json:"recovered,omitempty"`
6460 RecoveryReason string `json:"recoveryReason,omitempty"`
6461 RecoveryDigest string `json:"recoveryDigest,omitempty"`
6462 RecoveryParentID string `json:"recoveryParentId,omitempty"`
6463 IsolatedWorktree bool `json:"isolatedWorktree,omitempty"`
6464 Children []ProjectNode `json:"children,omitempty"`
6465 }
6466
6467 func normalizeTopicStatus(status string) string {
6468 switch status {
6469 case topicStatusThinking, topicStatusStreaming, topicStatusWaitingConfirmation, topicStatusBackgroundJob, topicStatusPaused, topicStatusError:
6470 return status
6471 default:
6472 return ""
6473 }
6474 }
6475
6476 func activityStatusForTab(tab *WorkspaceTab) string {
6477 if tab == nil {
6478 return ""
6479 }
6480 status := normalizeTopicStatus(tab.ActivityStatus)
6481 if tab.Ctrl == nil {
6482 return status
6483 }
6484 runtimeStatus := tab.Ctrl.RuntimeStatus()
6485 if runtimeStatus.PendingPrompt {
6486 return topicStatusWaitingConfirmation
6487 }
6488 if runtimeStatus.Running {
6489 if status == "" || status == topicStatusError || status == topicStatusPaused {
6490 return topicStatusThinking
6491 }
6492 return status
6493 }
6494 if runtimeStatus.BackgroundJobs > 0 {
6495 return topicStatusBackgroundJob
6496 }
6497 if status == topicStatusError || status == topicStatusPaused {
6498 return status
6499 }
6500 return ""
6501 }
6502
6503 // migrateLegacySessionsIntoGlobalTopics makes pre-topic desktop history visible
6504 // in the v2 sidebar. Imported v0.x sessions and older desktop sessions are plain
6505 // .jsonl files, sometimes with branch metadata but no topic metadata; the
6506 // history panel can list them, but the project tree cannot. Give each such
6507 // session a deterministic Global topic so every old conversation has a direct
6508 // sidebar entry without guessing a project workspace.
6509 // legacyMigrationMu serializes the lockless load-modify-save of the projects /
6510 // topic-title files: this migration runs from every concurrent buildTabController
6511 // and from ListProjectTree, so without it parallel runs lose each other's appends.
6512 var legacyMigrationMu sync.Mutex
6513
6514 // topicMigrationMarker, once written into a session dir, records that the
6515 // pre-topic → Global-topic migration pass completed for that dir. Later
6516 // ListProjectTree calls can skip the full session decode while the marker is
6517 // newer than the directory's session files, but a newly-created CLI session
6518 // invalidates the marker and gets a bounded re-scan.
6519 // It is stamped only when the pass left nothing deferred (an empty legacy
6520 // session that could gain content later keeps the dir unmarked), so the gate
6521 // never hides a session that should still be migrated.
6522 // v2 re-evaluates recovery-named sessions that v1 skipped solely by filename.
6523 // The old marker cannot safely suppress this one-time data-preservation pass.
6524 const topicMigrationMarker = ".topics-migrated-v2"
6525 const topicIndexRepairMarker = ".topic-indexes-repaired-v2"
6526
6527 func invalidateTopicDirMarkers(dir string) error {
6528 dir = strings.TrimSpace(dir)
6529 if dir == "" {
6530 return nil
6531 }
6532 var errs []error
6533 for _, marker := range []string{topicMigrationMarker, topicIndexRepairMarker} {
6534 if err := os.Remove(filepath.Join(dir, marker)); err != nil && !os.IsNotExist(err) {
6535 errs = append(errs, err)
6536 }
6537 }
6538 return errors.Join(errs...)
6539 }
6540
6541 func topicDirMarkerDone(dir, marker string) bool {
6542 dir = strings.TrimSpace(dir)
6543 marker = strings.TrimSpace(marker)
6544 if dir == "" || marker == "" {
6545 return false
6546 }
6547 markerInfo, err := os.Stat(filepath.Join(dir, marker))
6548 if err != nil {
6549 return false
6550 }
6551 entries, err := os.ReadDir(dir)
6552 if err != nil {
6553 return true
6554 }
6555 markerTime := markerInfo.ModTime()
6556 for _, entry := range entries {
6557 if entry.IsDir() {
6558 continue
6559 }
6560 name := entry.Name()
6561 if !store.IsSessionTranscriptName(name) && !strings.HasSuffix(name, ".jsonl.meta") {
6562 continue
6563 }
6564 info, err := entry.Info()
6565 if err != nil {
6566 return false
6567 }
6568 if info.ModTime().After(markerTime) {
6569 return false
6570 }
6571 }
6572 return true
6573 }
6574
6575 func topicMigrationDone(dir string) bool {
6576 return topicDirMarkerDone(dir, topicMigrationMarker)
6577 }
6578
6579 func topicIndexRepairDone(dir string) bool {
6580 return topicDirMarkerDone(dir, topicIndexRepairMarker)
6581 }
6582
6583 func markTopicDirMarkerDone(dir, marker string) {
6584 dir = strings.TrimSpace(dir)
6585 marker = strings.TrimSpace(marker)
6586 if dir == "" || marker == "" {
6587 return
6588 }
6589 if err := os.MkdirAll(dir, 0o755); err != nil {
6590 return
6591 }
6592 _ = os.WriteFile(filepath.Join(dir, marker), nil, 0o644)
6593 }
6594
6595 func markTopicMigrationDone(dir string) {
6596 markTopicDirMarkerDone(dir, topicMigrationMarker)
6597 }
6598
6599 func markTopicIndexRepairDone(dir string) {
6600 markTopicDirMarkerDone(dir, topicIndexRepairMarker)
6601 }
6602
6603 func migrateLegacySessionsIntoGlobalTopics(dir string) []string {
6604 if strings.TrimSpace(dir) == "" {
6605 return nil
6606 }
6607 repairedTopicIDs := repairIndexedSessionTopics(dir)
6608 // One-shot per dir: once the migration pass has completed, skip the full
6609 // per-render session scan entirely.
6610 if topicMigrationDone(dir) {
6611 return repairedTopicIDs
6612 }
6613 scope, workspaceRoot, topicTitleRoot, ok := legacyMigrationTargetForDir(dir)
6614 if !ok {
6615 return nil
6616 }
6617 legacyMigrationMu.Lock()
6618 defer legacyMigrationMu.Unlock()
6619 // Re-check under the lock: another render may have completed the pass while
6620 // this one waited.
6621 if topicMigrationDone(dir) {
6622 return nil
6623 }
6624 infos, err := agent.ListSessionOrder(dir)
6625 if err != nil {
6626 return nil // transient read error — retry on the next render, leave unmarked
6627 }
6628
6629 var migratedTopicIDs []string
6630 var titles map[string]string
6631 var topicTitles map[string]string
6632 var topicSources map[string]string
6633 // deferred stays false only when every session was either migrated or is
6634 // permanently non-migratable. A transient skip (unreadable meta, empty
6635 // session that may gain content, failed write) sets it, keeping the dir
6636 // unmarked so the next render retries instead of the gate hiding it forever.
6637 deferred := false
6638 for _, info := range infos {
6639 if sessionOrderInfoIsUnmodifiedRecoveryCopy(info, dir) {
6640 continue
6641 }
6642 if strings.TrimSpace(info.TopicID) != "" {
6643 continue
6644 }
6645 if meta, ok, err := agent.LoadBranchMeta(info.Path); err != nil {
6646 deferred = true
6647 continue
6648 } else if ok && !legacySessionMetaMatchesMigrationTarget(meta, scope, workspaceRoot) {
6649 continue
6650 }
6651 topicID := legacySessionTopicID(info.Path)
6652 if topicID == "" {
6653 continue
6654 }
6655 preview, turns := agent.SessionPreview(info.Path)
6656 if turns == 0 {
6657 deferred = true // empty now, but a later turn could make it migratable
6658 continue
6659 }
6660 if titles == nil {
6661 titles = loadSessionTitles(dir)
6662 }
6663 title := strings.TrimSpace(titles[filepath.Base(info.Path)])
6664 if title == "" {
6665 title = topicTitleFromText(preview)
6666 } else if normalized := topicTitleFromText(title); normalized != "" {
6667 title = normalized
6668 }
6669 if title == "" {
6670 when := info.LastActivityAt
6671 if when.IsZero() {
6672 when = info.ModTime
6673 }
6674 if when.IsZero() {
6675 title = "历史会话"
6676 } else {
6677 title = "历史会话 " + when.Local().Format("2006-01-02")
6678 }
6679 }
6680
6681 migrated, err := func() (bool, error) {
6682 // Read-modify-write on the branch-meta sidecar: hold the per-path
6683 // meta lock so agent-side writers (autosave revision bumps,
6684 // in-flight markers) can't interleave between the load and save
6685 // below and lose their fields.
6686 unlock := agent.LockSessionMetaPath(info.Path)
6687 defer unlock()
6688 meta, err := agent.EnsureBranchMeta(info.Path)
6689 if err != nil {
6690 return false, err
6691 }
6692 // Preserve scoped sessions only when their existing ownership matches
6693 // the directory being migrated.
6694 if !legacySessionMetaMatchesMigrationTarget(meta, scope, workspaceRoot) {
6695 return false, nil
6696 }
6697 meta.Scope = scope
6698 meta.WorkspaceRoot = workspaceRoot
6699 meta.TopicID = topicID
6700 meta.TopicTitle = title
6701 return true, agent.SaveBranchMetaPreserveUpdated(info.Path, meta)
6702 }()
6703 if err != nil {
6704 deferred = true
6705 continue
6706 }
6707 if !migrated {
6708 continue
6709 }
6710 if topicTitles == nil {
6711 topicTitles = loadTopicTitles(topicTitleRoot)
6712 }
6713 if topicSources == nil {
6714 topicSources = loadTopicTitleSources(topicTitleRoot)
6715 }
6716 if strings.TrimSpace(topicTitles[topicID]) == "" {
6717 topicTitles[topicID] = title
6718 topicSources[topicID] = topicTitleSourceManual
6719 }
6720 migratedTopicIDs = append(migratedTopicIDs, topicID)
6721 }
6722 if len(migratedTopicIDs) == 0 {
6723 if !deferred {
6724 markTopicMigrationDone(dir) // nothing left to migrate — gate future scans
6725 }
6726 return repairedTopicIDs
6727 }
6728 _ = prependTopicsInProjectsFile(workspaceRoot, migratedTopicIDs, false)
6729 // Same fresh tombstone re-check as the repair pass: these are whole-map
6730 // saves, so a concurrent DeleteTopic of an unrelated topic must not have
6731 // its title written back by this migration batch.
6732 pruneDeletedTopicEntries(topicTitles, topicSources)
6733 if topicTitles != nil {
6734 _ = saveTopicTitles(topicTitleRoot, topicTitles)
6735 }
6736 if topicSources != nil {
6737 _ = saveTopicTitleSources(topicTitleRoot, topicSources)
6738 }
6739 invalidateTopicSessionIndex(dir)
6740 projectSessionCache.invalidateDirs(dir)
6741 if !deferred {
6742 markTopicMigrationDone(dir) // pass complete with nothing deferred
6743 }
6744 return uniqueStrings(append(repairedTopicIDs, migratedTopicIDs...))
6745 }
6746
6747 // pruneDeletedTopicEntries drops tombstoned topics from scan-built title and
6748 // source maps just before they are persisted, re-reading DeletedTopics so a
6749 // DeleteTopic that landed after the scan snapshot wins. The maps are loaded
6750 // whole at scan start and saved whole at the end; without this re-check the
6751 // save would write the deleted topic's stale entries back, and a title-map
6752 // entry alone resurrects a topic in the sidebar (orderedTopicIDs lists topics
6753 // that exist only in the title map). Returns the tombstone list so callers can
6754 // filter their returned topic-ID slices against the same snapshot.
6755 func pruneDeletedTopicEntries(maps ...map[string]string) []string {
6756 deleted := loadProjectsFile().DeletedTopics
6757 if len(deleted) == 0 {
6758 return nil
6759 }
6760 for _, m := range maps {
6761 for _, id := range deleted {
6762 delete(m, id)
6763 }
6764 }
6765 return deleted
6766 }
6767
6768 func repairIndexedSessionTopics(dir string) []string {
6769 if strings.TrimSpace(dir) == "" || topicIndexRepairDone(dir) {
6770 return nil
6771 }
6772 scope, workspaceRoot, topicTitleRoot, ok := legacyMigrationTargetForDir(dir)
6773 if !ok {
6774 return nil
6775 }
6776 legacyMigrationMu.Lock()
6777 defer legacyMigrationMu.Unlock()
6778 if topicIndexRepairDone(dir) {
6779 return nil
6780 }
6781 infos, err := agent.ListSessionOrder(dir)
6782 if err != nil {
6783 return nil
6784 }
6785
6786 topicTitles, err := loadTopicTitlesForUpdate(topicTitleRoot)
6787 if err != nil {
6788 return nil
6789 }
6790 topicSources, err := loadTopicTitleSourcesForUpdate(topicTitleRoot)
6791 if err != nil {
6792 return nil
6793 }
6794 projects := loadProjectsFile()
6795 deletedTopics := projects.DeletedTopics
6796 // Repair only topics missing from the sidebar index. Skipping topics that
6797 // are already listed and titled keeps steady-state rescans write-free:
6798 // otherwise every rescan (any session activity invalidates the marker)
6799 // would prepend the full topic list back in most-recently-active order,
6800 // reordering the user's sidebar and handing already-visible topics to the
6801 // blank-tab binding in the migration callers.
6802 indexedTopics := projects.GlobalTopics
6803 if scope == "project" {
6804 indexedTopics = nil
6805 if i := projectIndexByRoot(projects.Projects, workspaceRoot); i >= 0 {
6806 indexedTopics = projects.Projects[i].Topics
6807 }
6808 }
6809 indexedSet := make(map[string]bool, len(indexedTopics))
6810 for _, id := range indexedTopics {
6811 indexedSet[id] = true
6812 }
6813 var repairedTopicIDs []string
6814 var sessionTitles map[string]string
6815 titlesChanged := false
6816 sourcesChanged := false
6817 deferred := false
6818 for _, info := range infos {
6819 if sessionOrderInfoIsUnmodifiedRecoveryCopy(info, dir) {
6820 continue
6821 }
6822 topicID := strings.TrimSpace(info.TopicID)
6823 if topicID == "" {
6824 continue
6825 }
6826 if indexedSet[topicID] && strings.TrimSpace(topicTitles[topicID]) != "" {
6827 continue // fully indexed already — nothing to repair, skip the meta read
6828 }
6829 meta, ok, err := agent.LoadBranchMeta(info.Path)
6830 if err != nil {
6831 deferred = true
6832 continue
6833 }
6834 if !ok || strings.TrimSpace(meta.TopicID) == "" {
6835 continue
6836 }
6837 if containsDesktopString(deletedTopics, topicID) {
6838 continue
6839 }
6840 if !legacySessionScopeMatchesMigrationTarget(meta, scope, workspaceRoot) {
6841 continue
6842 }
6843 repairedTopicIDs = append(repairedTopicIDs, topicID)
6844 if strings.TrimSpace(topicTitles[topicID]) == "" {
6845 if sessionTitles == nil {
6846 sessionTitles = loadSessionTitles(dir)
6847 }
6848 title := indexedSessionTopicTitle(sessionTitles, info, meta)
6849 if title == "" {
6850 title = defaultTopicTitle
6851 }
6852 topicTitles[topicID] = title
6853 titlesChanged = true
6854 }
6855 if strings.TrimSpace(topicSources[topicID]) == "" {
6856 topicSources[topicID] = topicTitleSourceManual
6857 sourcesChanged = true
6858 }
6859 }
6860 if len(repairedTopicIDs) > 0 {
6861 // Re-check tombstones right before persisting: a DeleteTopic landing
6862 // after the scan snapshot must win. The prepend re-filters under the
6863 // projects-file lock; the whole-map title/source saves and the
6864 // returned IDs (callers bind blank Global tabs to the first entry)
6865 // need the same fresh read.
6866 if deletedNow := pruneDeletedTopicEntries(topicTitles, topicSources); len(deletedNow) > 0 {
6867 deletedSet := make(map[string]bool, len(deletedNow))
6868 for _, id := range deletedNow {
6869 deletedSet[id] = true
6870 }
6871 live := repairedTopicIDs[:0]
6872 for _, id := range repairedTopicIDs {
6873 if !deletedSet[id] {
6874 live = append(live, id)
6875 }
6876 }
6877 repairedTopicIDs = live
6878 }
6879 }
6880 if len(repairedTopicIDs) > 0 {
6881 if err := prependTopicsInProjectsFile(workspaceRoot, repairedTopicIDs, false); err != nil {
6882 deferred = true
6883 }
6884 if titlesChanged {
6885 if err := saveTopicTitles(topicTitleRoot, topicTitles); err != nil {
6886 deferred = true
6887 }
6888 }
6889 if sourcesChanged {
6890 if err := saveTopicTitleSources(topicTitleRoot, topicSources); err != nil {
6891 deferred = true
6892 }
6893 }
6894 if !deferred {
6895 projectSessionCache.invalidateDirs(dir)
6896 }
6897 }
6898 if !deferred {
6899 markTopicIndexRepairDone(dir)
6900 return uniqueStrings(repairedTopicIDs)
6901 }
6902 return nil
6903 }
6904
6905 func indexedSessionTopicTitle(sessionTitles map[string]string, info agent.SessionOrderInfo, meta agent.BranchMeta) string {
6906 if title := topicTitleFromText(meta.TopicTitle); title != "" {
6907 return title
6908 }
6909 if title := topicTitleFromText(info.TopicTitle); title != "" {
6910 return title
6911 }
6912 if title := topicTitleFromText(sessionTitles[filepath.Base(info.Path)]); title != "" {
6913 return title
6914 }
6915 return topicTitleFromText(info.Preview)
6916 }
6917
6918 func sessionOrderInfoIsAutomaticRecovery(info agent.SessionOrderInfo) bool {
6919 return info.Recovered ||
6920 strings.TrimSpace(info.RecoveryDigest) != "" ||
6921 isAutomaticRecoverySessionPath(info.Path)
6922 }
6923
6924 func sessionInfoIsAutomaticRecovery(info agent.SessionInfo) bool {
6925 return info.Recovered ||
6926 strings.TrimSpace(info.RecoveryDigest) != "" ||
6927 isAutomaticRecoverySessionPath(info.Path)
6928 }
6929
6930 func sessionOrderInfoIsUnmodifiedRecoveryCopy(info agent.SessionOrderInfo, parentDir string) bool {
6931 return sessionOrderInfoIsAutomaticRecovery(info) &&
6932 agent.RecoveryBranchCoveredByParent(info.Path, parentDir)
6933 }
6934
6935 func sessionInfoIsUnmodifiedRecoveryCopy(info agent.SessionInfo, parentDir string) bool {
6936 return sessionInfoIsAutomaticRecovery(info) &&
6937 agent.RecoveryBranchCoveredByParent(info.Path, parentDir)
6938 }
6939
6940 func isAutomaticRecoverySessionPath(path string) bool {
6941 id := agent.BranchID(path)
6942 idx := strings.LastIndex(id, "-recovery-")
6943 if idx <= 0 {
6944 return false
6945 }
6946 suffix := id[idx+len("-recovery-"):]
6947 if len(suffix) != 16 {
6948 return false
6949 }
6950 for _, r := range suffix {
6951 if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') {
6952 continue
6953 }
6954 return false
6955 }
6956 return true
6957 }
6958
6959 func legacyMigrationTargetForDir(dir string) (scope, workspaceRoot, topicTitleRoot string, ok bool) {
6960 dir = cleanDesktopPath(dir)
6961 if dir == "" {
6962 return "", "", "", false
6963 }
6964 if sameDesktopPath(dir, config.SessionDir()) || sameDesktopPath(dir, desktopSessionDir(globalWorkspaceRoot())) {
6965 return "global", "", "", true
6966 }
6967 for _, p := range loadProjectsFile().Projects {
6968 if sameDesktopPath(config.ProjectSessionDir(p.Root), dir) {
6969 return "project", p.Root, p.Root, true
6970 }
6971 }
6972 return "", "", "", false
6973 }
6974
6975 func legacySessionMetaMatchesMigrationTarget(meta agent.BranchMeta, scope, workspaceRoot string) bool {
6976 if strings.TrimSpace(meta.TopicID) != "" {
6977 return false
6978 }
6979 return legacySessionScopeMatchesMigrationTarget(meta, scope, workspaceRoot)
6980 }
6981
6982 func legacySessionScopeMatchesMigrationTarget(meta agent.BranchMeta, scope, workspaceRoot string) bool {
6983 metaScope := strings.TrimSpace(meta.Scope)
6984 if metaScope != "" && metaScope != scope {
6985 return false
6986 }
6987 metaRoot := normalizeProjectRoot(meta.WorkspaceRoot)
6988 if scope == "project" {
6989 return metaRoot == "" || sameProjectRoot(workspaceRoot, metaRoot)
6990 }
6991 return metaRoot == "" || sameProjectRoot(globalWorkspaceRoot(), metaRoot)
6992 }
6993
6994 func cleanDesktopPath(path string) string {
6995 path = strings.TrimSpace(path)
6996 if path == "" {
6997 return ""
6998 }
6999 if abs, err := filepath.Abs(path); err == nil {
7000 path = abs
7001 }
7002 return filepath.Clean(path)
7003 }
7004
7005 func sameDesktopPath(a, b string) bool {
7006 a = cleanDesktopPath(a)
7007 b = cleanDesktopPath(b)
7008 if a == "" || b == "" {
7009 return false
7010 }
7011 if os.PathSeparator == '\\' {
7012 return strings.EqualFold(a, b)
7013 }
7014 return a == b
7015 }
7016
7017 // projectRootKey is the map-key form of a project root: cleaned, absolute,
7018 // and case-folded on Windows — the same key form agent.CanonicalSessionPath
7019 // uses for session paths, so equivalent spellings never split lookups.
7020 func projectRootKey(root string) string {
7021 root = cleanDesktopPath(root)
7022 if os.PathSeparator == '\\' {
7023 return strings.ToLower(root)
7024 }
7025 return root
7026 }
7027
7028 func restoreSessionTopicIndex(dir, sessionPath string) error {
7029 sessionPath = strings.TrimSpace(sessionPath)
7030 if sessionPath == "" {
7031 return nil
7032 }
7033 meta, ok, err := agent.LoadBranchMeta(sessionPath)
7034 if err != nil {
7035 return err
7036 }
7037 if !ok || strings.TrimSpace(meta.TopicID) == "" {
7038 // The migration pass takes per-session meta locks itself, so it must
7039 // run outside the lock taken below.
7040 migrateLegacySessionsIntoGlobalTopics(dir)
7041 return nil
7042 }
7043
7044 // Read-modify-write on the branch-meta sidecar: re-read and save under the
7045 // per-path meta lock so a concurrent save's revision bump can't land in
7046 // between and get rolled back by the write at the end.
7047 unlock := agent.LockSessionMetaPath(sessionPath)
7048 defer unlock()
7049 meta, ok, err = agent.LoadBranchMeta(sessionPath)
7050 if err != nil {
7051 return err
7052 }
7053 if !ok || strings.TrimSpace(meta.TopicID) == "" {
7054 return nil
7055 }
7056
7057 topicID := strings.TrimSpace(meta.TopicID)
7058 scope := strings.TrimSpace(meta.Scope)
7059 workspaceRoot := strings.TrimSpace(meta.WorkspaceRoot)
7060 if scope != "global" && scope != "project" {
7061 if workspaceRoot == "" {
7062 scope = "global"
7063 } else {
7064 scope = "project"
7065 }
7066 }
7067 if scope == "global" {
7068 workspaceRoot = ""
7069 } else {
7070 workspaceRoot = normalizeProjectRoot(workspaceRoot)
7071 if workspaceRoot == "" {
7072 scope = "global"
7073 }
7074 }
7075
7076 title := restoredSessionTopicTitle(dir, sessionPath, meta)
7077 if title == "" {
7078 title = defaultTopicTitle
7079 }
7080 if err := setTopicTitleWithSource(workspaceRoot, topicID, title, topicTitleSourceManual); err != nil {
7081 return err
7082 }
7083
7084 if scope == "global" {
7085 meta.Scope = "global"
7086 meta.WorkspaceRoot = ""
7087 } else {
7088 meta.Scope = "project"
7089 meta.WorkspaceRoot = workspaceRoot
7090 }
7091 meta.TopicID = topicID
7092 meta.TopicTitle = title
7093 if err := prependTopicInProjectsFile(workspaceRoot, topicID, scope == "project"); err != nil {
7094 return err
7095 }
7096 if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, meta); err != nil {
7097 return err
7098 }
7099 invalidateTopicSessionIndexForPath(sessionPath)
7100 return nil
7101 }
7102
7103 func restoredSessionTopicTitle(dir, sessionPath string, meta agent.BranchMeta) string {
7104 if title := storedSessionTopicTitle(dir, sessionPath, meta); title != "" {
7105 return title
7106 }
7107 if s, err := agent.LoadSession(sessionPath); err == nil {
7108 for _, msg := range s.Messages {
7109 if msg.Role == provider.RoleUser {
7110 if title := topicTitleFromText(agent.UserMessageText(msg)); title != "" {
7111 return title
7112 }
7113 }
7114 }
7115 }
7116 return ""
7117 }
7118
7119 func storedSessionTopicTitle(dir, sessionPath string, meta agent.BranchMeta) string {
7120 if title := topicTitleFromText(meta.TopicTitle); title != "" {
7121 return title
7122 }
7123 return topicTitleFromText(loadSessionTitles(dir)[filepath.Base(sessionPath)])
7124 }
7125
7126 func legacySessionTopicID(path string) string {
7127 id := agent.BranchID(path)
7128 id = strings.TrimSpace(id)
7129 if id == "" {
7130 return ""
7131 }
7132 sum := sha256.Sum256([]byte(id))
7133 var b strings.Builder
7134 b.WriteString("legacy_")
7135 for _, r := range id {
7136 switch {
7137 case unicode.IsLetter(r), unicode.IsDigit(r):
7138 b.WriteRune(r)
7139 case r == '-', r == '_':
7140 b.WriteRune(r)
7141 default:
7142 b.WriteByte('_')
7143 }
7144 }
7145 prefix := strings.TrimRight(b.String(), "_")
7146 if prefix == "legacy" {
7147 prefix = "legacy_session"
7148 }
7149 return prefix + "_" + hex.EncodeToString(sum[:])[:12]
7150 }
7151
7152 // TopicMeta describes a topic for the project tree.
7153 type TopicMeta struct {
7154 ID string `json:"id"`
7155 Title string `json:"title"`
7156 CreatedAt int64 `json:"createdAt"`
7157 }
7158
7159 // CreateTopic creates a new topic under a project workspace and returns its metadata.
7160 func (a *App) CreateTopic(scope, workspaceRoot, title string) (TopicMeta, error) {
7161 trimmedTitle := strings.TrimSpace(title)
7162 titleSource := topicTitleSourceManual
7163 if trimmedTitle == "" {
7164 trimmedTitle = defaultTopicTitle
7165 titleSource = topicTitleSourceAuto
7166 }
7167 topicID := newTopicID()
7168 createdAt := time.Now().UnixMilli()
7169 if scope == "global" {
7170 workspaceRoot = ""
7171 }
7172 if workspaceRoot != "" {
7173 if abs, err := filepath.Abs(workspaceRoot); err == nil {
7174 workspaceRoot = abs
7175 }
7176 }
7177 if err := setTopicTitleWithSource(workspaceRoot, topicID, trimmedTitle, titleSource); err != nil {
7178 return TopicMeta{}, err
7179 }
7180 if err := setTopicCreatedAt(workspaceRoot, topicID, createdAt); err != nil {
7181 return TopicMeta{}, err
7182 }
7183 // New topics should appear first in their project/global group so the item
7184 // just created is immediately visible and selected in the sidebar.
7185 _ = prependTopicInProjectsFile(workspaceRoot, topicID, workspaceRoot != "")
7186 a.emitProjectTreeMetadataChanged()
7187 return TopicMeta{ID: topicID, Title: a.localizedTopicTitle(trimmedTitle, titleSource), CreatedAt: createdAt}, nil
7188 }
7189
7190 // RenameProject updates the sidebar-only display title for a project folder.
7191 // Empty title clears the override and falls back to the folder name.
7192 func (a *App) RenameProject(workspaceRoot, title string) error {
7193 if err := renameProject(workspaceRoot, title); err != nil {
7194 return err
7195 }
7196 a.syncTabWorkspaceRootSpellings()
7197 a.emitProjectTreeMetadataChanged()
7198 return nil
7199 }
7200
7201 // SetProjectColor updates the project-level accent color used by project topics
7202 // in the sidebar and tabs. Empty color restores the default accent.
7203 func (a *App) SetProjectColor(workspaceRoot, color string) error {
7204 if err := setProjectColor(workspaceRoot, color); err != nil {
7205 return err
7206 }
7207 a.syncTabWorkspaceRootSpellings()
7208 a.emitProjectTreeMetadataChanged()
7209 return nil
7210 }
7211
7212 // SetProjectPinned controls whether a project folder is pinned above the rest of
7213 // the desktop project tree.
7214 func (a *App) SetProjectPinned(workspaceRoot string, pinned bool) error {
7215 root := normalizeProjectRoot(workspaceRoot)
7216 if root == "" {
7217 return fmt.Errorf("workspaceRoot is required")
7218 }
7219 if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
7220 i := projectIndexByRoot(f.Projects, root)
7221 if i < 0 {
7222 return false, fmt.Errorf("project %q not found", root)
7223 }
7224 root = f.Projects[i].Root
7225 next := make([]string, 0, len(f.PinnedProjects))
7226 for _, pinnedRoot := range f.PinnedProjects {
7227 if !sameProjectRoot(pinnedRoot, root) {
7228 next = append(next, pinnedRoot)
7229 }
7230 }
7231 if pinned {
7232 next = prependUniqueString(next, root)
7233 }
7234 if sameStringList(next, f.PinnedProjects) {
7235 return false, nil
7236 }
7237 f.PinnedProjects = next
7238 return true, nil
7239 }); err != nil {
7240 return err
7241 }
7242 a.emitProjectTreeMetadataChanged()
7243 return nil
7244 }
7245
7246 // ReorderProjects persists the user-defined order of project folders and,
7247 // when present, the virtual Global sidebar section.
7248 func (a *App) ReorderProjects(workspaceRoots []string) error {
7249 if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
7250 var seenProjects []string
7251 next := make([]desktopProject, 0, len(workspaceRoots))
7252 sidebarOrder := make([]string, 0, len(workspaceRoots))
7253 hasGlobalOrder := false
7254 for _, root := range workspaceRoots {
7255 root = strings.TrimSpace(root)
7256 if root == desktopGlobalOrderToken {
7257 if hasGlobalOrder {
7258 return false, fmt.Errorf("duplicate global section")
7259 }
7260 hasGlobalOrder = true
7261 sidebarOrder = append(sidebarOrder, root)
7262 continue
7263 }
7264 root = normalizeProjectRoot(root)
7265 i := projectIndexByRoot(f.Projects, root)
7266 if i < 0 {
7267 return false, fmt.Errorf("project %q not found", root)
7268 }
7269 project := f.Projects[i]
7270 if projectRootInList(seenProjects, project.Root) {
7271 return false, fmt.Errorf("duplicate project %q", root)
7272 }
7273 seenProjects = append(seenProjects, project.Root)
7274 next = append(next, project)
7275 sidebarOrder = append(sidebarOrder, project.Root)
7276 }
7277 if len(next) != len(f.Projects) {
7278 return false, fmt.Errorf("project order length mismatch")
7279 }
7280 changed := !sameProjectOrder(next, f.Projects)
7281 f.Projects = next
7282 if hasGlobalOrder {
7283 if !sameStringList(sidebarOrder, f.SidebarOrder) {
7284 changed = true
7285 }
7286 f.SidebarOrder = sidebarOrder
7287 } else {
7288 if len(f.SidebarOrder) > 0 {
7289 changed = true
7290 }
7291 f.SidebarOrder = nil
7292 }
7293 return changed, nil
7294 }); err != nil {
7295 return err
7296 }
7297 a.emitProjectTreeMetadataChanged()
7298 return nil
7299 }
7300
7301 // RenameTopic updates a topic's display title.
7302 func (a *App) RenameTopic(topicID, title string) error {
7303 trimmed := strings.TrimSpace(title)
7304 if trimmed == "" {
7305 trimmed = defaultTopicTitle
7306 }
7307 // Find which workspace this topic belongs to by scanning all project topic titles.
7308 f := loadProjectsFile()
7309 for _, p := range f.Projects {
7310 m := loadTopicTitles(p.Root)
7311 if _, ok := m[topicID]; ok {
7312 if err := setTopicTitle(p.Root, topicID, trimmed); err != nil {
7313 return err
7314 }
7315 a.updateOpenTopicTitle(topicID, trimmed, topicTitleSourceManual)
7316 changedDirs := a.updateTopicSessionTitles(topicID, trimmed)
7317 if len(changedDirs) > 0 {
7318 a.emitProjectTreeChangedForSessionDirs(changedDirs...)
7319 } else {
7320 a.emitProjectTreeMetadataChanged()
7321 }
7322 return nil
7323 }
7324 }
7325 // Check global.
7326 m := loadTopicTitles("")
7327 if _, ok := m[topicID]; ok {
7328 if err := setTopicTitle("", topicID, trimmed); err != nil {
7329 return err
7330 }
7331 a.updateOpenTopicTitle(topicID, trimmed, topicTitleSourceManual)
7332 changedDirs := a.updateTopicSessionTitles(topicID, trimmed)
7333 if len(changedDirs) > 0 {
7334 a.emitProjectTreeChangedForSessionDirs(changedDirs...)
7335 } else {
7336 a.emitProjectTreeMetadataChanged()
7337 }
7338 return nil
7339 }
7340 if scope, workspaceRoot, ok := a.findTopicLocation(topicID); ok {
7341 if err := ensureTopicIndexed(scope, workspaceRoot, topicID, trimmed, topicTitleSourceManual); err != nil {
7342 return err
7343 }
7344 a.updateOpenTopicTitle(topicID, trimmed, topicTitleSourceManual)
7345 changedDirs := a.updateTopicSessionTitles(topicID, trimmed)
7346 if len(changedDirs) > 0 {
7347 a.emitProjectTreeChangedForSessionDirs(changedDirs...)
7348 } else {
7349 a.emitProjectTreeMetadataChanged()
7350 }
7351 return nil
7352 }
7353 return fmt.Errorf("topic %q not found", topicID)
7354 }
7355
7356 func (a *App) findTopicLocation(topicID string) (string, string, bool) {
7357 topicID = strings.TrimSpace(topicID)
7358 if topicID == "" {
7359 return "", "", false
7360 }
7361 a.mu.RLock()
7362 for _, tab := range a.tabs {
7363 if tab == nil || tab.TopicID != topicID {
7364 continue
7365 }
7366 scope := tab.Scope
7367 workspaceRoot := tab.WorkspaceRoot
7368 a.mu.RUnlock()
7369 if scope == "global" {
7370 return "global", "", true
7371 }
7372 return "project", normalizeProjectRoot(workspaceRoot), true
7373 }
7374 a.mu.RUnlock()
7375
7376 infos, err := agent.ListSessions(config.SessionDir())
7377 if err != nil {
7378 return "", "", false
7379 }
7380 for _, info := range infos {
7381 if strings.TrimSpace(info.TopicID) != topicID {
7382 continue
7383 }
7384 scope := strings.TrimSpace(info.Scope)
7385 if scope == "" {
7386 scope = "global"
7387 }
7388 if scope == "global" {
7389 return "global", "", true
7390 }
7391 return "project", normalizeProjectRoot(info.WorkspaceRoot), true
7392 }
7393 return "", "", false
7394 }
7395
7396 func (a *App) updateOpenTopicTitle(topicID, title, source string) {
7397 if strings.TrimSpace(topicID) == "" || strings.TrimSpace(title) == "" {
7398 return
7399 }
7400 a.mu.Lock()
7401 defer a.mu.Unlock()
7402 for _, tab := range a.runtimeTabsLocked() {
7403 if tab != nil && tab.TopicID == topicID {
7404 tab.TopicTitle = title
7405 tab.topicTitleSource = source
7406 }
7407 }
7408 }
7409
7410 func (a *App) updateTopicSessionTitles(topicID, title string) []string {
7411 if strings.TrimSpace(topicID) == "" || strings.TrimSpace(title) == "" {
7412 return nil
7413 }
7414 var changedDirs []string
7415 for _, dir := range a.knownSessionDirs() {
7416 changed := false
7417 for _, match := range topicSessionMatches(dir, topicID) {
7418 // Read-modify-write on the branch-meta sidecar: hold the per-path
7419 // meta lock so a concurrent save's revision bump can't land between
7420 // the load and save below and get rolled back by this write.
7421 unlock := agent.LockSessionMetaPath(match.path)
7422 meta, ok, err := agent.LoadBranchMeta(match.path)
7423 if err != nil || !ok {
7424 unlock()
7425 continue
7426 }
7427 meta.TopicTitle = title
7428 err = agent.SaveBranchMetaPreserveUpdated(match.path, meta)
7429 unlock()
7430 if err == nil {
7431 invalidateTopicSessionIndex(dir)
7432 changed = true
7433 }
7434 }
7435 if changed {
7436 changedDirs = append(changedDirs, dir)
7437 }
7438 }
7439 return changedDirs
7440 }
7441
7442 func (a *App) setTabActivityStatus(tabID, status string) bool {
7443 a.mu.Lock()
7444 defer a.mu.Unlock()
7445 tab := a.tabByEventSinkIDLocked(tabID)
7446 if tab == nil {
7447 return false
7448 }
7449 status = normalizeTopicStatus(status)
7450 if tab.ActivityStatus == status {
7451 return false
7452 }
7453 tab.ActivityStatus = status
7454 return true
7455 }
7456
7457 func (a *App) emitProjectTreeChanged() {
7458 projectSessionCache.invalidate()
7459 a.emitProjectTreeChangedEvent()
7460 }
7461
7462 // emitProjectTreeChangedForSessionDirs keeps cached listings for unrelated
7463 // workspaces. A session mutation only changes the directory containing that
7464 // transcript, so invalidating every known project turns one archive into an
7465 // O(all sessions) rescan for heavy users.
7466 func (a *App) emitProjectTreeChangedForSessionDirs(dirs ...string) {
7467 if !projectSessionCache.invalidateDirs(dirs...) {
7468 projectSessionCache.invalidate()
7469 }
7470 a.emitProjectTreeChangedEvent()
7471 }
7472
7473 // emitProjectTreeMetadataChanged refreshes ordering, titles, pins, and runtime
7474 // status without discarding session listings whose on-disk data did not move.
7475 func (a *App) emitProjectTreeMetadataChanged() {
7476 a.emitProjectTreeChangedEvent()
7477 }
7478
7479 func (a *App) emitProjectTreeChangedEvent() {
7480 if a.projectTreeChangedHook != nil {
7481 a.projectTreeChangedHook()
7482 return
7483 }
7484 a.emitRuntimeEvent("project-tree:changed")
7485 }
7486
7487 func (a *App) emitRuntimeEvent(name string, payload ...interface{}) {
7488 if a == nil || a.ctx == nil {
7489 return
7490 }
7491 a.runtimeEvents.Emit(a.ctx, name, payload...)
7492 }
7493
7494 // DeleteTopic removes a topic and its title metadata.
7495 func (a *App) DeleteTopic(topicID string) error {
7496 return friendlySessionFileError(a.deleteTopic(topicID))
7497 }
7498
7499 func (a *App) deleteTopic(topicID string) error {
7500 // Deletion converges on the fully-deleted state instead of keying the
7501 // whole cleanup on the title entry: a retry after a partial failure (or a
7502 // concurrent duplicate delete) may find the title already gone while the
7503 // sources map, created-at entry, sidebar index, or tombstone still need
7504 // cleanup, so every step checks its own leftovers.
7505 //
7506 // Detailed cleanup is limited to roots that can actually hold the topic:
7507 // roots whose sidebar index lists it, plus any root whose title map
7508 // contains it. The title probe tolerates read errors on unindexed roots
7509 // so unreadable metadata in an unrelated project cannot abort the
7510 // deletion, while roots known to hold the topic still fail hard instead
7511 // of being half-cleaned silently.
7512 f := loadProjectsFile()
7513 indexed := map[string]bool{
7514 "": containsDesktopString(f.GlobalTopics, topicID) ||
7515 containsDesktopString(f.GlobalPinnedTopics, topicID),
7516 }
7517 roots := make([]string, 0, len(f.Projects)+1)
7518 for _, p := range f.Projects {
7519 roots = append(roots, p.Root)
7520 indexed[p.Root] = containsDesktopString(p.Topics, topicID) ||
7521 containsDesktopString(p.PinnedTopics, topicID)
7522 }
7523 roots = append(roots, "")
7524 for _, root := range roots {
7525 titles, err := loadTopicTitlesForUpdate(root)
7526 if err != nil {
7527 if indexed[root] {
7528 return err
7529 }
7530 continue
7531 }
7532 _, hasTitle := titles[topicID]
7533 if !hasTitle && !indexed[root] {
7534 continue
7535 }
7536 // Fallible cleanup runs before the title entry is removed: for a
7537 // title-map-only topic the title is the only locator that makes this
7538 // root a candidate again, so it must survive a failed attempt and be
7539 // deleted last.
7540 sources, err := loadTopicTitleSourcesForUpdate(root)
7541 if err != nil {
7542 return err
7543 }
7544 if _, ok := sources[topicID]; ok {
7545 delete(sources, topicID)
7546 if err := saveTopicTitleSources(root, sources); err != nil {
7547 return err
7548 }
7549 }
7550 if err := deleteTopicCreatedAt(root, topicID); err != nil {
7551 return err
7552 }
7553 if err := deleteTopicAutoTitleMeta(root, topicID); err != nil {
7554 return err
7555 }
7556 if hasTitle {
7557 delete(titles, topicID)
7558 if err := saveTopicTitles(root, titles); err != nil {
7559 return err
7560 }
7561 }
7562 }
7563 if err := removeTopicFromProjectsFile(topicID); err != nil {
7564 return err
7565 }
7566 a.emitProjectTreeMetadataChanged()
7567 return nil
7568 }
7569
7570 // SetTopicPinned controls whether a topic is pinned to the top of its project
7571 // or Global section in the desktop project tree.
7572 func (a *App) SetTopicPinned(topicID string, pinned bool) error {
7573 topicID = strings.TrimSpace(topicID)
7574 if topicID == "" {
7575 return fmt.Errorf("topicID is required")
7576 }
7577 if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
7578 for i, p := range f.Projects {
7579 m := loadTopicTitles(p.Root)
7580 if _, ok := m[topicID]; !ok && !containsDesktopString(p.Topics, topicID) {
7581 continue
7582 }
7583 next := removeString(f.Projects[i].PinnedTopics, topicID)
7584 if pinned {
7585 next = prependUniqueString(f.Projects[i].PinnedTopics, topicID)
7586 }
7587 if sameStringList(next, f.Projects[i].PinnedTopics) {
7588 return false, nil
7589 }
7590 f.Projects[i].PinnedTopics = next
7591 return true, nil
7592 }
7593 globalTitles := loadTopicTitles("")
7594 if _, ok := globalTitles[topicID]; !ok && !containsDesktopString(f.GlobalTopics, topicID) {
7595 return false, fmt.Errorf("topic %q not found", topicID)
7596 }
7597 next := removeString(f.GlobalPinnedTopics, topicID)
7598 if pinned {
7599 next = prependUniqueString(f.GlobalPinnedTopics, topicID)
7600 }
7601 if sameStringList(next, f.GlobalPinnedTopics) {
7602 return false, nil
7603 }
7604 f.GlobalPinnedTopics = next
7605 return true, nil
7606 }); err != nil {
7607 return err
7608 }
7609 a.emitProjectTreeMetadataChanged()
7610 return nil
7611 }
7612
7613 var errTopicHasActiveWork = errors.New("wait for the session to finish, answer pending prompts, and stop background jobs before archiving this topic")
7614
7615 // TrashTopic removes an idle topic from the project tree and moves its saved
7616 // session records into the session trash. Idle in-process runtimes are detached
7617 // first, so their autosave cannot recreate state after the topic is gone.
7618 func (a *App) TrashTopic(topicID string) error {
7619 return friendlySessionFileError(a.trashTopic(topicID))
7620 }
7621
7622 func (a *App) topicHasActiveRuntimeWork(topicID string) bool {
7623 a.mu.RLock()
7624 defer a.mu.RUnlock()
7625 for _, tabs := range []map[string]*WorkspaceTab{a.tabs, a.detachedSessions} {
7626 for _, tab := range tabs {
7627 if tab != nil && tab.TopicID == topicID && tab.hasActiveRuntimeWork() {
7628 return true
7629 }
7630 }
7631 }
7632 return false
7633 }
7634
7635 func (a *App) trashTopic(topicID string) error {
7636 if strings.TrimSpace(topicID) == "" {
7637 return fmt.Errorf("topicID is required")
7638 }
7639
7640 var fallback fallbackRuntimeTarget
7641 var changedDirs []string
7642 if err := func() error {
7643 defer a.lockRuntimeMutation("trash-topic")()
7644 a.sessionRemovalMu.Lock()
7645 defer a.sessionRemovalMu.Unlock()
7646 if a.topicHasActiveRuntimeWork(topicID) {
7647 return errTopicHasActiveWork
7648 }
7649
7650 targets, err := a.topicTrashTargets(topicID)
7651 if err != nil {
7652 return err
7653 }
7654 for _, target := range targets {
7655 changedDirs = append(changedDirs, target.dir)
7656 }
7657 removed, nextFallback := a.removeTopicRuntimeBindings(topicID)
7658 fallback = nextFallback
7659 if err := a.prepareRemovedSessionRuntimes(removed); err != nil {
7660 a.closeRemainingRemovedSessionRuntimesAdmissionHeld(removed, map[control.SessionAPI]bool{})
7661 return err
7662 }
7663 destroyBegun := false
7664 closedRemoved := map[control.SessionAPI]bool{}
7665 defer func() {
7666 if destroyBegun {
7667 a.closeRemainingRemovedSessionRuntimesAfterDestroyAdmissionHeld(removed, closedRemoved)
7668 return
7669 }
7670 a.closeRemainingRemovedSessionRuntimesAdmissionHeld(removed, closedRemoved)
7671 }()
7672
7673 for _, target := range targets {
7674 destroys := a.destroyHandlesForSession(target.dir, target.sessionPath, removed)
7675 if len(destroys) > 0 {
7676 destroyBegun = true
7677 }
7678 teardownTimedOut := waitDestroyHandles(destroys)
7679 a.closeRemovedSessionRuntimesForSessionAfterDestroyAdmissionHeld(removed, target.dir, target.sessionPath, closedRemoved)
7680 if teardownTimedOut {
7681 if err := agent.MarkCleanupPending(target.sessionPath, "delete"); err != nil {
7682 return err
7683 }
7684 go delayedDesktopSessionTrash(target.dir, target.sessionPath, target.key, destroys)
7685 } else {
7686 err := trashSessionArtifacts(target.dir, target.sessionPath, target.key)
7687 finishDestroyHandles(destroys)
7688 if err != nil {
7689 return err
7690 }
7691 }
7692 }
7693 return a.deleteTopic(topicID)
7694 }(); err != nil {
7695 return err
7696 }
7697 if fallback.needs {
7698 fallback.topicID = ""
7699 if err := a.openFallbackRuntime(fallback); err != nil {
7700 return err
7701 }
7702 }
7703 if len(changedDirs) > 0 {
7704 a.emitProjectTreeChangedForSessionDirs(changedDirs...)
7705 } else {
7706 a.emitProjectTreeMetadataChanged()
7707 }
7708 return nil
7709 }
7710
7711 type topicTrashTarget struct {
7712 dir string
7713 sessionPath string
7714 key string
7715 }
7716
7717 func (a *App) topicTrashTargets(topicID string) ([]topicTrashTarget, error) {
7718 topicID = strings.TrimSpace(topicID)
7719 var targets []topicTrashTarget
7720 seen := map[string]bool{}
7721 addTarget := func(dir, path string) error {
7722 sessionPath, key, err := validateSessionPath(dir, path)
7723 if err != nil {
7724 return err
7725 }
7726 id := dir + "\x00" + sessionPath
7727 if seen[id] {
7728 return nil
7729 }
7730 seen[id] = true
7731 if err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {
7732 return err
7733 }
7734 targets = append(targets, topicTrashTarget{dir: dir, sessionPath: sessionPath, key: key})
7735 return nil
7736 }
7737 for _, dir := range a.knownSessionDirs() {
7738 index, err := topicSessionIndexForDir(dir)
7739 if err != nil {
7740 return nil, err
7741 }
7742 for _, match := range index.byTopic[topicID] {
7743 if agent.IsCleanupPending(match.path) {
7744 continue
7745 }
7746 if err := addTarget(dir, match.path); err != nil {
7747 return nil, err
7748 }
7749 }
7750 }
7751 a.mu.RLock()
7752 var runtimeTargets []struct {
7753 dir string
7754 path string
7755 }
7756 for _, tab := range a.runtimeTabsLocked() {
7757 if tab == nil || tab.TopicID != topicID {
7758 continue
7759 }
7760 if path := canonicalTabSessionPath(tab.currentSessionPath()); path != "" {
7761 dir := tabSessionDir(tab)
7762 if filepath.IsAbs(path) {
7763 dir = filepath.Dir(path)
7764 }
7765 runtimeTargets = append(runtimeTargets, struct {
7766 dir string
7767 path string
7768 }{dir: dir, path: path})
7769 }
7770 }
7771 a.mu.RUnlock()
7772 for _, target := range runtimeTargets {
7773 if err := addTarget(target.dir, target.path); err != nil {
7774 return nil, err
7775 }
7776 }
7777 return targets, nil
7778 }
7779
7780 // ListProjectTree builds the sidebar tree: project folders each containing
7781 // their topics, plus a Global section.
7782 // topicSummary is used by ListProjectTree and mergeSessionInfos to track
7783 // per-topic turn count and last activity.
7784 type topicSummary struct {
7785 turns int
7786 adoptedRecoveryTurns int
7787 lastActivityAt int64
7788 hasNormalSession bool
7789 hasRecoveryOnly bool
7790 hasAdoptedRecovery bool
7791 }
7792
7793 func (s topicSummary) displayTurns() int {
7794 if s.adoptedRecoveryTurns > s.turns {
7795 return s.adoptedRecoveryTurns
7796 }
7797 return s.turns
7798 }
7799
7800 // runtimeSessionStatus is one open or detached runtime session, as shown in
7801 // the sidebar tree.
7802 type runtimeSessionStatus struct {
7803 sessionPath string
7804 label string
7805 titleSource string
7806 turns int
7807 createdAt int64
7808 lastActivityAt int64
7809 open bool
7810 running bool
7811 status string
7812 recovered bool
7813 recoveryReason string
7814 recoveryDigest string
7815 recoveryParentID string
7816 }
7817
7818 // topicHiddenAsRecoveryOnly hides topics whose only on-disk sessions are
7819 // conflict-recovery copies: they stay reachable from History, but must not sit
7820 // in the tree as regular conversations. Pinned topics and topics with any
7821 // open or running runtime session remain visible — note topicRuntimeStatus
7822 // reports open/running only for single-session topics, so it must not gate
7823 // topic existence.
7824 func topicHiddenAsRecoveryOnly(summary topicSummary, pinned bool, runtimeSessions []runtimeSessionStatus) bool {
7825 if !summary.hasRecoveryOnly || summary.hasNormalSession || summary.hasAdoptedRecovery || pinned {
7826 return false
7827 }
7828 for _, session := range runtimeSessions {
7829 if session.open || session.running {
7830 return false
7831 }
7832 }
7833 return true
7834 }
7835
7836 var listProjectTreeMu sync.Mutex
7837
7838 func (a *App) ListProjectTree() []ProjectNode {
7839 listProjectTreeMu.Lock()
7840 defer listProjectTreeMu.Unlock()
7841
7842 knownDirs := a.knownSessionDirs()
7843 for _, dir := range knownDirs {
7844 migrateLegacySessionsIntoGlobalTopics(dir)
7845 }
7846 f := loadProjectsFile()
7847 // Render-side tombstone guard: a deleted topic whose title survived a racy
7848 // whole-map save would otherwise reappear via the orderedTopicIDs title-map
7849 // fallback. The tombstone is authoritative until an intentional
7850 // single-topic write (create/restore/tab indexing) clears it.
7851 deletedTopicSet := make(map[string]bool, len(f.DeletedTopics))
7852 for _, id := range f.DeletedTopics {
7853 deletedTopicSet[id] = true
7854 }
7855 out := []ProjectNode{}
7856 topicSummaries := map[string]topicSummary{}
7857 sessionInfos := map[string]agent.SessionInfo{}
7858 sessionTitles := map[string]string{}
7859
7860 // Read session listings from all known directories concurrently, since
7861 // each dir is independent I/O. With N workspaces × dozens of sessions,
7862 // sequential reads add up to seconds of wall time on cold start.
7863 type sessionDirLoadResult struct {
7864 dir string
7865 infos []agent.SessionInfo
7866 titles map[string]string
7867 ok bool
7868 }
7869 results := make(chan sessionDirLoadResult, len(knownDirs))
7870 pendingLoads := 0
7871 for _, dir := range knownDirs {
7872 infos, titles, ok := projectSessionCache.get(dir)
7873 if ok {
7874 mergeSessionInfos(dir, infos, titles, sessionInfos, sessionTitles, topicSummaries)
7875 continue
7876 }
7877 pendingLoads++
7878 dir := dir // capture
7879 cacheToken := projectSessionCache.versionToken(dir)
7880 go func() {
7881 result := sessionDirLoadResult{dir: dir}
7882 defer func() {
7883 if recover() != nil {
7884 result.ok = false
7885 }
7886 results <- result
7887 }()
7888
7889 // Sidecar-backed listing: ListSessions reads turn count + preview from
7890 // each session's .meta sidecar, so even large directories list in a few
7891 // milliseconds without decoding any .jsonl body. The in-memory
7892 // projectSessionCache still elides repeat listings within a session.
7893 infos, err := agent.ListSessions(dir)
7894 if err != nil {
7895 return
7896 }
7897 titles := loadSessionTitles(dir)
7898 projectSessionCache.put(dir, infos, titles, cacheToken)
7899 result.infos = infos
7900 result.titles = titles
7901 result.ok = true
7902 }()
7903 }
7904 if pendingLoads > 0 {
7905 timer := time.NewTimer(5 * time.Second)
7906 for received := 0; received < pendingLoads; {
7907 select {
7908 case result := <-results:
7909 received++
7910 if result.ok {
7911 mergeSessionInfos(result.dir, result.infos, result.titles, sessionInfos, sessionTitles, topicSummaries)
7912 }
7913 case <-timer.C:
7914 received = pendingLoads
7915 }
7916 }
7917 timer.Stop()
7918 }
7919
7920 runtimeSessionsByTopic := map[string][]runtimeSessionStatus{}
7921 a.mu.RLock()
7922 seenRuntimePaths := map[string]bool{}
7923 addRuntimeSession := func(tab *WorkspaceTab, open bool) {
7924 if tab == nil || strings.TrimSpace(tab.TopicID) == "" {
7925 return
7926 }
7927 sessionPath := sessionRuntimeKey(tab.currentSessionPath())
7928 if sessionPath == "" || seenRuntimePaths[sessionPath] {
7929 return
7930 }
7931 seenRuntimePaths[sessionPath] = true
7932 info := sessionInfos[sessionPath]
7933 recovered := sessionInfoIsAutomaticRecovery(info) || isAutomaticRecoverySessionPath(sessionPath)
7934 label := runtimeSessionTreeLabel(tab, info, sessionTitles[sessionPath])
7935 titleSource := tab.topicTitleSource
7936 if strings.TrimSpace(sessionTitles[sessionPath]) != "" {
7937 titleSource = topicTitleSourceManual
7938 }
7939 status := activityStatusForTab(tab)
7940 runtimeStatus := control.RuntimeStatus{}
7941 if tab.Ctrl != nil {
7942 runtimeStatus = tab.Ctrl.RuntimeStatus()
7943 }
7944 running := status != "" || runtimeStatus.Running || runtimeStatus.PendingPrompt || runtimeStatus.BackgroundJobs > 0
7945 runtimeSessionsByTopic[topicSummaryKey(tab.Scope, tab.WorkspaceRoot, tab.TopicID)] = append(runtimeSessionsByTopic[topicSummaryKey(tab.Scope, tab.WorkspaceRoot, tab.TopicID)], runtimeSessionStatus{
7946 sessionPath: sessionPath,
7947 label: label,
7948 titleSource: titleSource,
7949 turns: info.Turns,
7950 createdAt: unixMilliOrZero(info.CreatedAt),
7951 lastActivityAt: unixMilliOrZero(info.LastActivityAt),
7952 open: open,
7953 running: running,
7954 status: status,
7955 recovered: recovered,
7956 recoveryReason: info.RecoveryReason,
7957 recoveryDigest: info.RecoveryDigest,
7958 recoveryParentID: string(info.ParentID),
7959 })
7960 }
7961 for _, tab := range a.tabs {
7962 addRuntimeSession(tab, true)
7963 }
7964 for _, tab := range a.detachedSessions {
7965 addRuntimeSession(tab, false)
7966 }
7967 a.mu.RUnlock()
7968 for key := range runtimeSessionsByTopic {
7969 sort.Slice(runtimeSessionsByTopic[key], func(i, j int) bool {
7970 left := runtimeSessionsByTopic[key][i]
7971 right := runtimeSessionsByTopic[key][j]
7972 if left.lastActivityAt != right.lastActivityAt {
7973 return left.lastActivityAt > right.lastActivityAt
7974 }
7975 return left.sessionPath < right.sessionPath
7976 })
7977 }
7978 topicRuntimeStatus := func(key string) (open, running bool, status string) {
7979 sessions := runtimeSessionsByTopic[key]
7980 if len(sessions) != 1 {
7981 return false, false, ""
7982 }
7983 session := sessions[0]
7984 return session.open, session.running, session.status
7985 }
7986 runtimeSessionNodes := func(scope, workspaceRoot, topicID, projectColor string) []ProjectNode {
7987 key := topicSummaryKey(scope, workspaceRoot, topicID)
7988 sessions := runtimeSessionsByTopic[key]
7989 if len(sessions) <= 1 {
7990 return nil
7991 }
7992 nodes := make([]ProjectNode, 0, len(sessions))
7993 for _, session := range sessions {
7994 kind := "session"
7995 if scope == "global" {
7996 kind = "global_session"
7997 }
7998 nodes = append(nodes, ProjectNode{
7999 Key: projectSessionNodeKey(scope, session.sessionPath),
8000 Kind: kind,
8001 Label: a.localizedTopicTitle(session.label, session.titleSource),
8002 Root: workspaceRoot,
8003 TopicID: topicID,
8004 SessionPath: session.sessionPath,
8005 ProjectColor: projectColor,
8006 Turns: session.turns,
8007 CreatedAt: session.createdAt,
8008 LastActivityAt: session.lastActivityAt,
8009 Open: session.open,
8010 Running: session.running,
8011 Status: session.status,
8012 Recovered: session.recovered,
8013 RecoveryReason: session.recoveryReason,
8014 RecoveryDigest: session.recoveryDigest,
8015 RecoveryParentID: session.recoveryParentID,
8016 })
8017 }
8018 return nodes
8019 }
8020
8021 // Global section.
8022 globalTitleMap := loadTopicTitles("")
8023 globalTitleSources := loadTopicTitleSources("")
8024 globalCreatedMap := loadTopicCreatedAts("")
8025 if len(globalTitleMap) > 0 || len(f.Projects) == 0 {
8026 globalTitle := strings.TrimSpace(f.GlobalTitle)
8027 if globalTitle == "" {
8028 globalTitle = "Global"
8029 }
8030 globalColor := normalizeProjectColor(f.GlobalColor)
8031 globalTopicIDs := pinnedTopicIDs(orderedTopicIDs(f.GlobalTopics, globalTitleMap), f.GlobalPinnedTopics)
8032 children := make([]ProjectNode, 0, len(globalTopicIDs))
8033 for _, id := range globalTopicIDs {
8034 if deletedTopicSet[id] {
8035 continue
8036 }
8037 title := a.localizedTopicTitle(globalTitleMap[id], globalTitleSources[id])
8038 summaryKey := topicSummaryKey("global", "", id)
8039 summary := topicSummaries[summaryKey]
8040 open, running, status := topicRuntimeStatus(summaryKey)
8041 pinned := containsDesktopString(f.GlobalPinnedTopics, id)
8042 if topicHiddenAsRecoveryOnly(summary, pinned, runtimeSessionsByTopic[summaryKey]) {
8043 continue
8044 }
8045 children = append(children, ProjectNode{
8046 Key: "global_topic_" + id,
8047 Kind: "global_topic",
8048 Label: title,
8049 TopicID: id,
8050 ProjectColor: globalColor,
8051 Turns: summary.displayTurns(),
8052 CreatedAt: topicCreatedAtForTree(globalCreatedMap, id),
8053 LastActivityAt: summary.lastActivityAt,
8054 Open: open,
8055 Running: running,
8056 Status: status,
8057 Pinned: pinned,
8058 Children: runtimeSessionNodes("global", "", id, globalColor),
8059 })
8060 }
8061 out = append(out, ProjectNode{
8062 Key: "global_folder",
8063 Kind: "global_folder",
8064 Label: globalTitle,
8065 Root: globalWorkspaceRoot(),
8066 ProjectColor: globalColor,
8067 Children: children,
8068 })
8069 }
8070
8071 // Project sections.
8072 type projectTopics struct {
8073 project desktopProject
8074 titles map[string]string
8075 sources map[string]string
8076 createdAts map[string]int64
8077 }
8078 projectTopicResults := make([]projectTopics, len(f.Projects))
8079 var topicLoadWg sync.WaitGroup
8080 for i, p := range f.Projects {
8081 i, p := i, p
8082 topicLoadWg.Add(1)
8083 go func() {
8084 defer topicLoadWg.Done()
8085 projectTopicResults[i] = projectTopics{
8086 project: p,
8087 titles: loadTopicTitles(p.Root),
8088 sources: loadTopicTitleSources(p.Root),
8089 createdAts: loadTopicCreatedAts(p.Root),
8090 }
8091 }()
8092 }
8093 topicLoadWg.Wait()
8094 for _, loaded := range projectTopicResults {
8095 p := loaded.project
8096 title := p.Title
8097 if title == "" {
8098 title = workspaceName(p.Root)
8099 }
8100 node := ProjectNode{
8101 Key: "project_" + p.Root,
8102 Kind: "project",
8103 Root: p.Root,
8104 Pinned: containsDesktopString(f.PinnedProjects, p.Root),
8105 IsolatedWorktree: worktree.IsManagedPath(p.Root, config.DeliveryWorktreeDir()),
8106 }
8107
8108 // Gather topics: explicit topic list + all known topic titles.
8109 titleMap := loaded.titles
8110 titleSources := loaded.sources
8111 createdMap := loaded.createdAts
8112 topicIDs := pinnedTopicIDs(orderedTopicIDs(p.Topics, titleMap), p.PinnedTopics)
8113
8114 children := make([]ProjectNode, 0, len(topicIDs))
8115 for _, tid := range topicIDs {
8116 if deletedTopicSet[tid] {
8117 continue
8118 }
8119 topicTitle := strings.TrimSpace(titleMap[tid])
8120 if topicTitle == "" {
8121 topicTitle = defaultTopicTitle
8122 }
8123 topicTitle = a.localizedTopicTitle(topicTitle, titleSources[tid])
8124 summaryKey := topicSummaryKey("project", p.Root, tid)
8125 summary := topicSummaries[summaryKey]
8126 open, running, status := topicRuntimeStatus(summaryKey)
8127 pinned := containsDesktopString(p.PinnedTopics, tid)
8128 if topicHiddenAsRecoveryOnly(summary, pinned, runtimeSessionsByTopic[summaryKey]) {
8129 continue
8130 }
8131 children = append(children, ProjectNode{
8132 Key: "topic_" + tid,
8133 Kind: "topic",
8134 Label: topicTitle,
8135 Root: p.Root,
8136 TopicID: tid,
8137 ProjectColor: p.Color,
8138 Turns: summary.displayTurns(),
8139 CreatedAt: topicCreatedAtForTree(createdMap, tid),
8140 LastActivityAt: summary.lastActivityAt,
8141 Open: open,
8142 Running: running,
8143 Status: status,
8144 Pinned: pinned,
8145 Children: runtimeSessionNodes("project", p.Root, tid, p.Color),
8146 })
8147 }
8148 node.Label = title
8149 node.ProjectColor = p.Color
8150 node.Children = children
8151 out = append(out, node)
8152 }
8153
8154 return applyPinnedProjectOrder(applyProjectTreeOrder(out, f.SidebarOrder), f.PinnedProjects)
8155 }
8156
8157 func topicSummaryKey(scope, workspaceRoot, topicID string) string {
8158 if scope == "global" {
8159 return "global::" + topicID
8160 }
8161 // Producers key by the live tab's root spelling while the sidebar keys by
8162 // the registry's canonical spelling; fold both so runtime status never
8163 // splits across equivalent roots.
8164 return "project:" + projectRootKey(workspaceRoot) + ":" + topicID
8165 }
8166
8167 func projectSessionNodeKey(scope, sessionPath string) string {
8168 sum := sha256.Sum256([]byte(sessionRuntimeKey(sessionPath)))
8169 return scope + "_session_" + hex.EncodeToString(sum[:8])
8170 }
8171
8172 func runtimeSessionTreeLabel(tab *WorkspaceTab, info agent.SessionInfo, title string) string {
8173 if title = strings.TrimSpace(title); title != "" {
8174 return title
8175 }
8176 if preview := topicTitleFromText(info.Preview); preview != "" {
8177 return preview
8178 }
8179 if tab != nil {
8180 if title := strings.TrimSpace(tab.TopicTitle); title != "" {
8181 return title
8182 }
8183 }
8184 if path := strings.TrimSpace(info.Path); path != "" {
8185 return strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
8186 }
8187 if tab != nil {
8188 if path := strings.TrimSpace(tab.currentSessionPath()); path != "" {
8189 return strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
8190 }
8191 }
8192 return defaultTopicTitle
8193 }
8194
8195 func unixMilliOrZero(t time.Time) int64 {
8196 if t.IsZero() {
8197 return 0
8198 }
8199 return t.UnixMilli()
8200 }
8201
8202 // ContextPanelInfo is the right-side panel's data for one tab.
8203 type ContextPanelInfo struct {
8204 UsedTokens int `json:"usedTokens"`
8205 WindowTokens int `json:"windowTokens"`
8206 PromptTokens int `json:"promptTokens"`
8207 CompletionTokens int `json:"completionTokens"`
8208 TotalTokens int `json:"totalTokens"`
8209 ReasoningTokens int `json:"reasoningTokens"`
8210 CacheHitTokens int `json:"cacheHitTokens"`
8211 CacheMissTokens int `json:"cacheMissTokens"`
8212 Estimated bool `json:"estimated,omitempty"`
8213 // Session-cumulative token counts (from telemetry, atomic snapshot).
8214 // Separate from the per-turn fields above so existing consumers (status bar
8215 // turn tokens, donut chart) are unaffected.
8216 SessionCacheHitTokens int `json:"sessionCacheHitTokens"`
8217 SessionCacheMissTokens int `json:"sessionCacheMissTokens"`
8218 SessionCompletionTokens int `json:"sessionCompletionTokens"`
8219 SessionEstimated bool `json:"sessionEstimated,omitempty"`
8220 RequestCount int `json:"requestCount"`
8221 ElapsedMs int64 `json:"elapsedMs"`
8222 SessionCost float64 `json:"sessionCost"`
8223 SessionCurrency string `json:"sessionCurrency,omitempty"`
8224 SessionCostUsd float64 `json:"sessionCostUsd,omitempty"`
8225 Sources map[string]usageSourceStats `json:"sources,omitempty"`
8226 Mock bool `json:"mock,omitempty"`
8227 ReadFiles []readFileRecord `json:"readFiles"`
8228 ChangedFiles []ChangedFileInfo `json:"changedFiles"`
8229 }
8230
8231 type ChangedFileInfo struct {
8232 Path string `json:"path"`
8233 OldPath string `json:"oldPath,omitempty"`
8234 Sources []string `json:"sources"`
8235 GitStatus string `json:"gitStatus,omitempty"`
8236 Turns []int `json:"turns"`
8237 LatestPrompt string `json:"latestPrompt,omitempty"`
8238 LatestTime int64 `json:"latestTime,omitempty"`
8239 }
8240
8241 // ContextPanel returns the context usage, read files, and changed files for a
8242 // specific tab.
8243 func (a *App) ContextPanel(tabID string) ContextPanelInfo {
8244 a.mu.RLock()
8245 tab, ok := a.tabs[tabID]
8246 var ctrl control.SessionAPI
8247 if ok && tab != nil {
8248 ctrl = tab.Ctrl
8249 }
8250 a.mu.RUnlock()
8251 if !ok {
8252 return ContextPanelInfo{ReadFiles: []readFileRecord{}, ChangedFiles: []ChangedFileInfo{}}
8253 }
8254
8255 info := ContextPanelInfo{ReadFiles: []readFileRecord{}, ChangedFiles: []ChangedFileInfo{}}
8256 if ctrl != nil {
8257 if sp := ctrl.SessionPath(); sp != "" {
8258 tab.syncTelemetryToSession(sp)
8259 }
8260 used, window := ctrl.ContextSnapshot()
8261 info.UsedTokens = used
8262 info.WindowTokens = window
8263 // Session rebind rebuilds the controller: the fresh executor has no
8264 // per-turn usage yet, so ContextSnapshot reports used=0. Fall back to
8265 // the telemetry-persisted last-used value from the most recent turn.
8266 if used == 0 {
8267 if snap := tab.telemetrySnapshot(); snap.Usage.LastUsedTokens > 0 {
8268 info.UsedTokens = snap.Usage.LastUsedTokens
8269 }
8270 }
8271 // Per-turn token breakdown from LastUsage (same snapshot as UsedTokens)
8272 // so the donut segments are proportional to the current context fill,
8273 // not inflated by cumulative session totals.
8274 if u := ctrl.LastUsage(); u != nil {
8275 info.PromptTokens = u.PromptTokens
8276 info.CompletionTokens = u.CompletionTokens
8277 info.ReasoningTokens = u.ReasoningTokens
8278 info.CacheHitTokens = u.CacheHitTokens
8279 info.CacheMissTokens = u.CacheMissTokens
8280 info.Estimated = u.Estimated
8281 } else {
8282 // Executor rebuilt (session rebind): fall back to the telemetry-
8283 // persisted per-turn breakdown so the donut chart and type
8284 // breakdown show the last turn's composition instead of "other".
8285 snap := tab.telemetrySnapshot()
8286 info.PromptTokens = snap.Usage.LastPromptTokens
8287 info.CompletionTokens = snap.Usage.LastCompletionTokens
8288 info.ReasoningTokens = snap.Usage.LastReasoningTokens
8289 info.CacheHitTokens = snap.Usage.LastCacheHitTokens
8290 info.CacheMissTokens = snap.Usage.LastCacheMissTokens
8291 info.Estimated = snap.Usage.LastEstimated
8292 }
8293 }
8294
8295 telemetry := tab.telemetrySnapshot()
8296 if records := telemetry.ReadFiles; records != nil {
8297 info.ReadFiles = records
8298 }
8299 usage := telemetry.Usage
8300 info.TotalTokens = usage.TotalTokens
8301 info.RequestCount = usage.RequestCount
8302 info.ElapsedMs = usage.ElapsedMs
8303 info.SessionCost = usage.SessionCost
8304 info.SessionCurrency = usage.SessionCurrency
8305 info.SessionCostUsd = usage.SessionCostUsd
8306 info.Sources = usage.Sources
8307 info.SessionCacheHitTokens = usage.CacheHitTokens
8308 info.SessionCacheMissTokens = usage.CacheMissTokens
8309 info.SessionCompletionTokens = usage.CompletionTokens
8310 info.SessionEstimated = usage.Estimated
8311
8312 // Gather workspace changes for this tab's root.
8313 if ctrl != nil && tab.WorkspaceRoot != "" {
8314 for _, meta := range ctrl.Checkpoints() {
8315 for _, path := range meta.Paths {
8316 info.ChangedFiles = append(info.ChangedFiles, ChangedFileInfo{
8317 Path: path,
8318 Sources: []string{"session"},
8319 Turns: []int{meta.Turn},
8320 LatestPrompt: meta.Prompt,
8321 LatestTime: meta.Time.UnixMilli(),
8322 })
8323 }
8324 }
8325 }
8326
8327 return info
8328 }
8329
8330 // --- utility ----------------------------------------------------------------
8331
8332 func (a *App) newUniqueTabIDLocked() string {
8333 for {
8334 id := newTabID()
8335 if _, exists := a.tabs[id]; !exists {
8336 return id
8337 }
8338 }
8339 }
8340
8341 func (a *App) restoredTabIDLocked(id string) string {
8342 id = strings.TrimSpace(id)
8343 if id == "" {
8344 return a.newUniqueTabIDLocked()
8345 }
8346 if _, exists := a.tabs[id]; exists {
8347 return a.newUniqueTabIDLocked()
8348 }
8349 return id
8350 }
8351
8352 func normalizeTabMode(mode string) string {
8353 switch mode {
8354 case "plan", "yolo", "plan-yolo", "yolo-plan":
8355 if mode == "yolo-plan" {
8356 return "plan-yolo"
8357 }
8358 return mode
8359 default:
8360 return "normal"
8361 }
8362 }
8363
8364 func tabModeFromAxes(plan, autoApproveTools bool) string {
8365 switch {
8366 case plan && autoApproveTools:
8367 return "plan-yolo"
8368 case plan:
8369 return "plan"
8370 case autoApproveTools:
8371 return "yolo"
8372 default:
8373 return "normal"
8374 }
8375 }
8376
8377 func tabModeHasPlan(mode string) bool {
8378 switch normalizeTabMode(mode) {
8379 case "plan", "plan-yolo":
8380 return true
8381 default:
8382 return false
8383 }
8384 }
8385
8386 func tabModeHasAutoApproveTools(mode string) bool {
8387 switch normalizeTabMode(mode) {
8388 case "yolo", "plan-yolo":
8389 return true
8390 default:
8391 return false
8392 }
8393 }
8394
8395 func currentTabMode(tab *WorkspaceTab) string {
8396 if tab == nil {
8397 return "normal"
8398 }
8399 if tab.Ctrl != nil {
8400 return tabModeFromAxes(tab.Ctrl.PlanMode(), tab.Ctrl.AutoApproveTools())
8401 }
8402 return normalizeTabMode(tab.mode)
8403 }
8404
8405 func currentTabGoal(tab *WorkspaceTab) string {
8406 if tab == nil {
8407 return ""
8408 }
8409 if tab.Ctrl != nil {
8410 return tab.Ctrl.Goal()
8411 }
8412 return strings.TrimSpace(tab.goal)
8413 }
8414
8415 func currentTabGoalStatus(tab *WorkspaceTab) string {
8416 if tab == nil {
8417 return control.GoalStatusStopped
8418 }
8419 if tab.Ctrl != nil {
8420 return tab.Ctrl.GoalStatus()
8421 }
8422 if strings.TrimSpace(tab.goal) != "" {
8423 return control.GoalStatusRunning
8424 }
8425 return control.GoalStatusStopped
8426 }
8427
8428 func currentTabCollaborationMode(tab *WorkspaceTab) string {
8429 if tab == nil {
8430 return "normal"
8431 }
8432 if tabModeHasPlan(currentTabMode(tab)) {
8433 return "plan"
8434 }
8435 if strings.TrimSpace(currentTabGoal(tab)) != "" && currentTabGoalStatus(tab) == control.GoalStatusRunning {
8436 return "goal"
8437 }
8438 return "normal"
8439 }
8440
8441 func currentTabToolApprovalMode(tab *WorkspaceTab) string {
8442 if tab == nil {
8443 return control.ToolApprovalAsk
8444 }
8445 if tab.Ctrl != nil {
8446 return tab.Ctrl.ToolApprovalMode()
8447 }
8448 return normalizeToolApprovalMode(tab.toolApprovalMode)
8449 }
8450
8451 func currentTabTokenMode(tab *WorkspaceTab) string {
8452 if tab == nil {
8453 return boot.TokenModeFull
8454 }
8455 return boot.NormalizeTokenMode(tab.tokenMode)
8456 }
8457
8458 // tabRuntimeSnapshot is a consistent under-a.mu copy of the per-tab fields
8459 // that bound methods and rebuild paths need after releasing the lock. The
8460 // build/rebuild goroutines write these fields under a.mu, so lock-free reads
8461 // from other goroutines are data races (same class as the sessionLease race
8462 // fixed for #5955). Controller methods are invoked on the snapshot's ctrl
8463 // AFTER unlocking, never while holding a.mu.
8464 type tabRuntimeSnapshot struct {
8465 ctrl control.SessionAPI
8466 sink *tabEventSink
8467 label string
8468 ready bool
8469 readOnly bool
8470 startupErr string
8471 scope string
8472 workspaceRoot string
8473 sessionPath string
8474 topicID string
8475 topicTitle string
8476 sharedHostKey string
8477 model string
8478 effort *string
8479 tokenMode string
8480 mode string
8481 goal string
8482 toolApprovalMode string
8483 }
8484
8485 // normalizedTabRuntime is the internal, orthogonal runtime profile restored
8486 // across controller rebuilds. Goal sidecars remain authoritative; legacyGoal is
8487 // only a fallback for a running legacy Goal with no sidecar.
8488 type normalizedTabRuntime struct {
8489 collaborationMode string
8490 toolApprovalMode string
8491 tokenMode string
8492 legacyGoal string
8493 }
8494
8495 // snapshotTabRuntimeLocked copies the racy per-tab fields. Callers must hold
8496 // a.mu (read or write side).
8497 func snapshotTabRuntimeLocked(tab *WorkspaceTab) tabRuntimeSnapshot {
8498 if tab == nil {
8499 return tabRuntimeSnapshot{}
8500 }
8501 return tabRuntimeSnapshot{
8502 ctrl: tab.Ctrl,
8503 sink: tab.sink,
8504 label: tab.Label,
8505 ready: tab.Ready,
8506 readOnly: tab.ReadOnly,
8507 startupErr: tab.StartupErr,
8508 scope: tab.Scope,
8509 workspaceRoot: tab.WorkspaceRoot,
8510 sessionPath: tab.SessionPath,
8511 topicID: tab.TopicID,
8512 topicTitle: tab.TopicTitle,
8513 sharedHostKey: tab.SharedHostKey,
8514 model: tab.model,
8515 effort: cloneStringPtr(tab.effort),
8516 tokenMode: tab.tokenMode,
8517 mode: tab.mode,
8518 goal: tab.goal,
8519 toolApprovalMode: tab.toolApprovalMode,
8520 }
8521 }
8522
8523 func (a *App) tabRuntimeSnapshot(tab *WorkspaceTab) tabRuntimeSnapshot {
8524 if tab == nil {
8525 return tabRuntimeSnapshot{}
8526 }
8527 a.mu.RLock()
8528 defer a.mu.RUnlock()
8529 return snapshotTabRuntimeLocked(tab)
8530 }
8531
8532 // Snapshot-based forms of the currentTabX helpers, for callers that already
8533 // hold a consistent tabRuntimeSnapshot.
8534
8535 func (s tabRuntimeSnapshot) currentMode() string {
8536 if s.ctrl != nil {
8537 return tabModeFromAxes(s.ctrl.PlanMode(), s.ctrl.AutoApproveTools())
8538 }
8539 return normalizeTabMode(s.mode)
8540 }
8541
8542 func (s tabRuntimeSnapshot) currentGoal() string {
8543 if s.ctrl != nil {
8544 return s.ctrl.Goal()
8545 }
8546 return strings.TrimSpace(s.goal)
8547 }
8548
8549 func (s tabRuntimeSnapshot) currentGoalStatus() string {
8550 if s.ctrl != nil {
8551 return s.ctrl.GoalStatus()
8552 }
8553 if strings.TrimSpace(s.goal) != "" {
8554 return control.GoalStatusRunning
8555 }
8556 return control.GoalStatusStopped
8557 }
8558
8559 func (s tabRuntimeSnapshot) collaborationMode() string {
8560 if tabModeHasPlan(s.currentMode()) {
8561 return "plan"
8562 }
8563 if strings.TrimSpace(s.currentGoal()) != "" && s.currentGoalStatus() == control.GoalStatusRunning {
8564 return "goal"
8565 }
8566 return "normal"
8567 }
8568
8569 func (s tabRuntimeSnapshot) currentToolApprovalMode() string {
8570 if s.ctrl != nil {
8571 return s.ctrl.ToolApprovalMode()
8572 }
8573 return normalizeToolApprovalMode(s.toolApprovalMode)
8574 }
8575
8576 func (s tabRuntimeSnapshot) currentTokenMode() string {
8577 return boot.NormalizeTokenMode(s.tokenMode)
8578 }
8579
8580 // normalizedRuntime reads live Controller state only after the App snapshot has
8581 // released a.mu. Rebuild callers hold turnStartMu while invoking it, so all
8582 // three axes and the legacy Goal fallback describe one admitted runtime state.
8583 func (s tabRuntimeSnapshot) normalizedRuntime() normalizedTabRuntime {
8584 plan := tabModeHasPlan(normalizeTabMode(s.mode))
8585 approvalMode := normalizeToolApprovalMode(s.toolApprovalMode)
8586 goal := strings.TrimSpace(s.goal)
8587 goalStatus := control.GoalStatusStopped
8588 if goal != "" {
8589 goalStatus = control.GoalStatusRunning
8590 }
8591 if s.ctrl != nil {
8592 plan = s.ctrl.PlanMode()
8593 approvalMode = normalizeToolApprovalMode(s.ctrl.ToolApprovalMode())
8594 goal = strings.TrimSpace(s.ctrl.Goal())
8595 goalStatus = s.ctrl.GoalStatus()
8596 }
8597
8598 runtime := normalizedTabRuntime{
8599 collaborationMode: "normal",
8600 toolApprovalMode: approvalMode,
8601 tokenMode: boot.NormalizeTokenMode(s.tokenMode),
8602 }
8603 switch {
8604 case plan:
8605 runtime.collaborationMode = "plan"
8606 case goal != "" && goalStatus == control.GoalStatusRunning:
8607 runtime.collaborationMode = "goal"
8608 runtime.legacyGoal = goal
8609 }
8610 return runtime
8611 }
8612
8613 func (r normalizedTabRuntime) tabMode() string {
8614 return tabModeFromAxes(r.collaborationMode == "plan", r.toolApprovalMode == control.ToolApprovalYolo)
8615 }
8616
8617 func applyNormalizedRuntimeToTabLocked(tab *WorkspaceTab, runtime normalizedTabRuntime) {
8618 if tab == nil {
8619 return
8620 }
8621 tab.mode = runtime.tabMode()
8622 tab.toolApprovalMode = normalizeToolApprovalMode(runtime.toolApprovalMode)
8623 tab.tokenMode = boot.NormalizeTokenMode(runtime.tokenMode)
8624 if runtime.collaborationMode == "goal" {
8625 tab.goal = strings.TrimSpace(runtime.legacyGoal)
8626 } else {
8627 tab.goal = ""
8628 }
8629 }
8630
8631 func persistedTabTokenMode(mode string) string {
8632 mode = boot.NormalizeTokenMode(mode)
8633 if mode == boot.TokenModeEconomy || mode == boot.TokenModeDelivery {
8634 return mode
8635 }
8636 return ""
8637 }
8638
8639 func normalizeToolApprovalMode(mode string) string {
8640 switch strings.ToLower(strings.TrimSpace(mode)) {
8641 case control.ToolApprovalAuto:
8642 return control.ToolApprovalAuto
8643 case control.ToolApprovalYolo, "full", "full-access", "bypass":
8644 return control.ToolApprovalYolo
8645 default:
8646 return control.ToolApprovalAsk
8647 }
8648 }
8649
8650 func persistedToolApprovalMode(mode string) string {
8651 switch normalizeToolApprovalMode(mode) {
8652 case control.ToolApprovalAuto, control.ToolApprovalYolo:
8653 return normalizeToolApprovalMode(mode)
8654 default:
8655 return ""
8656 }
8657 }
8658
8659 // persistedTabMode is the composer mode saved with a tab so it survives reload
8660 // and app relaunch. plan, yolo, and plan-yolo are remembered (a restored yolo
8661 // tab keeps its status-bar indicator); "normal" is the default and isn't
8662 // persisted. (#3517)
8663 func persistedTabMode(mode string) string {
8664 switch normalizeTabMode(mode) {
8665 case "plan", "yolo", "plan-yolo":
8666 return normalizeTabMode(mode)
8667 }
8668 return ""
8669 }
8670
8671 func newTabID() string {
8672 var b [16]byte
8673 if _, err := rand.Read(b[:]); err != nil {
8674 now := time.Now().UTC()
8675 return "tab_" + now.Format("20060102150405") + "_" + fmt.Sprintf("%09d", now.Nanosecond())
8676 }
8677 return "tab_" + hex.EncodeToString(b[:])
8678 }
8679
8680 func newTopicID() string {
8681 var b [8]byte
8682 if _, err := rand.Read(b[:]); err != nil {
8683 now := time.Now().UTC()
8684 return "topic_" + now.Format("20060102-150405") + "_" + fmt.Sprintf("%09d", now.Nanosecond())
8685 }
8686 return "topic_" + time.Now().UTC().Format("20060102-150405") + "_" + hex.EncodeToString(b[:])
8687 }
8688
8689 func globalWorkspaceRoot() string {
8690 return filepath.Join(desktopConfigDir(), "global-workspace")
8691 }
8692
8693 func ensureGlobalWorkspaceRoot() (string, error) {
8694 root := globalWorkspaceRoot()
8695 if err := os.MkdirAll(root, 0o755); err != nil {
8696 return "", err
8697 }
8698 return root, nil
8699 }
8700
8701 func globalTabWorkspaceRoot() string {
8702 root, err := ensureGlobalWorkspaceRoot()
8703 if err != nil {
8704 return globalWorkspaceRoot()
8705 }
8706 return root
8707 }
8708
8709 func loadPinnedTabSession(dir, sessionPath string) (*agent.Session, string, bool, error) {
8710 return loadPinnedTabSessionWithPreloadAndMigrationFallback(dir, sessionPath, loadedTabSession{}, true)
8711 }
8712
8713 func loadPinnedTabSessionWithPreload(dir, sessionPath string, preloaded loadedTabSession) (*agent.Session, string, bool, error) {
8714 return loadPinnedTabSessionWithPreloadAndMigrationFallback(dir, sessionPath, preloaded, false)
8715 }
8716
8717 func loadPinnedTabSessionWithPreloadAndMigrationFallback(dir, sessionPath string, preloaded loadedTabSession, allowMigrationFallback bool) (*agent.Session, string, bool, error) {
8718 path, ok := pinnedTabSessionPath(dir, sessionPath)
8719 if !ok && allowMigrationFallback {
8720 path, ok = migratedPinnedTabSessionPath(dir, sessionPath)
8721 }
8722 if !ok {
8723 return nil, "", false, nil
8724 }
8725 if agent.IsCleanupPending(path) {
8726 return nil, "", false, nil
8727 }
8728 if preloaded.matches(path) {
8729 if preloaded.Session != nil && len(preloaded.Session.Snapshot()) == 0 {
8730 return nil, path, true, nil
8731 }
8732 return preloaded.Session, path, true, nil
8733 }
8734 loaded, err := agent.LoadSession(path)
8735 if err != nil {
8736 if os.IsNotExist(err) {
8737 return nil, path, true, nil
8738 }
8739 return nil, path, true, err
8740 }
8741 // An empty file (0 messages) is a pre-created placeholder, not a real
8742 // session to resume. Treating it as valid would make ctrl.Resume replace
8743 // the executor's live session (with system prompt) with the empty one,
8744 // causing the saved transcript to lack the agent identity contract.
8745 if len(loaded.Snapshot()) == 0 {
8746 return nil, path, true, nil
8747 }
8748 return loaded, path, true, nil
8749 }
8750
8751 func migratedPinnedTabSessionPath(dir, sessionPath string) (string, bool) {
8752 sessionPath = strings.TrimSpace(sessionPath)
8753 if sessionPath == "" || dir == "" || !filepath.IsAbs(sessionPath) {
8754 return "", false
8755 }
8756 if _, err := os.Stat(sessionPath); err == nil || !os.IsNotExist(err) {
8757 return "", false
8758 }
8759 base := filepath.Base(sessionPath)
8760 if base == "." || base == string(filepath.Separator) || !strings.HasSuffix(base, ".jsonl") {
8761 return "", false
8762 }
8763 path, _, err := validateSessionPath(dir, filepath.Join(dir, base))
8764 if err != nil {
8765 return "", false
8766 }
8767 return path, true
8768 }
8769
8770 func pinnedTabSessionPath(dir, sessionPath string) (string, bool) {
8771 sessionPath = strings.TrimSpace(sessionPath)
8772 if sessionPath == "" || dir == "" {
8773 return "", false
8774 }
8775 path, _, err := validateSessionPath(dir, sessionPath)
8776 if err != nil {
8777 if filepath.IsAbs(sessionPath) {
8778 return "", false
8779 }
8780 base := filepath.Base(sessionPath)
8781 if base == "." || base == string(filepath.Separator) || !strings.HasSuffix(base, ".jsonl") {
8782 return "", false
8783 }
8784 path, _, err = validateSessionPath(dir, filepath.Join(dir, base))
8785 if err != nil {
8786 return "", false
8787 }
8788 }
8789 return path, true
8790 }
8791
8792 // saveTabSessionMeta persists the tab's scope/topic/mode fields into the
8793 // session's branch-meta sidecar at path. Tab fields are snapshotted under a.mu
8794 // (controller reads happen off-lock) so a concurrent tab mutation can't tear
8795 // the persisted record.
8796 func (a *App) saveTabSessionMeta(tab *WorkspaceTab, path string) error {
8797 if tab == nil || strings.TrimSpace(path) == "" {
8798 return nil
8799 }
8800 a.mu.RLock()
8801 ctrl := tab.Ctrl
8802 snap := tabSessionMetaSnapshot{
8803 path: path,
8804 scope: tab.Scope,
8805 workspaceRoot: tab.WorkspaceRoot,
8806 topicID: tab.TopicID,
8807 topicTitle: tab.TopicTitle,
8808 tokenMode: boot.NormalizeTokenMode(tab.tokenMode),
8809 mode: normalizeTabMode(tab.mode),
8810 toolApprovalMode: normalizeToolApprovalMode(tab.toolApprovalMode),
8811 goal: strings.TrimSpace(tab.goal),
8812 }
8813 a.mu.RUnlock()
8814 if ctrl != nil {
8815 snap.mode = tabModeFromAxes(ctrl.PlanMode(), ctrl.AutoApproveTools())
8816 snap.toolApprovalMode = normalizeToolApprovalMode(ctrl.ToolApprovalMode())
8817 if goal := strings.TrimSpace(ctrl.Goal()); goal != "" && ctrl.GoalStatus() == control.GoalStatusRunning {
8818 snap.goal = goal
8819 } else {
8820 snap.goal = ""
8821 }
8822 }
8823 return saveTabSessionMetaSnapshot(snap)
8824 }
8825
8826 type tabSessionMetaSnapshot struct {
8827 path string
8828 scope string
8829 workspaceRoot string
8830 topicID string
8831 topicTitle string
8832 tokenMode string
8833 mode string
8834 toolApprovalMode string
8835 goal string
8836 }
8837
8838 func (a *App) saveTabSessionMetaForCurrentSession(tab *WorkspaceTab) error {
8839 snap, ok := a.tabSessionMetaSnapshotForCurrentSession(tab)
8840 if !ok {
8841 return nil
8842 }
8843 return saveTabSessionMetaSnapshot(snap)
8844 }
8845
8846 func (a *App) tabSessionMetaSnapshotForCurrentSession(tab *WorkspaceTab) (tabSessionMetaSnapshot, bool) {
8847 if tab == nil {
8848 return tabSessionMetaSnapshot{}, false
8849 }
8850 a.mu.RLock()
8851 if tab.ID != "" && a.tabs[tab.ID] != tab {
8852 a.mu.RUnlock()
8853 return tabSessionMetaSnapshot{}, false
8854 }
8855 readOnly := tab.ReadOnly
8856 ctrl := tab.Ctrl
8857 storedPath := strings.TrimSpace(tab.SessionPath)
8858 scope := tab.Scope
8859 workspaceRoot := tab.WorkspaceRoot
8860 topicID := tab.TopicID
8861 topicTitle := tab.TopicTitle
8862 tokenMode := boot.NormalizeTokenMode(tab.tokenMode)
8863 mode := normalizeTabMode(tab.mode)
8864 toolApprovalMode := normalizeToolApprovalMode(tab.toolApprovalMode)
8865 goal := strings.TrimSpace(tab.goal)
8866 a.mu.RUnlock()
8867 if readOnly {
8868 return tabSessionMetaSnapshot{}, false
8869 }
8870
8871 ctrlPath := ""
8872 ctrlDir := ""
8873 activeWork := false
8874 if ctrl != nil {
8875 ctrlPath = strings.TrimSpace(ctrl.SessionPath())
8876 if dir, ok := safeControllerSessionDir(ctrl); ok {
8877 ctrlDir = strings.TrimSpace(dir)
8878 }
8879 status := ctrl.RuntimeStatus()
8880 activeWork = status.Running || status.PendingPrompt || status.BackgroundJobs > 0
8881 mode = tabModeFromAxes(ctrl.PlanMode(), ctrl.AutoApproveTools())
8882 toolApprovalMode = normalizeToolApprovalMode(ctrl.ToolApprovalMode())
8883 if ctrl.GoalStatus() == control.GoalStatusRunning {
8884 goal = strings.TrimSpace(ctrl.Goal())
8885 } else {
8886 goal = ""
8887 }
8888 }
8889
8890 currentPath := ctrlPath
8891 if currentPath == "" {
8892 currentPath = storedPath
8893 }
8894 if currentPath == "" {
8895 return tabSessionMetaSnapshot{}, false
8896 }
8897
8898 sessionDir := desktopSessionDir("")
8899 if workspaceRoot != "" {
8900 sessionDir = desktopSessionDir(workspaceRoot)
8901 } else if ctrlDir != "" {
8902 sessionDir = ctrlDir
8903 }
8904 runtimeDir := sessionDir
8905 if ctrlDir != "" {
8906 if _, _, err := validateSessionPath(ctrlDir, currentPath); err == nil {
8907 runtimeDir = ctrlDir
8908 }
8909 }
8910 if topicID == "" && !activeWork && storedPath != "" && sessionPathHasNoContent(sessionDir, storedPath) {
8911 return tabSessionMetaSnapshot{}, false
8912 }
8913 path := tabSessionMetaPathForSession(runtimeDir, sessionDir, currentPath)
8914 if path == "" {
8915 return tabSessionMetaSnapshot{}, false
8916 }
8917 return tabSessionMetaSnapshot{
8918 path: path,
8919 scope: scope,
8920 workspaceRoot: workspaceRoot,
8921 topicID: topicID,
8922 topicTitle: topicTitle,
8923 tokenMode: tokenMode,
8924 mode: mode,
8925 toolApprovalMode: toolApprovalMode,
8926 goal: goal,
8927 }, true
8928 }
8929
8930 func saveTabSessionMetaSnapshot(snap tabSessionMetaSnapshot) error {
8931 if strings.TrimSpace(snap.path) == "" {
8932 return nil
8933 }
8934 // Read-modify-write on the branch-meta sidecar: hold the per-path meta lock
8935 // so agent-side writers (autosave UpdateSessionMeta, in-flight markers)
8936 // can't interleave and drop fields.
8937 unlock := agent.LockSessionMetaPath(snap.path)
8938 defer unlock()
8939 m, err := agent.EnsureBranchMeta(snap.path)
8940 if err != nil {
8941 return err
8942 }
8943 scope := snap.scope
8944 workspaceRoot := snap.workspaceRoot
8945 if ownerScope, ownerRoot, _, ok := legacyMigrationTargetForDir(filepath.Dir(snap.path)); ok {
8946 if ownerScope == "project" {
8947 scope = ownerScope
8948 workspaceRoot = ownerRoot
8949 }
8950 }
8951 if scope == "project" {
8952 workspaceRoot = normalizeProjectRoot(workspaceRoot)
8953 } else {
8954 scope = "global"
8955 workspaceRoot = ""
8956 }
8957 m.Scope = scope
8958 m.WorkspaceRoot = workspaceRoot
8959 m.TopicID = snap.topicID
8960 m.TopicTitle = snap.topicTitle
8961 m.TokenMode = persistedTabTokenMode(snap.tokenMode)
8962 m.Mode = persistedTabMode(snap.mode)
8963 m.ToolApprovalMode = persistedToolApprovalMode(snap.toolApprovalMode)
8964 m.Goal = strings.TrimSpace(snap.goal)
8965 if err := agent.SaveBranchMetaPreserveUpdated(snap.path, m); err != nil {
8966 return err
8967 }
8968 invalidateTopicSessionIndexForPath(snap.path)
8969 return nil
8970 }
8971
8972 func tabSessionMetaPathForSession(runtimeDir, sessionDir, sessionPath string) string {
8973 sessionPath = strings.TrimSpace(sessionPath)
8974 if sessionPath == "" {
8975 return ""
8976 }
8977 for _, dir := range []string{runtimeDir, sessionDir} {
8978 if resolved, ok := pinnedTabSessionPath(dir, sessionPath); ok {
8979 return resolved
8980 }
8981 }
8982 path := canonicalTabSessionPath(sessionPath)
8983 if filepath.IsAbs(path) {
8984 return path
8985 }
8986 return ""
8987 }
8988
8989 type tabSessionProfile struct {
8990 tokenMode string
8991 mode string
8992 toolApprovalMode string
8993 goal string
8994 }
8995
8996 func defaultTabSessionProfile() tabSessionProfile {
8997 return tabSessionProfile{
8998 tokenMode: boot.TokenModeFull,
8999 mode: "normal",
9000 toolApprovalMode: control.ToolApprovalAsk,
9001 }
9002 }
9003
9004 func tabSessionProfileFromMeta(sessionPath string, meta agent.BranchMeta) tabSessionProfile {
9005 profile := defaultTabSessionProfile()
9006 profile.tokenMode = boot.NormalizeTokenMode(meta.TokenMode)
9007 profile.mode = normalizeTabMode(meta.Mode)
9008 profile.toolApprovalMode = normalizeToolApprovalMode(meta.ToolApprovalMode)
9009 if profile.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(meta.Mode) {
9010 profile.toolApprovalMode = control.ToolApprovalYolo
9011 }
9012 profile.goal = runningTabSessionGoal(sessionPath, meta.Goal)
9013 return profile
9014 }
9015
9016 func loadTabSessionProfile(sessionPath string) tabSessionProfile {
9017 meta, ok, err := agent.LoadBranchMeta(sessionPath)
9018 if err != nil || !ok {
9019 return defaultTabSessionProfile()
9020 }
9021 return tabSessionProfileFromMeta(sessionPath, meta)
9022 }
9023
9024 func applyTabSessionProfile(tab *WorkspaceTab, profile tabSessionProfile) {
9025 if tab == nil {
9026 return
9027 }
9028 tab.tokenMode = boot.NormalizeTokenMode(profile.tokenMode)
9029 tab.mode = normalizeTabMode(profile.mode)
9030 tab.toolApprovalMode = normalizeToolApprovalMode(profile.toolApprovalMode)
9031 if tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(tab.mode) {
9032 tab.toolApprovalMode = control.ToolApprovalYolo
9033 }
9034 tab.mode = tabModeFromAxes(tabModeHasPlan(tab.mode), tab.toolApprovalMode == control.ToolApprovalYolo)
9035 tab.goal = strings.TrimSpace(profile.goal)
9036 }
9037
9038 func persistedTabGoal(tab *WorkspaceTab) string {
9039 goal := strings.TrimSpace(currentTabGoal(tab))
9040 if goal == "" || currentTabGoalStatus(tab) != control.GoalStatusRunning {
9041 return ""
9042 }
9043 return goal
9044 }
9045
9046 type tabSessionGoalState struct {
9047 Goal string `json:"goal,omitempty"`
9048 Status string `json:"status,omitempty"`
9049 }
9050
9051 func runningTabSessionGoal(sessionPath, fallback string) string {
9052 fallback = strings.TrimSpace(fallback)
9053 if fallback == "" {
9054 return ""
9055 }
9056 data, err := readFileUTF8(store.SessionGoalState(sessionPath))
9057 if err != nil {
9058 return fallback
9059 }
9060 var state tabSessionGoalState
9061 if err := json.Unmarshal(data, &state); err != nil {
9062 return fallback
9063 }
9064 switch state.Status {
9065 case control.GoalStatusRunning:
9066 if goal := strings.TrimSpace(state.Goal); goal != "" {
9067 return goal
9068 }
9069 return fallback
9070 case "", control.GoalStatusStopped:
9071 return ""
9072 default:
9073 return ""
9074 }
9075 }
9076
9077 func canonicalTabSessionPath(path string) string {
9078 path = strings.TrimSpace(path)
9079 if path == "" {
9080 return ""
9081 }
9082 if validPath, _, err := validateSessionPath(config.SessionDir(), path); err == nil {
9083 return validPath
9084 }
9085 // Project-scope sessions live outside config.SessionDir(), so validation
9086 // against it always fails. Still normalize the shape: without clean+abs,
9087 // the same file spelled with a different separator or a relative prefix
9088 // splits into distinct runtime keys.
9089 cleaned := filepath.Clean(path)
9090 if abs, err := filepath.Abs(cleaned); err == nil {
9091 return abs
9092 }
9093 return cleaned
9094 }
9095
9096 func (a *App) rememberTabSessionPath(tab *WorkspaceTab, path string) {
9097 path = canonicalTabSessionPath(path)
9098 if tab == nil || path == "" {
9099 return
9100 }
9101 a.mu.Lock()
9102 if current := a.tabs[tab.ID]; current == tab {
9103 tab.SessionPath = path
9104 a.saveTabsLocked()
9105 } else {
9106 tab.SessionPath = path
9107 }
9108 a.mu.Unlock()
9109 }
9110
9111 func (a *App) persistTabSessionPath(tab *WorkspaceTab, path string) {
9112 path = canonicalTabSessionPath(path)
9113 if tab == nil || path == "" {
9114 return
9115 }
9116 if reconciled, ok := a.reconcileTabWithSessionPath(tab, path); ok {
9117 path = canonicalTabSessionPath(reconciled)
9118 }
9119 _ = a.saveTabSessionMeta(tab, path)
9120 a.rememberTabSessionPath(tab, path)
9121 }
9122
9123 func (a *App) knownSessionDirs() []string {
9124 seen := map[string]bool{}
9125 out := []string{}
9126 add := func(dir string) {
9127 dir = strings.TrimSpace(dir)
9128 if dir == "" {
9129 return
9130 }
9131 if abs, err := filepath.Abs(dir); err == nil {
9132 dir = abs
9133 }
9134 if seen[dir] {
9135 return
9136 }
9137 seen[dir] = true
9138 out = append(out, dir)
9139 }
9140 add(config.SessionDir()) // legacy/global sessions from earlier desktop builds
9141 add(desktopSessionDir(globalWorkspaceRoot()))
9142 for _, project := range loadProjectsFile().Projects {
9143 dir := desktopSessionDir(project.Root)
9144 if _, err := os.Stat(dir); os.IsNotExist(err) {
9145 continue // project dir removed or external volume unmounted
9146 }
9147 add(dir)
9148 }
9149 a.mu.RLock()
9150 for _, tab := range a.tabs {
9151 add(tabSessionDir(tab))
9152 }
9153 for _, tab := range a.detachedSessions {
9154 add(tabSessionDir(tab))
9155 }
9156 a.mu.RUnlock()
9157 return out
9158 }
9159
9160 func topicSessionMatchMatchesTarget(match topicSessionMatch, scope, workspaceRoot string) bool {
9161 if scope == "project" {
9162 return match.scope == "project" && sameProjectRoot(match.workspaceRoot, workspaceRoot)
9163 }
9164 return match.scope == "" || match.scope == "global"
9165 }
9166
9167 func (a *App) findTopicSessionForTarget(scope, workspaceRoot, topicID string) (string, string) {
9168 return a.findTopicSessionForTargetByContent(scope, workspaceRoot, topicID, false)
9169 }
9170
9171 func (a *App) findTopicContentSessionForTarget(scope, workspaceRoot, topicID string) (string, string) {
9172 return a.findTopicSessionForTargetByContent(scope, workspaceRoot, topicID, true)
9173 }
9174
9175 func (a *App) findTopicSessionForTargetByContent(scope, workspaceRoot, topicID string, requireContent bool) (string, string) {
9176 topicID = strings.TrimSpace(topicID)
9177 if topicID == "" {
9178 return "", ""
9179 }
9180 type candidate struct {
9181 match topicSessionMatch
9182 dir string
9183 }
9184 var candidates []candidate
9185 for _, dir := range a.knownSessionDirs() {
9186 for _, match := range topicSessionMatches(dir, topicID) {
9187 if !topicSessionMatchMatchesTarget(match, scope, workspaceRoot) {
9188 continue
9189 }
9190 candidates = append(candidates, candidate{match: match, dir: dir})
9191 }
9192 }
9193 sort.Slice(candidates, func(i, j int) bool {
9194 a, b := candidates[i].match, candidates[j].match
9195 if !a.updatedAt.Equal(b.updatedAt) {
9196 return a.updatedAt.After(b.updatedAt)
9197 }
9198 return a.path < b.path
9199 })
9200 // Content-bearing sessions outrank content-free ones regardless of
9201 // updatedAt: a freshly created empty session must not hijack the topic
9202 // from the conversation the user actually had (#7305). The content probe
9203 // reads session files, so it walks newest-first and stops at the first
9204 // hit — the common case checks one file.
9205 for _, c := range candidates {
9206 if sessionFileHasConversationContent(c.match.path) {
9207 return c.match.path, c.dir
9208 }
9209 }
9210 if requireContent || len(candidates) == 0 {
9211 return "", ""
9212 }
9213 return candidates[0].match.path, candidates[0].dir
9214 }
9215
9216 type topicSessionFileSignature struct {
9217 Name string `json:"name"`
9218 Size int64 `json:"size"`
9219 ModTime int64 `json:"mod_time"`
9220 }
9221
9222 type topicSessionMatch struct {
9223 path string
9224 updatedAt time.Time
9225 scope string
9226 workspaceRoot string
9227 }
9228
9229 type topicSessionDirIndex struct {
9230 signature []topicSessionFileSignature
9231 byTopic map[string][]topicSessionMatch
9232 }
9233
9234 // sessionListCache caches ListSessions results per directory so that
9235 // ListProjectTree (called on every sidebar render) does not re-read every
9236 // session dir from disk. Session mutations invalidate only their own
9237 // directories; the global generation remains a correctness fallback for
9238 // changes whose affected directories are not known.
9239 type sessionListCacheEntry struct {
9240 infos []agent.SessionInfo
9241 titles map[string]string
9242 cachedAt time.Time
9243 }
9244
9245 // Scoped invalidation handles in-process mutations immediately. The TTL is the
9246 // low-frequency reconciliation path for CLI, older Desktop, and external
9247 // process writes that cannot emit an event into this process.
9248 const sessionListCacheTTL = 30 * time.Second
9249
9250 type sessionListCacheToken struct {
9251 globalVersion uint64
9252 dirVersion uint64
9253 }
9254
9255 type sessionListCache struct {
9256 mu sync.Mutex
9257 byDir map[string]sessionListCacheEntry
9258 dirVersions map[string]uint64
9259 nextDirVersion uint64
9260 version atomic.Uint64
9261 }
9262
9263 func (c *sessionListCache) get(dir string) ([]agent.SessionInfo, map[string]string, bool) {
9264 dir = sessionListCacheDirKey(dir)
9265 c.mu.Lock()
9266 defer c.mu.Unlock()
9267 e, ok := c.byDir[dir]
9268 if !ok {
9269 return nil, nil, false
9270 }
9271 if e.cachedAt.IsZero() || time.Since(e.cachedAt) >= sessionListCacheTTL {
9272 delete(c.byDir, dir)
9273 return nil, nil, false
9274 }
9275 return e.infos, e.titles, true
9276 }
9277
9278 func (c *sessionListCache) versionToken(dir string) sessionListCacheToken {
9279 dir = sessionListCacheDirKey(dir)
9280 c.mu.Lock()
9281 defer c.mu.Unlock()
9282 if c.dirVersions == nil {
9283 c.dirVersions = map[string]uint64{}
9284 }
9285 dirVersion := c.dirVersions[dir]
9286 if dirVersion == 0 {
9287 c.nextDirVersion++
9288 dirVersion = c.nextDirVersion
9289 c.dirVersions[dir] = dirVersion
9290 }
9291 return sessionListCacheToken{
9292 globalVersion: c.version.Load(),
9293 dirVersion: dirVersion,
9294 }
9295 }
9296
9297 func (c *sessionListCache) put(dir string, infos []agent.SessionInfo, titles map[string]string, token sessionListCacheToken) {
9298 dir = sessionListCacheDirKey(dir)
9299 if c.version.Load() != token.globalVersion {
9300 return
9301 }
9302 c.mu.Lock()
9303 defer c.mu.Unlock()
9304 if c.version.Load() != token.globalVersion || c.dirVersions[dir] != token.dirVersion {
9305 return
9306 }
9307 if c.byDir == nil {
9308 c.byDir = map[string]sessionListCacheEntry{}
9309 }
9310 c.byDir[dir] = sessionListCacheEntry{infos: infos, titles: titles, cachedAt: time.Now()}
9311 }
9312
9313 func (c *sessionListCache) invalidateDirs(dirs ...string) bool {
9314 keys := make(map[string]struct{}, len(dirs))
9315 for _, dir := range dirs {
9316 if key := sessionListCacheDirKey(dir); key != "" {
9317 keys[key] = struct{}{}
9318 }
9319 }
9320 if len(keys) == 0 {
9321 return false
9322 }
9323 c.mu.Lock()
9324 defer c.mu.Unlock()
9325 if c.dirVersions == nil {
9326 c.dirVersions = map[string]uint64{}
9327 }
9328 for key := range keys {
9329 c.nextDirVersion++
9330 c.dirVersions[key] = c.nextDirVersion
9331 delete(c.byDir, key)
9332 }
9333 return true
9334 }
9335
9336 // forgetDirs drops directories that are no longer part of the project
9337 // lifecycle. Tokens are never reused, so an in-flight scan from before the
9338 // removal cannot repopulate the cache after the entry is forgotten or re-added.
9339 func (c *sessionListCache) forgetDirs(dirs ...string) bool {
9340 keys := make(map[string]struct{}, len(dirs))
9341 for _, dir := range dirs {
9342 if key := sessionListCacheDirKey(dir); key != "" {
9343 keys[key] = struct{}{}
9344 }
9345 }
9346 if len(keys) == 0 {
9347 return false
9348 }
9349 c.mu.Lock()
9350 defer c.mu.Unlock()
9351 for key := range keys {
9352 delete(c.byDir, key)
9353 delete(c.dirVersions, key)
9354 }
9355 return true
9356 }
9357
9358 func (c *sessionListCache) invalidate() {
9359 c.mu.Lock()
9360 c.version.Add(1)
9361 c.byDir = map[string]sessionListCacheEntry{}
9362 c.dirVersions = map[string]uint64{}
9363 c.mu.Unlock()
9364 }
9365
9366 func sessionListCacheDirKey(dir string) string {
9367 return projectRootKey(dir)
9368 }
9369
9370 func sessionListCacheDirForPath(path string) string {
9371 path = strings.TrimSpace(path)
9372 if path == "" {
9373 return ""
9374 }
9375 return filepath.Dir(path)
9376 }
9377
9378 var projectSessionCache = &sessionListCache{byDir: map[string]sessionListCacheEntry{}}
9379
9380 // mergeSessionInfos merges one directory's session listing into the maps used by
9381 // ListProjectTree. The result collection loop calls it serially.
9382 func mergeSessionInfos(dir string, infos []agent.SessionInfo, titles map[string]string, sessionInfos map[string]agent.SessionInfo, sessionTitles map[string]string, topicSummaries map[string]topicSummary) {
9383 for _, info := range infos {
9384 sessionKey := sessionRuntimeKey(info.Path)
9385 if sessionKey != "" {
9386 sessionInfos[sessionKey] = info
9387 title := strings.TrimSpace(info.CustomTitle)
9388 if title == "" {
9389 title = titles[filepath.Base(info.Path)]
9390 }
9391 sessionTitles[sessionKey] = title
9392 }
9393 if strings.TrimSpace(info.TopicID) == "" {
9394 continue
9395 }
9396 key := topicSummaryKey(info.Scope, info.WorkspaceRoot, info.TopicID)
9397 summary := topicSummaries[key]
9398 lastActivityAt := info.LastActivityAt.UnixMilli()
9399 if sessionInfoIsAutomaticRecovery(info) {
9400 // A covered conflict copy duplicates its parent, so its turns must not
9401 // be added. Any branch with unique content keeps the topic visible.
9402 if sessionInfoIsUnmodifiedRecoveryCopy(info, dir) {
9403 summary.hasRecoveryOnly = true
9404 } else {
9405 summary.hasAdoptedRecovery = true
9406 if info.Turns > summary.adoptedRecoveryTurns {
9407 summary.adoptedRecoveryTurns = info.Turns
9408 }
9409 }
9410 if lastActivityAt > summary.lastActivityAt {
9411 summary.lastActivityAt = lastActivityAt
9412 }
9413 topicSummaries[key] = summary
9414 continue
9415 }
9416 summary.hasNormalSession = true
9417 summary.turns += info.Turns
9418 if lastActivityAt > summary.lastActivityAt {
9419 summary.lastActivityAt = lastActivityAt
9420 }
9421 topicSummaries[key] = summary
9422 }
9423 }
9424
9425 var topicSessionIndexCache = struct {
9426 sync.Mutex
9427 byDir map[string]topicSessionDirIndex
9428 }{byDir: map[string]topicSessionDirIndex{}}
9429
9430 func topicSessionDirKey(dir string) string {
9431 dir = strings.TrimSpace(dir)
9432 if dir == "" {
9433 return ""
9434 }
9435 if abs, err := filepath.Abs(dir); err == nil {
9436 return abs
9437 }
9438 return dir
9439 }
9440
9441 func topicSessionDirSnapshot(dir string) ([]topicSessionFileSignature, []string, error) {
9442 entries, err := os.ReadDir(dir)
9443 if err != nil {
9444 return nil, nil, err
9445 }
9446 signature := []topicSessionFileSignature{}
9447 sessionNames := []string{}
9448 for _, entry := range entries {
9449 name := entry.Name()
9450 if entry.IsDir() {
9451 continue
9452 }
9453 isSession := store.IsSessionTranscriptName(name)
9454 isMeta := strings.HasSuffix(name, ".jsonl.meta")
9455 if !isSession && !isMeta {
9456 continue
9457 }
9458 info, err := entry.Info()
9459 if err != nil {
9460 continue
9461 }
9462 signature = append(signature, topicSessionFileSignature{
9463 Name: name,
9464 Size: info.Size(),
9465 ModTime: info.ModTime().UnixNano(),
9466 })
9467 if isSession {
9468 sessionNames = append(sessionNames, name)
9469 }
9470 }
9471 sort.Slice(signature, func(i, j int) bool {
9472 return signature[i].Name < signature[j].Name
9473 })
9474 sort.Strings(sessionNames)
9475 return signature, sessionNames, nil
9476 }
9477
9478 func topicSessionSignaturesEqual(a, b []topicSessionFileSignature) bool {
9479 if len(a) != len(b) {
9480 return false
9481 }
9482 for i := range a {
9483 if a[i] != b[i] {
9484 return false
9485 }
9486 }
9487 return true
9488 }
9489
9490 func topicSessionIndexForDir(dir string) (topicSessionDirIndex, error) {
9491 key := topicSessionDirKey(dir)
9492 if key == "" {
9493 return topicSessionDirIndex{}, nil
9494 }
9495 signature, sessionNames, err := topicSessionDirSnapshot(key)
9496 if err != nil {
9497 if os.IsNotExist(err) {
9498 return topicSessionDirIndex{}, nil
9499 }
9500 return topicSessionDirIndex{}, err
9501 }
9502 topicSessionIndexCache.Lock()
9503 cached, ok := topicSessionIndexCache.byDir[key]
9504 if ok && topicSessionSignaturesEqual(cached.signature, signature) {
9505 topicSessionIndexCache.Unlock()
9506 return cached, nil
9507 }
9508 topicSessionIndexCache.Unlock()
9509
9510 index := topicSessionDirIndex{
9511 signature: signature,
9512 byTopic: map[string][]topicSessionMatch{},
9513 }
9514 for _, name := range sessionNames {
9515 path := filepath.Join(key, name)
9516 meta, ok, err := agent.LoadBranchMeta(path)
9517 if err != nil || !ok {
9518 continue
9519 }
9520 topicID := strings.TrimSpace(meta.TopicID)
9521 if topicID == "" {
9522 continue
9523 }
9524 index.byTopic[topicID] = append(index.byTopic[topicID], topicSessionMatch{
9525 path: path,
9526 updatedAt: meta.UpdatedAt,
9527 scope: meta.DefaultScope(),
9528 workspaceRoot: meta.WorkspaceRoot,
9529 })
9530 }
9531
9532 topicSessionIndexCache.Lock()
9533 topicSessionIndexCache.byDir[key] = index
9534 topicSessionIndexCache.Unlock()
9535 return index, nil
9536 }
9537
9538 func topicSessionIndexHasContentTopic(index topicSessionDirIndex, topicID string) bool {
9539 matches := index.byTopic[strings.TrimSpace(topicID)]
9540 for _, match := range matches {
9541 if sessionFileHasConversationContent(match.path) {
9542 return true
9543 }
9544 }
9545 return false
9546 }
9547
9548 // topicSessionIndexHasForeignLeaseTopic reports whether any session file
9549 // indexed under topicID is currently lease-held by a runtime other than this
9550 // process. A blank topic can still be lease-held — its session lease keeper
9551 // keeps a leftover blank tab's lease alive across a hide-to-tray close, and a
9552 // stale-but-live holder blocks a genuinely new session from ever settling on
9553 // this path. Reusing it anyway would make the "new" tab collide with that
9554 // holder: every lease-gated switch (effort/model/token mode) would fail as if
9555 // a foreign window owned it, and creating another "new" conversation would
9556 // keep re-picking the same stuck topic (#6028, #6109).
9557 func topicSessionIndexHasForeignLeaseTopic(index topicSessionDirIndex, topicID string) bool {
9558 matches := index.byTopic[strings.TrimSpace(topicID)]
9559 for _, match := range matches {
9560 if agent.SessionLeaseHeldByOtherRuntime(match.path) {
9561 return true
9562 }
9563 }
9564 return false
9565 }
9566
9567 func topicSessionMatches(dir, topicID string) []topicSessionMatch {
9568 index, err := topicSessionIndexForDir(dir)
9569 if err != nil {
9570 return nil
9571 }
9572 matches := index.byTopic[strings.TrimSpace(topicID)]
9573 if len(matches) == 0 {
9574 return nil
9575 }
9576 out := make([]topicSessionMatch, 0, len(matches))
9577 for _, match := range matches {
9578 if agent.IsCleanupPending(match.path) {
9579 continue
9580 }
9581 out = append(out, match)
9582 }
9583 if len(out) == 0 {
9584 return nil
9585 }
9586 return out
9587 }
9588
9589 func invalidateTopicSessionIndex(dir string) {
9590 key := topicSessionDirKey(dir)
9591 if key == "" {
9592 return
9593 }
9594 topicSessionIndexCache.Lock()
9595 delete(topicSessionIndexCache.byDir, key)
9596 topicSessionIndexCache.Unlock()
9597 }
9598
9599 func invalidateTopicSessionIndexForPath(path string) {
9600 path = strings.TrimSpace(path)
9601 if path == "" {
9602 return
9603 }
9604 invalidateTopicSessionIndex(filepath.Dir(path))
9605 }
9606
9607 // findTopicSession returns the most recently updated .jsonl file whose .meta
9608 // carries the given topicID, using a directory-level sidecar index cache.
9609 func findTopicSession(dir, topicID string) string {
9610 if topicID == "" || dir == "" {
9611 return ""
9612 }
9613 var bestPath string
9614 var bestTime time.Time
9615 for _, match := range topicSessionMatches(dir, topicID) {
9616 if match.updatedAt.After(bestTime) {
9617 bestTime = match.updatedAt
9618 bestPath = match.path
9619 }
9620 }
9621 return bestPath
9622 }
9623
9623 lines GO