Files
2026-07-13 13:00:08 +08:00

8200 lines
238 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"unicode"
"github.com/wailsapp/wails/v2/pkg/runtime"
"reasonix/internal/agent"
"reasonix/internal/boot"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/eventwire"
"reasonix/internal/fileutil"
"reasonix/internal/notify"
"reasonix/internal/provider"
"reasonix/internal/store"
)
// --- WorkspaceTab -----------------------------------------------------------
// WorkspaceTab is one open conversation tab in the desktop. Each tab owns an
// independent controller (its own agent, session, tool registry, plugin host,
// memory, permissions) scoped to a workspace root, so multiple projects and
// topics can be active concurrently without interfering.
type WorkspaceTab struct {
ID string // stable random id
Scope string // "project" | "global"
WorkspaceRoot string // project root dir (empty for global)
SharedHostKey string // opaque key for the shared plugin host (set by buildTabController)
TopicID string // topic within the project
TopicTitle string // display title
SessionPath string // exact .jsonl file this tab continues
ReadOnly bool // true for external channel transcripts opened for browsing
Ctrl control.SessionAPI // nil while booting / on error
Label string // model label (for the tab badge)
Ready bool // true once boot.Build completes
StartupErr string // build error, surfaced to the frontend
StartupErrLeaseHeld bool // true when StartupErr can be retried after a session lease releases
sessionLease *agent.SessionLease
sessionLeaseMu sync.Mutex
sink *tabEventSink // routes events with this tab's ID
buildCancel context.CancelFunc // cancels in-flight boot for tabs removed before Ready
buildGeneration uint64 // identifies the current in-flight build
removed bool // set when the visible tab is pruned/closed before build completes
reconcileMu sync.Mutex // serializes stale controller workspace repair for this tab
turnStartMu sync.Mutex // serializes foreground turn admission for this tab
ActivityStatus string // transient project-tree status for the in-flight turn
// Per-turn autosave per tab.
saveMu sync.Mutex
saving bool
saveAgain bool
saveFailures int
// lastAutosaveWarnAt debounces the user-facing autosave-failure notice:
// a persistently failing disk (AV hold, full volume) otherwise emits a
// chat warning for every completed turn. Logs are never debounced.
lastAutosaveWarnAt time.Time
// closing is set under saveMu when the tab is being torn down. Once set,
// tabSnapshotLoop stops taking new snapshot work and CloseTab waits on
// saveCond until any in-flight snapshot finishes - so no background
// snapshot can write a session file back to disk after CloseTab returns.
// Without this, deleting a just-closed session races that write and the
// session "resurrects" (#4384).
closing bool
saveCond *sync.Cond
// readTelemetry tracks files read during this tab's session.
readTelemetry []readFileRecord
usageTelemetry sessionUsageStats
// telemetrySessionKey is the sessionRuntimeKey the telemetry above belongs
// to. Controller-side session rotations (typed /new, bot /reset) bypass the
// App bindings, so telemetry writers and readers re-key through
// syncTelemetryToSession before trusting the in-memory totals — otherwise a
// previous session's cost keeps accumulating under the new session and gets
// persisted into its sidecar (#5850).
telemetrySessionKey string
telemMu sync.Mutex
// plannerDisplay keeps display-only planner output for the in-flight turn.
// The executor session remains the model-facing transcript; this sidecar
// lets frontend history restore the planner cards after a rebuild/reload.
plannerDisplay []HistoryMessage
plannerDisplayTools map[string]string
plannerDisplayMu sync.Mutex
model string // active model ref (for meta)
effort *string
tokenMode string
mode string // "normal" | "plan" | "yolo" | "plan-yolo"; yolo/full access is runtime-only
goal string
toolApprovalMode string
disabledMCP map[string]ServerView
mcpOrder []string
}
const (
topicStatusThinking = "thinking"
topicStatusStreaming = "streaming"
topicStatusWaitingConfirmation = "waiting_confirmation"
topicStatusBackgroundJob = "background_job"
topicStatusPaused = "paused"
topicStatusError = "error"
)
type readFileRecord struct {
Path string `json:"path"`
Turn int `json:"turn"`
Time int64 `json:"time"`
Offset int `json:"offset,omitempty"`
Limit int `json:"limit,omitempty"`
Truncated bool `json:"truncated,omitempty"`
}
type sessionUsageStats struct {
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
ReasoningTokens int `json:"reasoningTokens"`
CacheHitTokens int `json:"cacheHitTokens"`
CacheMissTokens int `json:"cacheMissTokens"`
RequestCount int `json:"requestCount"`
ElapsedMs int64 `json:"elapsedMs"`
SessionCost float64 `json:"sessionCost,omitempty"`
SessionCurrency string `json:"sessionCurrency,omitempty"`
SessionCostUsd float64 `json:"sessionCostUsd,omitempty"`
Sources map[string]usageSourceStats `json:"sources,omitempty"`
activeTurnStartedAt int64
sourceSessionCache map[string]sourceSessionCacheCounters
}
type usageSourceStats struct {
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
ReasoningTokens int `json:"reasoningTokens"`
CacheHitTokens int `json:"cacheHitTokens"`
CacheMissTokens int `json:"cacheMissTokens"`
RequestCount int `json:"requestCount"`
SessionCost float64 `json:"sessionCost,omitempty"`
SessionCurrency string `json:"sessionCurrency,omitempty"`
SessionCostUsd float64 `json:"sessionCostUsd,omitempty"`
}
type sourceSessionCacheCounters struct {
Hit int
Miss int
}
func cloneSessionUsageStats(in sessionUsageStats) sessionUsageStats {
out := in
if len(in.Sources) > 0 {
out.Sources = make(map[string]usageSourceStats, len(in.Sources))
for source, stats := range in.Sources {
out.Sources[source] = stats
}
}
if len(in.sourceSessionCache) > 0 {
out.sourceSessionCache = make(map[string]sourceSessionCacheCounters, len(in.sourceSessionCache))
for source, counters := range in.sourceSessionCache {
out.sourceSessionCache[source] = counters
}
}
return out
}
func (s *sessionUsageStats) cacheTokenDelta(source string, u *provider.Usage, sessionHit, sessionMiss int) (hit, miss int) {
if u != nil {
hit = u.CacheHitTokens
miss = u.CacheMissTokens
}
if source != event.UsageSourceExecutor && source != event.UsageSourcePlanner {
return hit, miss
}
if sessionHit+sessionMiss <= 0 {
return hit, miss
}
if s.sourceSessionCache == nil {
s.sourceSessionCache = map[string]sourceSessionCacheCounters{}
}
prev, ok := s.sourceSessionCache[source]
s.sourceSessionCache[source] = sourceSessionCacheCounters{Hit: sessionHit, Miss: sessionMiss}
if !ok {
return sessionHit, sessionMiss
}
if sessionHit < prev.Hit || sessionMiss < prev.Miss {
if hit+miss > 0 {
return hit, miss
}
return sessionHit, sessionMiss
}
return sessionHit - prev.Hit, sessionMiss - prev.Miss
}
type tabTelemetrySnapshot struct {
Version int `json:"version"`
ReadFiles []readFileRecord `json:"readFiles"`
Usage sessionUsageStats `json:"usage"`
}
func cloneStringPtr(v *string) *string {
if v == nil {
return nil
}
cp := *v
return &cp
}
func cloneServerViewMap(in map[string]ServerView) map[string]ServerView {
out := make(map[string]ServerView, len(in))
for name, view := range in {
view.EnvKeys = append([]string(nil), view.EnvKeys...)
view.HeaderKeys = append([]string(nil), view.HeaderKeys...)
out[name] = view
}
return out
}
func (t *WorkspaceTab) currentSessionPath() string {
if t == nil {
return ""
}
if t.Ctrl != nil {
if path := strings.TrimSpace(t.Ctrl.SessionPath()); path != "" {
return path
}
}
return strings.TrimSpace(t.SessionPath)
}
func (t *WorkspaceTab) hasActiveRuntimeWork() bool {
if t == nil || t.Ctrl == nil {
return false
}
status := t.Ctrl.RuntimeStatus()
return status.Running || status.PendingPrompt || status.BackgroundJobs > 0
}
// sessionRuntimeKey is the comparison/map key for "same session" checks. It
// layers agent.CanonicalSessionPath on top of the desktop path normalization
// so the key matches the form held by session leases (lowercased on Windows).
// Comparing a lease's Path() against a raw tab path without this fold made
// every rebuild on Windows look like a foreign holder (self-lock, #5999).
// Keys are identities only — never use them as display or file paths.
func sessionRuntimeKey(path string) string {
return agent.CanonicalSessionPath(canonicalTabSessionPath(path))
}
var sessionLeaseAcquireHookForTest func()
func (t *WorkspaceTab) ensureSessionLease(path string) error {
if t == nil || t.ReadOnly {
return nil
}
key := sessionRuntimeKey(path)
if key == "" {
return nil
}
t.sessionLeaseMu.Lock()
if t.sessionLease != nil && sessionRuntimeKey(t.sessionLease.Path()) == key {
t.sessionLeaseMu.Unlock()
return nil
}
lease, err := agent.TryAcquireSessionLease(key)
if err != nil {
t.sessionLeaseMu.Unlock()
return err
}
if hook := sessionLeaseAcquireHookForTest; hook != nil {
hook()
}
old := t.sessionLease
t.sessionLease = lease
t.sessionLeaseMu.Unlock()
if old != nil {
old.Release()
}
return nil
}
func (t *WorkspaceTab) releaseSessionLease() {
if t == nil {
return
}
t.sessionLeaseMu.Lock()
lease := t.sessionLease
t.sessionLease = nil
t.sessionLeaseMu.Unlock()
if lease != nil {
lease.Release()
}
}
// takeSessionLease removes and returns the tab's current lease WITHOUT
// releasing it, so ownership can transfer to another holder. All access to
// t.sessionLease must go through sessionLeaseMu; never read or assign the
// field directly outside these helpers.
func (t *WorkspaceTab) takeSessionLease() *agent.SessionLease {
if t == nil {
return nil
}
t.sessionLeaseMu.Lock()
lease := t.sessionLease
t.sessionLease = nil
t.sessionLeaseMu.Unlock()
return lease
}
// adoptSessionLease installs lease as the tab's session lease, releasing any
// previously held lease unless it is the very same lease. A nil tab releases
// the lease immediately so ownership is never dropped on the floor.
func (t *WorkspaceTab) adoptSessionLease(lease *agent.SessionLease) {
if t == nil {
if lease != nil {
lease.Release()
}
return
}
t.sessionLeaseMu.Lock()
old := t.sessionLease
t.sessionLease = lease
t.sessionLeaseMu.Unlock()
if old != nil && old != lease {
old.Release()
}
}
// sessionLeaseRuntimeKey reports the runtime key of the currently held lease,
// or "" when no lease is held. Safe to call while holding App.mu: the lock
// order App.mu → sessionLeaseMu has no reverse path (the lease helpers never
// touch App state while holding sessionLeaseMu).
func (t *WorkspaceTab) sessionLeaseRuntimeKey() string {
if t == nil {
return ""
}
t.sessionLeaseMu.Lock()
defer t.sessionLeaseMu.Unlock()
if t.sessionLease == nil {
return ""
}
return sessionRuntimeKey(t.sessionLease.Path())
}
// releaseSessionLeaseForKey releases the tab's lease only when it is bound to
// key. Superseded builds clean up with this instead of releaseSessionLease:
// on a removed tab the keys match and the lease is released as before, but
// when a session rebind superseded the build, the rebind's replacement build
// holds a lease for a *different* session key (rebind early-returns on equal
// keys), and releasing that here would strip the live session's protection.
func (t *WorkspaceTab) releaseSessionLeaseForKey(key string) {
if t == nil || key == "" {
return
}
t.sessionLeaseMu.Lock()
lease := t.sessionLease
if lease == nil || sessionRuntimeKey(lease.Path()) != key {
t.sessionLeaseMu.Unlock()
return
}
t.sessionLease = nil
t.sessionLeaseMu.Unlock()
lease.Release()
}
func detachedRuntimeTabID(key string) string {
sum := sha256.Sum256([]byte(key))
return "detached_" + hex.EncodeToString(sum[:8])
}
func (a *App) ensureDetachedSessionsLocked() {
if a.detachedSessions == nil {
a.detachedSessions = map[string]*WorkspaceTab{}
}
}
func (a *App) runtimeTabsLocked() []*WorkspaceTab {
seen := map[*WorkspaceTab]bool{}
out := make([]*WorkspaceTab, 0, len(a.tabs)+len(a.detachedSessions))
for _, tab := range a.tabs {
if tab != nil && !seen[tab] {
seen[tab] = true
out = append(out, tab)
}
}
for _, tab := range a.detachedSessions {
if tab != nil && !seen[tab] {
seen[tab] = true
out = append(out, tab)
}
}
return out
}
func (a *App) tabByEventSinkIDLocked(tabID string) *WorkspaceTab {
if tab := a.tabs[tabID]; tab != nil {
return tab
}
for _, tab := range a.detachedSessions {
if tab != nil && tab.ID == tabID {
return tab
}
}
return nil
}
func (a *App) detachSessionRuntime(tab *WorkspaceTab) bool {
if tab == nil {
return false
}
a.mu.RLock()
ctrl := tab.Ctrl
fallbackPath := strings.TrimSpace(tab.SessionPath)
sink := tab.sink
a.mu.RUnlock()
path := fallbackPath
if ctrl != nil {
if p := strings.TrimSpace(ctrl.SessionPath()); p != "" {
path = p
}
}
key := sessionRuntimeKey(path)
if key == "" {
return false
}
if sink != nil {
sink.clearContext()
}
a.mu.Lock()
a.ensureDetachedSessionsLocked()
tab.SessionPath = canonicalTabSessionPath(path)
a.detachedSessions[key] = tab
a.mu.Unlock()
return true
}
// cloneDetachedRuntimeTab copies a running tab's runtime state into a fresh
// detached tab. Callers must hold a.mu: the copied fields (Ctrl, Ready,
// ActivityStatus, disabledMCP, ...) are written under a.mu by bound methods
// and the event sink, and the disabledMCP map read would otherwise race those
// writers. The session lease is transferred separately by the caller through
// the sessionLeaseMu helpers. key is the runtime identity (map key / tab id
// hash); path is the real session path — keys are case-folded on Windows and
// must not leak into SessionPath, which is displayed and persisted.
func cloneDetachedRuntimeTab(tab *WorkspaceTab, key, path string) *WorkspaceTab {
if tab == nil {
return nil
}
tab.telemMu.Lock()
readTelemetry := append([]readFileRecord(nil), tab.readTelemetry...)
usageTelemetry := cloneSessionUsageStats(tab.usageTelemetry)
telemetrySessionKey := tab.telemetrySessionKey
tab.telemMu.Unlock()
return &WorkspaceTab{
ID: detachedRuntimeTabID(key),
Scope: tab.Scope,
WorkspaceRoot: tab.WorkspaceRoot,
SharedHostKey: tab.SharedHostKey,
TopicID: tab.TopicID,
TopicTitle: tab.TopicTitle,
SessionPath: canonicalTabSessionPath(path),
Ctrl: tab.Ctrl,
Label: tab.Label,
Ready: tab.Ready,
StartupErr: tab.StartupErr,
StartupErrLeaseHeld: tab.StartupErrLeaseHeld,
sink: tab.sink,
ActivityStatus: tab.ActivityStatus,
readTelemetry: readTelemetry,
usageTelemetry: usageTelemetry,
telemetrySessionKey: telemetrySessionKey,
model: tab.model,
effort: cloneStringPtr(tab.effort),
tokenMode: tab.tokenMode,
mode: tab.mode,
goal: tab.goal,
toolApprovalMode: tab.toolApprovalMode,
disabledMCP: cloneServerViewMap(tab.disabledMCP),
mcpOrder: append([]string(nil), tab.mcpOrder...),
}
}
func (a *App) detachRuntimeForReplacement(tab *WorkspaceTab) bool {
if tab == nil {
return false
}
// One a.mu critical section covers the membership check, the field
// snapshot, the lease/sink handover, and the re-publication:
// - the clone reads fields that bound methods and the event sink write
// under a.mu (ActivityStatus every event, disabledMCP is a map);
// - inserting the clone without re-checking a.tabs would resurrect a
// runtime that DeleteSession/TrashTopic/RemoveWorkspace already
// unlinked and closed (the "session resurrects" class, #4384);
// - publishing before the lease/sink handover would let a concurrent
// attachExistingSessionRuntime claim a half-initialized clone.
// The lease transfer stays deadlock-safe here: neither side holds a lease
// to release, so no lease I/O runs under a.mu.
a.mu.Lock()
if tab.removed || a.tabs[tab.ID] != tab {
a.mu.Unlock()
return false
}
sourcePath := tab.currentSessionPath()
key := sessionRuntimeKey(sourcePath)
if key == "" {
a.mu.Unlock()
return false
}
detached := cloneDetachedRuntimeTab(tab, key, sourcePath)
if detached == nil {
a.mu.Unlock()
return false
}
// Transfer lease ownership through the locked helpers: a concurrent
// ensureSessionLease (blank-session boot, recovery callback) must never
// observe a torn pointer or have its freshly acquired lease clobbered.
detached.adoptSessionLease(tab.takeSessionLease())
if detached.sink != nil {
detached.sink.setBinding(detached.ID, nil)
// clearContext (locked nil + drain the queued emitter), not a bare
// ctx=nil: the latter both data-races s.ctx and leaves already-queued
// events to flush onto the rebound tab after this session is backgrounded
// (#5352 — stale "AI 不断输出" on the now-visible session).
detached.sink.clearContext()
}
a.ensureDetachedSessionsLocked()
a.detachedSessions[key] = detached
a.mu.Unlock()
return true
}
// applyRuntimeTab moves source's runtime (controller, sink, lease, telemetry)
// onto target. path is the real session path for display/persistence; the
// case-folded runtime key must never be written into SessionPath.
func applyRuntimeTab(target, source *WorkspaceTab, path string, wailsCtx context.Context, app *App) {
if target == nil || source == nil {
return
}
source.telemMu.Lock()
readTelemetry := append([]readFileRecord(nil), source.readTelemetry...)
usageTelemetry := cloneSessionUsageStats(source.usageTelemetry)
telemetrySessionKey := source.telemetrySessionKey
source.telemMu.Unlock()
if source.sink != nil {
source.sink.setBinding(target.ID, app)
source.sink.setContext(wailsCtx)
}
target.Ctrl = source.Ctrl
target.sink = source.sink
target.adoptSessionLease(source.takeSessionLease())
target.SessionPath = canonicalTabSessionPath(path)
target.SharedHostKey = source.SharedHostKey
target.Label = source.Label
target.Ready = true
clearTabStartupError(target)
target.ActivityStatus = source.ActivityStatus
target.model = source.model
target.effort = cloneStringPtr(source.effort)
target.tokenMode = source.tokenMode
target.mode = source.mode
target.goal = source.goal
target.toolApprovalMode = source.toolApprovalMode
target.disabledMCP = cloneServerViewMap(source.disabledMCP)
target.mcpOrder = append([]string(nil), source.mcpOrder...)
target.readTelemetry = readTelemetry
target.usageTelemetry = usageTelemetry
target.telemetrySessionKey = telemetrySessionKey
}
func (a *App) attachExistingSessionRuntime(tab *WorkspaceTab, path string, wailsCtx context.Context) bool {
key := sessionRuntimeKey(path)
if tab == nil || key == "" {
return false
}
a.mu.Lock()
if tab.removed || a.tabs[tab.ID] != tab {
a.mu.Unlock()
return false
}
detached := a.detachedSessions[key]
if detached != nil {
delete(a.detachedSessions, key)
applyRuntimeTab(tab, detached, path, wailsCtx, a)
if current := a.tabs[tab.ID]; current == tab {
a.saveTabsLocked()
}
attachedCtrl := tab.Ctrl
a.mu.Unlock()
if attachedCtrl != nil {
attachedCtrl.ReplayPendingPrompts()
}
return true
}
var source *WorkspaceTab
for _, candidate := range a.tabs {
if candidate == nil || candidate == tab {
continue
}
if sessionRuntimeKey(candidate.currentSessionPath()) == key {
source = candidate
break
}
}
if source == nil {
a.mu.Unlock()
return false
}
delete(a.tabs, source.ID)
a.removeTabOrderLocked(source.ID)
if a.activeTabID == source.ID {
a.activeTabID = tab.ID
}
applyRuntimeTab(tab, source, path, wailsCtx, a)
a.saveTabsLocked()
attachedCtrl := tab.Ctrl
a.mu.Unlock()
if attachedCtrl != nil {
attachedCtrl.ReplayPendingPrompts()
}
return true
}
func (t *WorkspaceTab) recordReadFile(rec readFileRecord) {
t.telemMu.Lock()
t.readTelemetry = append(t.readTelemetry, rec)
t.telemMu.Unlock()
}
func (t *WorkspaceTab) recordTurnStarted(now int64) {
t.telemMu.Lock()
if t.usageTelemetry.activeTurnStartedAt == 0 {
t.usageTelemetry.activeTurnStartedAt = now
}
t.telemMu.Unlock()
}
func (t *WorkspaceTab) recordTurnDone(now int64) {
t.telemMu.Lock()
if started := t.usageTelemetry.activeTurnStartedAt; started > 0 && now >= started {
t.usageTelemetry.ElapsedMs += now - started
t.usageTelemetry.activeTurnStartedAt = 0
}
t.telemMu.Unlock()
}
func (t *WorkspaceTab) recordUsage(e event.Event) {
if e.Usage == nil {
return
}
u := e.Usage
source := strings.TrimSpace(e.UsageSource)
if source == "" {
source = event.UsageSourceExecutor
}
t.telemMu.Lock()
t.usageTelemetry.PromptTokens += u.PromptTokens
t.usageTelemetry.CompletionTokens += u.CompletionTokens
t.usageTelemetry.TotalTokens += u.TotalTokens
t.usageTelemetry.ReasoningTokens += u.ReasoningTokens
cacheHitTokens, cacheMissTokens := t.usageTelemetry.cacheTokenDelta(source, u, e.SessionHit, e.SessionMiss)
t.usageTelemetry.CacheHitTokens += cacheHitTokens
t.usageTelemetry.CacheMissTokens += cacheMissTokens
t.usageTelemetry.RequestCount++
if t.usageTelemetry.Sources == nil {
t.usageTelemetry.Sources = map[string]usageSourceStats{}
}
src := t.usageTelemetry.Sources[source]
src.PromptTokens += u.PromptTokens
src.CompletionTokens += u.CompletionTokens
src.TotalTokens += u.TotalTokens
src.ReasoningTokens += u.ReasoningTokens
src.CacheHitTokens += cacheHitTokens
src.CacheMissTokens += cacheMissTokens
src.RequestCount++
if e.Pricing != nil {
cost := e.Pricing.Cost(u)
t.usageTelemetry.SessionCost += cost
t.usageTelemetry.SessionCostUsd = t.usageTelemetry.SessionCost
t.usageTelemetry.SessionCurrency = e.Pricing.Symbol()
src.SessionCost += cost
src.SessionCostUsd = src.SessionCost
src.SessionCurrency = e.Pricing.Symbol()
}
t.usageTelemetry.Sources[source] = src
t.telemMu.Unlock()
}
func (t *WorkspaceTab) telemetrySnapshot() tabTelemetrySnapshot {
t.telemMu.Lock()
defer t.telemMu.Unlock()
records := make([]readFileRecord, len(t.readTelemetry))
copy(records, t.readTelemetry)
usage := t.usageTelemetry
if started := usage.activeTurnStartedAt; started > 0 {
now := time.Now().UnixMilli()
if now >= started {
usage.ElapsedMs += now - started
}
}
if len(t.usageTelemetry.Sources) > 0 {
usage.Sources = make(map[string]usageSourceStats, len(t.usageTelemetry.Sources))
for source, stats := range t.usageTelemetry.Sources {
usage.Sources[source] = stats
}
}
usage.activeTurnStartedAt = 0
usage.sourceSessionCache = nil
return tabTelemetrySnapshot{Version: 2, ReadFiles: records, Usage: usage}
}
func (t *WorkspaceTab) resetTelemetry(sessionPath string) {
t.telemMu.Lock()
t.readTelemetry = nil
t.usageTelemetry = sessionUsageStats{}
t.telemetrySessionKey = sessionRuntimeKey(sessionPath)
t.telemMu.Unlock()
}
// syncTelemetryToSession keys the in-memory telemetry to the runtime's current
// session. When the runtime rotated to a different session underneath the tab
// (typed /new routes through Controller.Submit and never reaches App.NewSession),
// the previous session's totals must not bleed into the new one: swap in the
// new session's persisted sidecar, or start from zero when none exists. The
// sidecar is rewritten on every recorded event, so a reload never loses more
// than the sub-second in-memory delta of an in-flight record.
func (t *WorkspaceTab) syncTelemetryToSession(sessionPath string) {
key := sessionRuntimeKey(sessionPath)
if key == "" {
return
}
t.telemMu.Lock()
same := t.telemetrySessionKey == key
t.telemMu.Unlock()
if same {
return
}
// File I/O stays outside telemMu; re-check the key after reacquiring in
// case a concurrent sync or reset re-keyed the tab first.
snapshot := loadTelemetry(sessionPath + ".telemetry.json")
t.telemMu.Lock()
if t.telemetrySessionKey != key {
t.readTelemetry = snapshot.ReadFiles
t.usageTelemetry = snapshot.Usage
t.telemetrySessionKey = key
}
t.telemMu.Unlock()
}
func (t *WorkspaceTab) resetPlannerDisplayTurn() {
t.plannerDisplayMu.Lock()
if len(t.plannerDisplay) == 0 {
t.plannerDisplayTools = nil
}
t.plannerDisplayMu.Unlock()
}
func (t *WorkspaceTab) recordPlannerDisplayEvent(e event.Event) {
if strings.TrimSpace(e.Source) != event.UsageSourcePlanner {
return
}
t.plannerDisplayMu.Lock()
defer t.plannerDisplayMu.Unlock()
switch e.Kind {
case event.Phase:
if strings.TrimSpace(e.Text) != "" {
t.plannerDisplay = append(t.plannerDisplay, HistoryMessage{Role: "phase", Content: e.Text})
}
case event.Reasoning:
if e.Text != "" {
hm := t.ensurePlannerAssistantLocked()
hm.Reasoning += e.Text
}
case event.Text:
if e.Text != "" {
hm := t.ensurePlannerAssistantLocked()
hm.Content += e.Text
}
case event.Message:
if e.Text != "" || e.Reasoning != "" || len(e.MemoryCitations) > 0 {
hm := t.ensurePlannerAssistantLocked()
if e.Text != "" {
hm.Content = e.Text
}
if e.Reasoning != "" {
hm.Reasoning = e.Reasoning
}
if len(e.MemoryCitations) > 0 {
hm.MemoryCitations = append([]provider.MemoryCitation(nil), e.MemoryCitations...)
}
}
case event.ToolDispatch:
if e.Tool.Partial || strings.TrimSpace(e.Tool.Name) == "" {
return
}
hm := t.ensurePlannerAssistantForToolLocked()
call := HistoryToolCall{
ID: e.Tool.ID,
Name: e.Tool.Name,
Arguments: e.Tool.Args,
Subject: historyToolSubject(e.Tool.Name, e.Tool.Args),
Summary: historyToolSummary(e.Tool.Name, e.Tool.Args, ""),
Diff: e.Tool.Diff,
Added: e.Tool.Added,
Removed: e.Tool.Removed,
}
replaced := false
if call.ID != "" {
for i := range hm.ToolCalls {
if hm.ToolCalls[i].ID == call.ID {
hm.ToolCalls[i] = call
replaced = true
break
}
}
if t.plannerDisplayTools == nil {
t.plannerDisplayTools = map[string]string{}
}
t.plannerDisplayTools[call.ID] = call.Name
}
if !replaced {
hm.ToolCalls = append(hm.ToolCalls, call)
}
case event.ToolResult:
callID := strings.TrimSpace(e.Tool.ID)
content := firstNonEmpty(e.Tool.Output, e.Tool.Err)
display, errPreview := plannerToolResultDisplay(content, e.Tool.Err != "")
if callID != "" {
updateHistoryToolCallSummary(t.plannerDisplay, callID, content)
}
toolName := e.Tool.Name
if toolName == "" && t.plannerDisplayTools != nil {
toolName = t.plannerDisplayTools[callID]
}
t.plannerDisplay = append(t.plannerDisplay, HistoryMessage{
Role: "tool",
ToolCallID: callID,
ToolName: toolName,
Content: display,
ToolResultError: errPreview,
})
case event.Notice:
if strings.TrimSpace(e.Text) != "" {
level := "info"
if e.Level == event.LevelWarn {
level = "warn"
}
t.plannerDisplay = append(t.plannerDisplay, HistoryMessage{Role: "notice", Level: level, Content: e.Text, Detail: e.Detail, Code: e.Code})
}
}
}
func (t *WorkspaceTab) ensurePlannerAssistantLocked() *HistoryMessage {
if n := len(t.plannerDisplay); n > 0 && t.plannerDisplay[n-1].Role == "assistant" {
return &t.plannerDisplay[n-1]
}
t.plannerDisplay = append(t.plannerDisplay, HistoryMessage{Role: "assistant"})
return &t.plannerDisplay[len(t.plannerDisplay)-1]
}
func (t *WorkspaceTab) ensurePlannerAssistantForToolLocked() *HistoryMessage {
if n := len(t.plannerDisplay); n > 0 && t.plannerDisplay[n-1].Role == "assistant" && strings.TrimSpace(t.plannerDisplay[n-1].Content) == "" {
return &t.plannerDisplay[n-1]
}
t.plannerDisplay = append(t.plannerDisplay, HistoryMessage{Role: "assistant"})
return &t.plannerDisplay[len(t.plannerDisplay)-1]
}
func plannerToolResultDisplay(content string, failed bool) (display, errPreview string) {
if strings.TrimSpace(content) == "" {
return "", ""
}
if failed || historyToolResultFailed(content) {
display = clipHistoryToolPreview(strings.TrimSpace(content))
return display, display
}
return "", ""
}
func (t *WorkspaceTab) takePlannerDisplayTurn() []HistoryMessage {
t.plannerDisplayMu.Lock()
defer t.plannerDisplayMu.Unlock()
out := cloneHistoryMessages(t.plannerDisplay)
t.plannerDisplay = nil
t.plannerDisplayTools = nil
return out
}
// tabEventSink wraps a parent event.Sink and prepends a tabId to every wire
// event so the frontend can route it to the correct tab's reducer.
//
// tabID and app are rebound while the controller keeps emitting when a running
// session is detached to the background or reattached to another tab, so they
// live under mu like ctx does (a bare field write would data-race Emit). Read
// them via binding(), write via setBinding().
type tabEventSink struct {
tabID string
app *App
mu sync.RWMutex
ctx context.Context
runtimeEvents asyncRuntimeEmitter
botSink event.Sink // optional: when set, events are also forwarded here
botSinkGen uint64
turnInFlight bool // stays true through the end of TurnDone fan-out
}
type closeableEventSink interface {
Close()
}
// binding snapshots the sink's current tab routing under the sink lock.
func (s *tabEventSink) binding() (string, *App) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.tabID, s.app
}
// setBinding reroutes the sink to another tab. A nil app keeps the current
// App pointer (detach/close paths only change the tab id).
func (s *tabEventSink) setBinding(tabID string, app *App) {
s.mu.Lock()
s.tabID = tabID
if app != nil {
s.app = app
}
s.mu.Unlock()
}
func (s *tabEventSink) Emit(e event.Event) {
if e.Kind == event.TurnStarted {
s.mu.Lock()
s.turnInFlight = true
s.mu.Unlock()
}
tabID, app := s.binding()
if app != nil {
switch e.Kind {
case event.TurnStarted:
s.resetPlannerDisplayTurn()
s.recordTurnStarted()
case event.Usage:
s.recordUsageTelemetry(e)
case event.TurnDone:
s.recordTurnDone()
}
if m := app.metrics.Load(); m != nil {
m.observe(e)
if e.Kind == event.TurnDone {
m.persist()
}
}
if e.Kind == event.TurnDone {
s.flushPlannerDisplay()
}
}
s.emitRuntimeEvent(eventChannel, toWireTab(e, tabID))
if app != nil {
if status, update := topicActivityStatusFromEvent(e); update {
changed := app.setTabActivityStatus(tabID, status)
if changed || isBackgroundJobLifecycleNotice(e) {
app.emitProjectTreeChanged()
}
}
}
// Record read_file successes in the tab's telemetry.
if e.Kind == event.ToolResult && e.Tool.Name == "read_file" && e.Tool.Err == "" {
s.recordReadTelemetry(e)
}
if app != nil {
s.recordPlannerDisplay(e)
}
// Persist after each turn so a force-kill loses at most the in-flight prompt.
if e.Kind == event.TurnDone && app != nil {
app.scheduleTabSnapshot(tabID)
}
// Forward event to bot channels when a bot forwarder is attached.
// Read the sink under the read lock so SetBotSink can safely swap it
// from another goroutine.
bs, botSinkGen := s.botSinkSnapshot()
if bs != nil {
bs.Emit(e)
// Detach the forwarder after TurnDone so subsequent turns on the
// same tab do not keep pushing to bot channels.
if e.Kind == event.TurnDone {
s.clearBotSink(botSinkGen)
}
}
// Unlike the transient botSink above, the bridge observes every tab for
// its whole lifetime (god view: /desktop status, watch subscriptions,
// remote approvals). observe only does in-memory bookkeeping and queueing.
if app != nil && app.botBridge != nil {
app.botBridge.observe(tabID, e)
}
if e.Kind == event.TurnDone {
s.mu.Lock()
s.turnInFlight = false
s.mu.Unlock()
}
}
// SetBotSink atomically sets or clears the bot event forwarder on this sink.
// It is safe to call concurrently with Emit.
func (s *tabEventSink) SetBotSink(sink event.Sink) uint64 {
s.mu.Lock()
old := s.botSink
s.botSink = sink
s.botSinkGen++
generation := s.botSinkGen
s.mu.Unlock()
if old != nil && old != sink {
if closer, ok := old.(closeableEventSink); ok {
closer.Close()
}
}
return generation
}
func (s *tabEventSink) botSinkSnapshot() (event.Sink, uint64) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.botSink, s.botSinkGen
}
// clearBotSink clears only the forwarder generation observed by the finishing
// turn. A delayed TurnDone must not detach a replacement installed meanwhile.
func (s *tabEventSink) clearBotSink(generation uint64) {
s.mu.Lock()
if s.botSinkGen != generation {
s.mu.Unlock()
return
}
old := s.botSink
s.botSink = nil
s.botSinkGen++
s.mu.Unlock()
if closer, ok := old.(closeableEventSink); ok {
closer.Close()
}
}
// tryBeginTurn reserves the tab until its TurnDone has finished fan-out. The
// controller clears RuntimeStatus().Running before it emits TurnDone, so the
// controller status alone leaves a window where a new turn can inherit the old
// turn's forwarder or have its replacement cleared by the old completion.
func (s *tabEventSink) tryBeginTurn() bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.turnInFlight {
return false
}
s.turnInFlight = true
return true
}
func (s *tabEventSink) cancelTurnStart() {
s.mu.Lock()
s.turnInFlight = false
s.mu.Unlock()
}
func (s *tabEventSink) setContext(ctx context.Context) {
s.mu.Lock()
s.ctx = ctx
s.mu.Unlock()
}
func (s *tabEventSink) clearContext() {
s.mu.Lock()
s.ctx = nil
s.mu.Unlock()
s.runtimeEvents.Clear()
}
func (s *tabEventSink) context() context.Context {
s.mu.RLock()
defer s.mu.RUnlock()
return s.ctx
}
func (s *tabEventSink) emitRuntimeEvent(name string, payload ...interface{}) {
if s == nil {
return
}
ctx := s.context()
if ctx == nil {
return
}
s.runtimeEvents.Emit(ctx, name, payload...)
}
type runtimeEventEmitFunc func(context.Context, string, ...interface{})
type runtimeEventEnvelope struct {
ctx context.Context
name string
payload []interface{}
}
// asyncRuntimeEmitter decouples Wails' runtime event bridge from agent
// emission. runtime.EventsEmit can block when the single webview event channel
// backs up; callers enqueue in-order work and return without holding the
// agent's event.Sync lock.
// runtimeEventsEmitFallback is the emit used when no per-instance override is
// installed. Production keeps the real Wails bridge; the test binary swaps in
// a no-op via TestMain, because Wails EventsEmit log.Fatals outside a running
// Wails app and would kill the whole test process from any code path that
// emits a runtime event with a plain Background context.
var runtimeEventsEmitFallback runtimeEventEmitFunc = runtime.EventsEmit
type asyncRuntimeEmitter struct {
mu sync.Mutex
emit runtimeEventEmitFunc
queue []runtimeEventEnvelope
head int
running bool
}
func (e *asyncRuntimeEmitter) Emit(ctx context.Context, name string, payload ...interface{}) {
if ctx == nil {
return
}
item := runtimeEventEnvelope{
ctx: ctx,
name: name,
payload: append([]interface{}(nil), payload...),
}
e.mu.Lock()
e.queue = append(e.queue, item)
if !e.running {
e.running = true
go e.run()
}
e.mu.Unlock()
}
func (e *asyncRuntimeEmitter) Clear() {
e.mu.Lock()
clear(e.queue)
e.queue = nil
e.head = 0
e.mu.Unlock()
}
func (e *asyncRuntimeEmitter) run() {
for {
e.mu.Lock()
if e.head >= len(e.queue) {
clear(e.queue)
e.queue = nil
e.head = 0
e.running = false
e.mu.Unlock()
return
}
item := e.queue[e.head]
var zero runtimeEventEnvelope
e.queue[e.head] = zero
e.head++
if e.head > 64 && e.head*2 >= len(e.queue) {
e.queue = append([]runtimeEventEnvelope(nil), e.queue[e.head:]...)
e.head = 0
}
emit := e.emit
if emit == nil {
emit = runtimeEventsEmitFallback
}
e.mu.Unlock()
emit(item.ctx, item.name, item.payload...)
}
}
func topicActivityStatusFromEvent(e event.Event) (string, bool) {
switch e.Kind {
case event.TurnStarted, event.Reasoning, event.ToolDispatch, event.ToolProgress, event.ToolResult, event.CompactionStarted, event.CompactionDone, event.Retrying:
return topicStatusThinking, true
case event.Text, event.Message:
return topicStatusStreaming, true
case event.ApprovalRequest, event.AskRequest:
return topicStatusWaitingConfirmation, true
case event.TurnDone:
if e.Outcome == event.TurnOutcomeFinalReadiness {
// The transcript presents this turn end as a recoverable delivery
// pause with a continue action, so the sidebar must not flag it
// as an error.
return topicStatusPaused, true
}
if e.Err != nil {
return topicStatusError, true
}
return "", true
case event.Notice:
if isBackgroundJobLifecycleNotice(e) {
return "", true
}
return "", false
default:
return "", false
}
}
func isBackgroundJobLifecycleNotice(e event.Event) bool {
if e.Kind != event.Notice {
return false
}
text := strings.TrimSpace(e.Text)
return strings.HasPrefix(text, "background ") &&
(strings.Contains(text, " started: ") ||
strings.Contains(text, " finished: ") ||
strings.Contains(text, " failed: ") ||
strings.Contains(text, " killed: "))
}
// notifyTabRuntimeRebuilt tells the frontend a tab's controller was replaced
// in place (model/effort/token-mode switch, clear-while-running). A rebuilt
// controller restarts its approval/ask id counter at "1", so tab-scoped
// frontend state keyed by prompt id (the attention-chime dedupe) must reset —
// unlike agent:ready, this event carries no reload semantics, so emitting it
// on every swap adds no hydration churn.
//
// Ordering matters: the reset must reach the frontend BEFORE the rebuilt
// controller's first approval/ask event, or the stale key still mutes it. The
// tab's agent events ride the tab sink's own async queue, so the notice goes
// through THAT queue — same lane, FIFO, guaranteed to arrive first. The
// App-level queue is only the fallback when the sink cannot deliver (no sink,
// or its webview context is cleared); it cannot order against sink traffic,
// but an unordered notice still beats none.
func (a *App) notifyTabRuntimeRebuilt(tab *WorkspaceTab) {
if tab == nil {
return
}
a.mu.RLock()
sink := tab.sink
tabID := tab.ID
a.mu.RUnlock()
if sink != nil && sink.context() != nil {
sink.emitRuntimeEvent("runtime:rebuilt", tabID)
return
}
a.emitRuntimeEvent("runtime:rebuilt", tabID)
}
func (a *App) emitReady(ctx context.Context, tabID ...string) {
a.mu.RLock()
hook := a.readyHook
a.mu.RUnlock()
if hook != nil {
hook()
return
}
if ctx != nil {
if len(tabID) > 0 && strings.TrimSpace(tabID[0]) != "" {
runtime.EventsEmit(ctx, "agent:ready", strings.TrimSpace(tabID[0]))
return
}
runtime.EventsEmit(ctx, "agent:ready")
}
}
func (s *tabEventSink) recordReadTelemetry(e event.Event) {
tabID, app := s.binding()
if app == nil {
return
}
app.mu.RLock()
tab := app.tabByEventSinkIDLocked(tabID)
var ctrl control.SessionAPI
if tab != nil {
ctrl = tab.Ctrl
}
app.mu.RUnlock()
if tab == nil {
return
}
turn := 0
if ctrl != nil {
turn = ctrl.Turn()
}
// Parse read_file args: {"path": "...", "offset": N, "limit": N}
var args struct {
Path string `json:"path"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
path := e.Tool.Args
offset := 0
limit := 0
if err := json.Unmarshal([]byte(e.Tool.Args), &args); err == nil && args.Path != "" {
path = args.Path
offset = args.Offset
limit = args.Limit
}
truncated := e.Tool.Truncated || strings.Contains(e.Tool.Output, "truncated") ||
strings.Contains(e.Tool.Output, "File truncated")
sp := ""
if ctrl != nil {
sp = ctrl.SessionPath()
}
if sp != "" {
tab.syncTelemetryToSession(sp)
}
tab.recordReadFile(readFileRecord{
Path: path,
Turn: turn,
Time: time.Now().UnixMilli(),
Offset: offset,
Limit: limit,
Truncated: truncated,
})
if sp != "" {
_ = saveTelemetry(sp+".telemetry.json", tab.telemetrySnapshot())
}
}
func (s *tabEventSink) recordTurnStarted() {
tab, sp := s.telemetryTab()
if tab == nil {
return
}
if sp != "" {
tab.syncTelemetryToSession(sp)
}
tab.recordTurnStarted(time.Now().UnixMilli())
if sp != "" {
_ = saveTelemetry(sp+".telemetry.json", tab.telemetrySnapshot())
}
}
func (s *tabEventSink) recordTurnDone() {
tab, sp := s.telemetryTab()
if tab == nil {
return
}
if sp != "" {
tab.syncTelemetryToSession(sp)
}
tab.recordTurnDone(time.Now().UnixMilli())
if sp != "" {
_ = saveTelemetry(sp+".telemetry.json", tab.telemetrySnapshot())
}
}
func (s *tabEventSink) recordUsageTelemetry(e event.Event) {
tab, sp := s.telemetryTab()
if tab == nil {
return
}
if sp != "" {
tab.syncTelemetryToSession(sp)
}
tab.recordUsage(e)
if sp != "" {
_ = saveTelemetry(sp+".telemetry.json", tab.telemetrySnapshot())
}
}
func (s *tabEventSink) resetPlannerDisplayTurn() {
tab, _ := s.eventTabAndController()
if tab != nil {
tab.resetPlannerDisplayTurn()
}
}
func (s *tabEventSink) recordPlannerDisplay(e event.Event) {
tab, _ := s.eventTabAndController()
if tab != nil {
tab.recordPlannerDisplayEvent(e)
}
}
func (s *tabEventSink) flushPlannerDisplay() {
tab, ctrl := s.eventTabAndController()
if tab == nil || ctrl == nil {
return
}
messages := tab.takePlannerDisplayTurn()
if len(messages) == 0 {
return
}
sessionPath := ctrl.SessionPath()
if sessionPath == "" {
return
}
userContent := lastUserMessageContent(ctrl.History())
if strings.TrimSpace(userContent) == "" {
return
}
_ = recordSessionPlannerDisplay(controllerSessionDir(ctrl), sessionPath, userContent, messages)
}
func (s *tabEventSink) eventTabAndController() (*WorkspaceTab, control.SessionAPI) {
tabID, app := s.binding()
if app == nil {
return nil, nil
}
app.mu.RLock()
defer app.mu.RUnlock()
tab := app.tabByEventSinkIDLocked(tabID)
if tab == nil {
return nil, nil
}
return tab, tab.Ctrl
}
func lastUserMessageContent(msgs []provider.Message) string {
for i := len(msgs) - 1; i >= 0; i-- {
if msgs[i].Role == provider.RoleUser {
return msgs[i].Content
}
}
return ""
}
func (s *tabEventSink) telemetryTab() (*WorkspaceTab, string) {
tabID, app := s.binding()
if app == nil {
return nil, ""
}
app.mu.RLock()
tab := app.tabByEventSinkIDLocked(tabID)
var ctrl control.SessionAPI
if tab != nil {
ctrl = tab.Ctrl
}
app.mu.RUnlock()
if tab == nil {
return nil, ""
}
if ctrl == nil {
return tab, ""
}
sp := ctrl.SessionPath()
if sp == "" {
return tab, ""
}
return tab, sp
}
// --- wire event with tab ----------------------------------------------------
func toWireTab(e event.Event, tabID string) wireEventTab {
w := eventwire.ToWire(e)
return wireEventTab{
Event: w,
TabID: tabID,
SessionHitTokens: e.SessionHit,
SessionMissTokens: e.SessionMiss,
SessionCost: 0, // filled by frontend accumulator per tab
SessionCurrency: "",
SessionCostUsd: 0, // deprecated compatibility alias
}
}
// wireEventTab extends the shared event wire with tab routing info. The frontend reducer
// uses tabId to dispatch to the correct per-tab state.
type wireEventTab struct {
eventwire.Event
TabID string `json:"tabId"`
// Session-cumulative tokens per tab.
SessionHitTokens int `json:"sessionHitTokens,omitempty"`
SessionMissTokens int `json:"sessionMissTokens,omitempty"`
// SessionCost is filled by the frontend's per-tab accumulator.
SessionCost float64 `json:"sessionCost,omitempty"`
SessionCurrency string `json:"sessionCurrency,omitempty"`
// SessionCostUsd is a deprecated compatibility alias. It mirrors
// SessionCost and does not imply USD.
SessionCostUsd float64 `json:"sessionCostUsd,omitempty"`
}
// --- Tab management on App --------------------------------------------------
// TabMeta is the frontend-facing shape of one tab.
type TabMeta struct {
ID string `json:"id"`
Scope string `json:"scope"`
WorkspaceRoot string `json:"workspaceRoot"`
WorkspaceName string `json:"workspaceName"`
WorkspacePath string `json:"workspacePath,omitempty"`
GitBranch string `json:"gitBranch,omitempty"`
TopicID string `json:"topicId"`
TopicTitle string `json:"topicTitle"`
SessionPath string `json:"sessionPath,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
ProjectColor string `json:"projectColor,omitempty"`
Label string `json:"label"`
Ready bool `json:"ready"`
Running bool `json:"running"`
PendingPrompt bool `json:"pendingPrompt,omitempty"`
RemoteControlled bool `json:"remoteControlled,omitempty"`
BackgroundJobs int `json:"backgroundJobs,omitempty"`
CancelRequested bool `json:"cancelRequested,omitempty"`
Cancellable bool `json:"cancellable"`
Mode string `json:"mode"`
CollaborationMode string `json:"collaborationMode"`
ToolApprovalMode string `json:"toolApprovalMode"`
TokenMode string `json:"tokenMode"`
Goal string `json:"goal,omitempty"`
GoalStatus string `json:"goalStatus,omitempty"`
AutoResearch *AutoResearchCompactView `json:"autoResearch,omitempty"`
Recovered bool `json:"recovered,omitempty"`
RecoveryReason string `json:"recoveryReason,omitempty"`
RecoveryDigest string `json:"recoveryDigest,omitempty"`
RecoveryParentID string `json:"recoveryParentId,omitempty"`
StartupErr string `json:"startupErr,omitempty"`
Active bool `json:"active"`
Cwd string `json:"cwd"`
}
func enrichTabMeta(meta TabMeta) TabMeta {
if meta.Active {
meta.GitBranch = workspaceGitBranchForMeta(meta.WorkspaceRoot)
}
return meta
}
func enrichTabMetas(metas []TabMeta) []TabMeta {
for i := range metas {
if metas[i].Active {
metas[i].GitBranch = workspaceGitBranchForMeta(metas[i].WorkspaceRoot)
}
}
return metas
}
func (a *App) tabMeta(tab *WorkspaceTab, active bool) TabMeta {
m := TabMeta{
ID: tab.ID,
Scope: tab.Scope,
WorkspaceRoot: tab.WorkspaceRoot,
WorkspaceName: workspaceName(tab.WorkspaceRoot),
WorkspacePath: tab.WorkspaceRoot,
TopicID: tab.TopicID,
TopicTitle: tab.TopicTitle,
SessionPath: tab.currentSessionPath(),
ReadOnly: tab.ReadOnly,
Label: tab.Label,
Ready: tab.Ready,
Mode: currentTabMode(tab),
CollaborationMode: currentTabCollaborationMode(tab),
ToolApprovalMode: currentTabToolApprovalMode(tab),
TokenMode: currentTabTokenMode(tab),
Goal: currentTabGoal(tab),
GoalStatus: currentTabGoalStatus(tab),
AutoResearch: compactAutoResearch(tab),
StartupErr: tab.StartupErr,
Active: active,
Cwd: tab.WorkspaceRoot,
}
switch tab.Scope {
case "global":
m.ProjectColor = globalProjectColor()
m.WorkspaceName = globalProjectTitle()
case "project":
m.ProjectColor = projectColor(tab.WorkspaceRoot)
}
if tab.Ctrl != nil {
status := tab.Ctrl.RuntimeStatus()
m.Running = status.Running || status.PendingPrompt || status.BackgroundJobs > 0
m.PendingPrompt = status.PendingPrompt
m.BackgroundJobs = status.BackgroundJobs
m.CancelRequested = status.CancelRequested
m.Cancellable = status.Cancellable
}
if a.botBridge != nil {
m.RemoteControlled = a.botBridge.remoteControlledTabs()[tab.ID]
}
if meta, ok, err := agent.LoadBranchMeta(tab.currentSessionPath()); err == nil && ok && meta.Recovered {
m.Recovered = true
m.RecoveryReason = meta.RecoveryReason
m.RecoveryDigest = meta.RecoveryDigest
m.RecoveryParentID = string(meta.ParentID)
}
return m
}
// ListTabs returns every open view container's metadata for the frontend chrome and sidebar.
func (a *App) ListTabs() []TabMeta {
a.mu.RLock()
out := make([]TabMeta, 0, len(a.tabs))
ordered, needsRepair := a.orderedTabIDsSnapshotLocked()
for _, id := range ordered {
if tab := a.tabs[id]; tab != nil {
out = append(out, a.tabMeta(tab, tab.ID == a.activeTabID))
}
}
a.mu.RUnlock()
if !needsRepair {
return enrichTabMetas(out)
}
a.mu.Lock()
out = make([]TabMeta, 0, len(a.tabs))
for _, id := range a.orderedTabIDsLocked() {
if tab := a.tabs[id]; tab != nil {
out = append(out, a.tabMeta(tab, tab.ID == a.activeTabID))
}
}
a.mu.Unlock()
return enrichTabMetas(out)
}
// syncTabWorkspaceRootSpellings repoints open project tabs at the registry's
// canonical root spelling after a registry write may have rewritten it
// (addProject and friends adopt the caller's spelling). Tabs, the project
// tree, and persisted tab state then agree on a single string form of each
// root, which the frontend compares exactly. Callers must not hold a.mu.
func (a *App) syncTabWorkspaceRootSpellings() {
projects := loadProjectsFile().Projects
a.mu.Lock()
changed := false
for _, tab := range a.tabs {
if tab == nil || tab.Scope != "project" {
continue
}
i := projectIndexByRoot(projects, tab.WorkspaceRoot)
if i < 0 || tab.WorkspaceRoot == projects[i].Root {
continue
}
tab.WorkspaceRoot = projects[i].Root
changed = true
}
if changed {
a.saveTabsLocked()
}
a.mu.Unlock()
if changed {
a.emitProjectTreeChanged()
}
}
// registerProjectRoot indexes workspaceRoot in the project registry and
// realigns open tabs when the registry adopted a new spelling of the root.
func (a *App) registerProjectRoot(workspaceRoot string) {
_ = addProject(workspaceRoot, "")
a.syncTabWorkspaceRootSpellings()
}
// OpenProjectTab builds a controller scoped to workspaceRoot and opens the
// session selected by the given topic. Topic selection resolves to a concrete
// session path first; the visible tab is then attached to that session runtime.
func (a *App) OpenProjectTab(workspaceRoot, topicID string) (TabMeta, error) {
if workspaceRoot == "" {
return TabMeta{}, fmt.Errorf("workspaceRoot is required")
}
if abs, err := filepath.Abs(workspaceRoot); err == nil {
workspaceRoot = abs
}
saveWorkspace(workspaceRoot)
a.registerProjectRoot(workspaceRoot)
sessionPath, _ := a.findTopicSessionForTarget("project", workspaceRoot, topicID)
return a.openTopicTab("project", workspaceRoot, topicID, sessionPath)
}
func (a *App) openTopicTab(scope, workspaceRoot, topicID, sessionPath string) (TabMeta, error) {
return a.openTopicTabWithActivation(scope, workspaceRoot, topicID, sessionPath, true)
}
func (a *App) openProjectTabInactive(workspaceRoot, topicID string) (TabMeta, error) {
if workspaceRoot == "" {
return TabMeta{}, fmt.Errorf("workspaceRoot is required")
}
if abs, err := filepath.Abs(workspaceRoot); err == nil {
workspaceRoot = abs
}
a.registerProjectRoot(workspaceRoot)
sessionPath, _ := a.findTopicSessionForTarget("project", workspaceRoot, topicID)
return a.openTopicTabWithActivation("project", workspaceRoot, topicID, sessionPath, false)
}
func (a *App) openGlobalTabInactive(topicID string) (TabMeta, error) {
globalRoot := globalWorkspaceRoot()
if err := os.MkdirAll(globalRoot, 0o755); err != nil {
return TabMeta{}, fmt.Errorf("create global workspace: %w", err)
}
sessionPath, _ := a.findTopicSessionForTarget("global", "", topicID)
return a.openTopicTabWithActivation("global", "", topicID, sessionPath, false)
}
func (a *App) openTopicTabWithActivation(scope, workspaceRoot, topicID, sessionPath string, activate bool) (TabMeta, error) {
actualRoot := workspaceRoot
if scope == "global" {
actualRoot = globalWorkspaceRoot()
}
targetKey := sessionRuntimeKey(sessionPath)
a.mu.Lock()
if targetKey != "" {
for _, tab := range a.tabs {
if tab == nil {
continue
}
if sessionRuntimeKey(tab.currentSessionPath()) == targetKey {
if activate {
a.activeTabID = tab.ID
}
meta := a.tabMeta(tab, tab.ID == a.activeTabID)
a.saveTabsLocked()
a.mu.Unlock()
return enrichTabMeta(meta), nil
}
}
}
for _, tab := range a.tabs {
if tabMatchesTopicTarget(tab, scope, workspaceRoot, topicID) {
if activate {
a.activeTabID = tab.ID
}
sameSession := targetKey == "" || sessionRuntimeKey(tab.currentSessionPath()) == targetKey
meta := a.tabMeta(tab, tab.ID == a.activeTabID)
a.saveTabsLocked()
a.mu.Unlock()
if sameSession {
return enrichTabMeta(meta), nil
}
if err := a.rebindTabToSessionPath(tab, sessionPath); err != nil {
return TabMeta{}, err
}
a.mu.RLock()
meta = a.tabMeta(tab, tab.ID == a.activeTabID)
a.mu.RUnlock()
return enrichTabMeta(meta), nil
}
}
tabID := a.newUniqueTabIDLocked()
topicTitle := topicTitleForTab(scope, workspaceRoot, topicID)
if t, source, ok := topicTitleFallbackForOpen(workspaceRoot, topicID, sessionPath); ok {
topicTitle = t
_ = setTopicTitleWithSource(workspaceRoot, topicID, t, source)
}
if sessionPath == "" {
var err error
sessionPath, err = createEmptySessionFile(desktopSessionDir(actualRoot), "")
if err != nil {
a.mu.Unlock()
return TabMeta{}, err
}
}
profile := loadTabSessionProfile(sessionPath)
tab := &WorkspaceTab{
ID: tabID,
Scope: scope,
WorkspaceRoot: actualRoot,
TopicID: topicID,
TopicTitle: topicTitle,
SessionPath: sessionPath,
disabledMCP: map[string]ServerView{},
}
applyTabSessionProfile(tab, profile)
tab.sink = &tabEventSink{tabID: tabID, app: a}
a.tabs[tabID] = tab
a.tabOrder = append(a.tabOrder, tabID)
if activate {
a.activeTabID = tabID
}
a.saveTabsLocked()
meta := a.tabMeta(tab, tab.ID == a.activeTabID)
a.mu.Unlock()
a.startTabControllerBuild(tab)
if scope == "project" {
a.emitProjectTreeChanged()
}
return enrichTabMeta(meta), nil
}
// OpenGlobalTab opens a new global-scope tab (no project root). The global
// workspace root is the reasonix user config directory.
func (a *App) OpenGlobalTab(topicID string) (TabMeta, error) {
globalRoot := globalWorkspaceRoot()
if err := os.MkdirAll(globalRoot, 0o755); err != nil {
return TabMeta{}, fmt.Errorf("create global workspace: %w", err)
}
sessionPath, _ := a.findTopicSessionForTarget("global", "", topicID)
return a.openTopicTab("global", "", topicID, sessionPath)
}
// OpenTopicSession opens a concrete saved session from the sidebar. Unlike
// OpenProjectTab/OpenGlobalTab, it does not resolve the topic to the latest
// session first; sessionPath is the runtime identity being selected.
func (a *App) OpenTopicSession(scope, workspaceRoot, topicID, sessionPath string) (TabMeta, error) {
scope = strings.TrimSpace(scope)
if scope != "project" {
scope = "global"
workspaceRoot = ""
}
if scope == "project" {
workspaceRoot = normalizeProjectRoot(workspaceRoot)
if workspaceRoot == "" {
return TabMeta{}, fmt.Errorf("workspaceRoot is required")
}
saveWorkspace(workspaceRoot)
a.registerProjectRoot(workspaceRoot)
}
_, validPath, err := a.sessionDirForPath(sessionPath)
if err != nil {
return TabMeta{}, err
}
return a.openTopicTab(scope, workspaceRoot, topicID, validPath)
}
// ActivateTopic opens a topic into the single visible conversation surface used
// by layouts without a tab strip. It delegates the actual open/reuse behavior to
// the classic tab path, then prunes every non-active visible tab so historical
// clicks do not accumulate hidden startup work.
func (a *App) ActivateTopic(scope, workspaceRoot, topicID, sessionPath string) (TabMeta, error) {
a.singleSurfaceMu.Lock()
defer a.singleSurfaceMu.Unlock()
var meta TabMeta
var err error
if strings.TrimSpace(sessionPath) != "" {
meta, err = a.OpenTopicSession(scope, workspaceRoot, topicID, sessionPath)
} else if strings.TrimSpace(scope) == "project" {
meta, err = a.OpenProjectTab(workspaceRoot, topicID)
} else {
meta, err = a.OpenGlobalTab(topicID)
}
if err != nil {
return TabMeta{}, err
}
return a.keepOnlyVisibleTab(meta.ID)
}
// EnsureBlankSurface mirrors EnsureBlankTab for no-tab-strip layouts: after
// creating or reusing a blank session, it removes other visible tabs while
// preserving running runtimes as detached background sessions.
func (a *App) EnsureBlankSurface(scope, workspaceRoot string) (TabMeta, error) {
a.singleSurfaceMu.Lock()
defer a.singleSurfaceMu.Unlock()
meta, err := a.EnsureBlankTab(scope, workspaceRoot)
if err != nil {
return TabMeta{}, err
}
return a.keepOnlyVisibleTab(meta.ID)
}
func tabMatchesTopicTarget(tab *WorkspaceTab, scope, workspaceRoot, topicID string) bool {
if tab == nil || tab.Scope != scope || tab.TopicID != topicID {
return false
}
if scope == "global" {
return true
}
return sameProjectRoot(tab.WorkspaceRoot, workspaceRoot)
}
func tabInWorkspace(tab *WorkspaceTab, workspaceRoot string) bool {
return tab != nil &&
tab.Scope == "project" &&
sameProjectRoot(tab.WorkspaceRoot, workspaceRoot)
}
// EnsureBlankTab activates the existing blank tab for the target scope, or
// creates one if none exists. Reusing a blank tab keeps repeated "new session"
// clicks from piling up empty conversations.
func (a *App) EnsureBlankTab(scope, workspaceRoot string) (TabMeta, error) {
scope = strings.TrimSpace(scope)
if scope != "project" {
scope = "global"
}
globalRoot := ""
if scope == "project" {
workspaceRoot = strings.TrimSpace(workspaceRoot)
if workspaceRoot == "" {
return TabMeta{}, fmt.Errorf("workspaceRoot is required")
}
if abs, err := filepath.Abs(workspaceRoot); err == nil {
workspaceRoot = abs
}
saveWorkspace(workspaceRoot)
a.registerProjectRoot(workspaceRoot)
} else {
workspaceRoot = ""
globalRoot = globalWorkspaceRoot()
if err := os.MkdirAll(globalRoot, 0o755); err != nil {
return TabMeta{}, fmt.Errorf("create global workspace: %w", err)
}
}
var created *WorkspaceTab
// Compute actual root early — both the indexed-topic fallback and the
// new-topic path need it when constructing the tab below.
actualRoot := workspaceRoot
if scope == "global" {
actualRoot = globalRoot
}
defaultModel, defaultToolApprovalMode := desktopNewSessionDefaults()
a.mu.Lock()
for _, id := range a.orderedTabIDsLocked() {
tab := a.tabs[id]
if a.blankTabMatchesTargetLocked(tab, scope, workspaceRoot) {
if err := resetReusableBlankTabTitle(tab, scope, workspaceRoot); err != nil {
a.mu.Unlock()
return TabMeta{}, err
}
a.activeTabID = tab.ID
meta := a.tabMeta(tab, true)
a.saveTabsLocked()
a.mu.Unlock()
return enrichTabMeta(meta), nil
}
}
// New blank sessions start from global session defaults for model and
// Ask/Auto/YOLO approval posture. Keep the remaining execution-local settings
// from the active tab so a new blank session preserves effort/token/MCP
// continuity without letting the active tab override global defaults (#4019).
inheritedModel := defaultModel
var inheritedEffort *string
inheritedTokenMode := boot.TokenModeFull
inheritedMode := tabModeFromAxes(false, defaultToolApprovalMode == control.ToolApprovalYolo)
inheritedToolApprovalMode := defaultToolApprovalMode
inheritedDisabledMCP := map[string]ServerView{}
var inheritedMCPOrder []string
if active := a.activeTabLocked(); active != nil {
inheritedEffort = cloneStringPtr(active.effort)
inheritedTokenMode = currentTabTokenMode(active)
inheritedDisabledMCP = cloneServerViewMap(active.disabledMCP)
inheritedMCPOrder = append([]string(nil), active.mcpOrder...)
}
if topicID := a.indexedBlankTopicIDLocked(scope, workspaceRoot); topicID != "" {
// Reuse a previously-indexed but unused blank topic instead of
// creating a new one. Build it inline (not via OpenProjectTab /
// OpenGlobalTab) so it inherits settings from the active tab.
if loadTopicCreatedAt(topicTitleRoot(scope, workspaceRoot), topicID) <= 0 {
createdAt := topicIDCreatedAt(topicID)
if createdAt <= 0 {
createdAt = time.Now().UnixMilli()
}
_ = setTopicCreatedAt(topicTitleRoot(scope, workspaceRoot), topicID, createdAt)
}
tabID := a.newUniqueTabIDLocked()
topicTitle := topicTitleForTab(scope, workspaceRoot, topicID)
created = &WorkspaceTab{
ID: tabID,
Scope: scope,
WorkspaceRoot: actualRoot,
TopicID: topicID,
TopicTitle: topicTitle,
model: inheritedModel,
effort: inheritedEffort,
tokenMode: inheritedTokenMode,
mode: inheritedMode,
toolApprovalMode: inheritedToolApprovalMode,
disabledMCP: inheritedDisabledMCP,
mcpOrder: inheritedMCPOrder,
}
created.sink = &tabEventSink{tabID: tabID, app: a}
a.tabs[tabID] = created
a.tabOrder = append(a.tabOrder, tabID)
a.activeTabID = tabID
prePath, err := createEmptySessionFile(desktopSessionDir(actualRoot), inheritedModel)
if err != nil {
delete(a.tabs, tabID)
a.removeTabOrderLocked(tabID)
a.mu.Unlock()
return TabMeta{}, err
}
created.SessionPath = prePath
a.saveTabsLocked()
meta := a.tabMeta(created, true)
a.mu.Unlock()
a.startTabControllerBuild(created)
a.emitProjectTreeChanged()
return enrichTabMeta(meta), nil
}
topicID := newTopicID()
topicTitle := defaultTopicTitle
createdAt := time.Now().UnixMilli()
if err := setTopicTitleWithSource(workspaceRoot, topicID, topicTitle, topicTitleSourceAuto); err != nil {
a.mu.Unlock()
return TabMeta{}, err
}
if err := setTopicCreatedAt(workspaceRoot, topicID, createdAt); err != nil {
a.mu.Unlock()
return TabMeta{}, err
}
_ = prependTopicInProjectsFile(workspaceRoot, topicID, false)
tabID := a.newUniqueTabIDLocked()
created = &WorkspaceTab{
ID: tabID,
Scope: scope,
WorkspaceRoot: actualRoot,
TopicID: topicID,
TopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),
model: inheritedModel,
effort: inheritedEffort,
tokenMode: inheritedTokenMode,
mode: inheritedMode,
toolApprovalMode: inheritedToolApprovalMode,
disabledMCP: inheritedDisabledMCP,
mcpOrder: inheritedMCPOrder,
}
created.sink = &tabEventSink{tabID: tabID, app: a}
a.tabs[tabID] = created
a.tabOrder = append(a.tabOrder, tabID)
a.activeTabID = tabID
prePath, err := createEmptySessionFile(desktopSessionDir(actualRoot), inheritedModel)
if err != nil {
delete(a.tabs, tabID)
a.removeTabOrderLocked(tabID)
a.mu.Unlock()
return TabMeta{}, err
}
created.SessionPath = prePath
a.saveTabsLocked()
meta := a.tabMeta(created, true)
a.mu.Unlock()
a.startTabControllerBuild(created)
a.emitProjectTreeChanged()
return enrichTabMeta(meta), nil
}
// blankTabMatchesTargetLocked returns true if tab is a reusable blank tab
// matching the given scope/project root — no running controller, no real history.
func (a *App) blankTabMatchesTargetLocked(tab *WorkspaceTab, scope, workspaceRoot string) bool {
if tab == nil || tab.Scope != scope {
return false
}
if scope == "project" && !sameProjectRoot(tab.WorkspaceRoot, workspaceRoot) {
return false
}
if tab.Ctrl == nil {
return blankTabSessionPathHasNoContent(tab)
}
if tab.hasActiveRuntimeWork() {
return false
}
return !messagesHaveConversationContent(tab.Ctrl.History())
}
func createEmptySessionFile(dir, model string) (string, error) {
dir = strings.TrimSpace(dir)
if dir == "" {
return "", fmt.Errorf("session dir is required")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
for i := 0; i < 3; i++ {
path := agent.NewSessionPath(dir, model)
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o644)
if err == nil {
if closeErr := f.Close(); closeErr != nil {
return "", closeErr
}
return path, nil
}
if os.IsExist(err) {
continue
}
return "", err
}
return "", fmt.Errorf("create empty session file: exhausted filename retries")
}
func blankTabSessionPathHasNoContent(tab *WorkspaceTab) bool {
if tab == nil {
return false
}
if strings.TrimSpace(tab.SessionPath) == "" {
return true
}
return sessionPathHasNoContent(tabSessionDir(tab), tab.SessionPath)
}
func sessionPathHasNoContent(sessionDir, sessionPath string) bool {
if strings.TrimSpace(sessionPath) == "" {
return true
}
path, ok := pinnedTabSessionPath(sessionDir, sessionPath)
if !ok {
return false
}
info, err := os.Stat(path)
if err != nil {
return false
}
if info.IsDir() {
return false
}
if info.Size() == 0 {
return true
}
session, err := agent.LoadSession(path)
if err != nil {
return false
}
return !session.HasContent()
}
func resetReusableBlankTabTitle(tab *WorkspaceTab, scope, workspaceRoot string) error {
if tab == nil {
return nil
}
topicID := strings.TrimSpace(tab.TopicID)
if topicID == "" {
return nil
}
titleRoot := topicTitleRoot(scope, workspaceRoot)
if source := loadTopicTitleSource(titleRoot, topicID); source != topicTitleSourceAuto {
return nil
}
if err := setTopicTitleWithSource(titleRoot, topicID, defaultTopicTitle, topicTitleSourceAuto); err != nil {
return err
}
_ = deleteTopicAutoTitleMeta(titleRoot, topicID)
tab.TopicTitle = defaultTopicTitle
return nil
}
// indexedBlankTopicIDLocked finds a blank topic ID that is indexed on disk
// but not open in any tab — for reusing without creating a new topic.
func (a *App) indexedBlankTopicIDLocked(scope, workspaceRoot string) string {
titleRoot := topicTitleRoot(scope, workspaceRoot)
titles := loadTopicTitles(titleRoot)
f := loadProjectsFile()
var topicIDs []string
if scope == "global" {
topicIDs = orderedTopicIDs(f.GlobalTopics, titles)
} else if i := projectIndexByRoot(f.Projects, workspaceRoot); i >= 0 {
topicIDs = orderedTopicIDs(f.Projects[i].Topics, titles)
}
if len(topicIDs) == 0 {
return ""
}
// Blank-tab reuse is an automatic write path: the reused ID flows into
// ensureTopicIndexed, whose intentional single-topic prepend clears delete
// tombstones. Picking a tombstoned topic here (its default title can
// linger title-only after a delete raced a scan save) would therefore
// fully resurrect a topic the user removed — skip them.
deletedTopics := make(map[string]bool, len(f.DeletedTopics))
for _, id := range f.DeletedTopics {
deletedTopics[id] = true
}
openTopics := map[string]bool{}
for _, tab := range a.tabs {
if tab == nil || tab.Scope != scope || strings.TrimSpace(tab.TopicID) == "" {
continue
}
if scope == "project" && !sameProjectRoot(tab.WorkspaceRoot, workspaceRoot) {
continue
}
openTopics[tab.TopicID] = true
}
seenSessionDirs := map[string]bool{}
sessionIndexes := []topicSessionDirIndex{}
addSessionIndex := func(dir string) {
dir = cleanDesktopPath(dir)
if dir == "" {
return
}
if seenSessionDirs[dir] {
return
}
seenSessionDirs[dir] = true
if index, err := topicSessionIndexForDir(dir); err == nil {
sessionIndexes = append(sessionIndexes, index)
}
}
if scope == "project" {
addSessionIndex(desktopSessionDir(workspaceRoot))
} else {
addSessionIndex(config.SessionDir())
addSessionIndex(desktopSessionDir(globalWorkspaceRoot()))
}
for _, topicID := range topicIDs {
if deletedTopics[topicID] || openTopics[topicID] {
continue
}
if topicTitleForTab(scope, workspaceRoot, topicID) != defaultTopicTitle {
continue
}
hasSession := false
leaseHeld := false
for _, index := range sessionIndexes {
if topicSessionIndexHasContentTopic(index, topicID) {
hasSession = true
break
}
if topicSessionIndexHasForeignLeaseTopic(index, topicID) {
leaseHeld = true
}
}
if hasSession || leaseHeld {
continue
}
return topicID
}
return ""
}
// SetActiveTab switches the frontend's active tab. A no-op when tabID is
// already active or unknown.
func (a *App) SetActiveTab(tabID string) error {
a.mu.RLock()
_, ok := a.tabs[tabID]
active := a.tabs[a.activeTabID]
alreadyActive := a.activeTabID == tabID
a.mu.RUnlock()
if !ok {
return fmt.Errorf("tab %q not found", tabID)
}
if alreadyActive {
return nil
}
if err := a.snapshotTabForAction(active, "switching tabs"); err != nil {
return err
}
a.mu.Lock()
if _, ok := a.tabs[tabID]; !ok {
a.mu.Unlock()
return fmt.Errorf("tab %q not found", tabID)
}
if a.activeTabID == tabID {
a.mu.Unlock()
return nil
}
a.activeTabID = tabID
dir, entries, activeID, version := a.saveTabsCollectLocked()
a.mu.Unlock()
// I/O outside the lock — disk writes can block for hundreds of ms on
// Windows when antivirus or the search indexer briefly locks the file.
a.saveTabsWrite(dir, entries, activeID, version)
return nil
}
// ReorderTabs persists the frontend's manual tab order. The submitted order must
// contain every currently open tab exactly once.
func (a *App) ReorderTabs(tabIDs []string) error {
a.mu.Lock()
defer a.mu.Unlock()
if len(tabIDs) != len(a.tabs) {
return fmt.Errorf("tab order length mismatch")
}
seen := make(map[string]bool, len(tabIDs))
next := make([]string, 0, len(tabIDs))
for _, id := range tabIDs {
if _, ok := a.tabs[id]; !ok {
return fmt.Errorf("tab %q not found", id)
}
if seen[id] {
return fmt.Errorf("duplicate tab %q", id)
}
seen[id] = true
next = append(next, id)
}
a.tabOrder = next
a.saveTabsLocked()
return nil
}
// CloseTab removes a visible tab. If the tab's session still has foreground or
// background work, the controller is detached so closing a view does not destroy
// the session runtime.
func (a *App) CloseTab(tabID string) error {
a.sessionRemovalMu.Lock()
defer a.sessionRemovalMu.Unlock()
a.mu.Lock()
tab, ok := a.tabs[tabID]
if !ok {
a.mu.Unlock()
return fmt.Errorf("tab %q not found", tabID)
}
if len(a.tabs) <= 1 {
a.mu.Unlock()
return fmt.Errorf("cannot close the last tab")
}
a.mu.Unlock()
// Snapshot while the tab binding is still present, but outside a.mu because
// snapshot recovery can re-enter App and acquire a.mu. sessionRemovalMu keeps
// DeleteSession/topic/workspace removal from trashing the same files while
// this save is in flight.
if err := a.snapshotTab(tab); err != nil {
slog.Warn("desktop: snapshot before closing tab failed", "tab", tabID, "err", err)
return fmt.Errorf("save current session before closing tab: %w", err)
}
if err := a.saveTabSessionMetaForCurrentSession(tab); err != nil {
slog.Warn("desktop: session metadata before closing tab failed", "tab", tabID, "err", err)
return fmt.Errorf("save current session metadata before closing tab: %w", err)
}
a.mu.Lock()
if current := a.tabs[tabID]; current != tab {
a.mu.Unlock()
if current == nil {
return fmt.Errorf("tab %q not found", tabID)
}
return fmt.Errorf("tab %q changed while closing", tabID)
}
if len(a.tabs) <= 1 {
a.mu.Unlock()
return fmt.Errorf("cannot close the last tab")
}
if tab.Ctrl == nil || !tab.hasActiveRuntimeWork() {
a.markTabRemovedLocked(tab)
}
ordered := a.orderedTabIDsLocked()
closedIndex := -1
for i, id := range ordered {
if id == tabID {
closedIndex = i
break
}
}
delete(a.tabs, tabID)
a.removeTabOrderLocked(tabID)
wasActive := a.activeTabID == tabID
if wasActive {
a.activeTabID = ""
if len(a.tabOrder) > 0 {
nextIndex := closedIndex
if nextIndex < 0 {
nextIndex = 0
}
if nextIndex >= len(a.tabOrder) {
nextIndex = len(a.tabOrder) - 1
}
a.activeTabID = a.tabOrder[nextIndex]
}
}
a.saveTabsLocked()
// Snapshot the teardown targets while still holding the lock: the tab is
// no longer reachable from a.tabs after this section, but locked writers
// holding stale pointers (rememberTabSessionPath, applySessionBindingToTab)
// can still write its fields under a.mu.
closeCtrl := tab.Ctrl
closeSink := tab.sink
a.mu.Unlock()
// Tear down outside the lock.
discardPath, discardTransientBlank := a.transientBlankSessionArtifactPath(tab)
if closeCtrl != nil {
if controllerHasActiveRuntimeWork(closeCtrl) && a.detachSessionRuntime(tab) {
// Detached runtimes keep running and must keep saving: do not
// clear the path or drain for them.
return nil
}
closeCtrl.SetSessionPath("") // future snapshots become no-ops
a.quiesceTabAutosave(tab) // wait for any in-flight snapshot to finish
closeCtrl.Cancel()
closeCtrl.Close()
// Release the shared plugin host reference. The host stays alive as
// long as any other tab for the same workspace root holds a reference;
// on the last release the host is closed and its subprocesses exit.
a.releaseTabSharedHost(tab)
tab.releaseSessionLease()
}
if closeSink != nil {
closeSink.clearContext() // stop further emissions (nil ctx -> Emit becomes no-op)
}
if discardTransientBlank {
discardTransientBlankSessionArtifacts(discardPath)
}
return nil
}
func (a *App) keepOnlyVisibleTab(tabID string) (TabMeta, error) {
type pruneCandidate struct {
id string
tab *WorkspaceTab
}
// sessionRemovalMu covers snapshotting, pruning the hidden bindings, and
// closing the removed runtimes (a detached runtime must finish its
// in-flight autosave before DeleteSession can see the files). The
// project-tree event stays outside so a listener can never re-enter a
// removal path while the lock is held.
meta, err := func() (TabMeta, error) {
a.sessionRemovalMu.Lock()
defer a.sessionRemovalMu.Unlock()
a.mu.Lock()
active := a.tabs[tabID]
if active == nil {
a.mu.Unlock()
return TabMeta{}, fmt.Errorf("tab %q not found", tabID)
}
candidates := make([]pruneCandidate, 0, len(a.tabs)-1)
for id, tab := range a.tabs {
if id == tabID {
continue
}
candidates = append(candidates, pruneCandidate{id: id, tab: tab})
}
a.mu.Unlock()
// Keep tab bindings in a.tabs while saving so DeleteSession still sees
// them, but do not hold a.mu: Snapshot can run recovery callbacks that
// re-enter App and need the same lock.
snapshotted := make(map[string]*WorkspaceTab, len(candidates))
for _, candidate := range candidates {
id, tab := candidate.id, candidate.tab
snapshotted[id] = tab
if err := a.snapshotTab(tab); err != nil {
slog.Warn("desktop: snapshot before pruning hidden tab failed", "tab", id, "err", err)
return TabMeta{}, fmt.Errorf("save current session before switching tabs: %w", err)
}
if err := a.saveTabSessionMetaForCurrentSession(tab); err != nil {
slog.Warn("desktop: session metadata before pruning hidden tab failed", "tab", id, "err", err)
return TabMeta{}, fmt.Errorf("save current session metadata before switching tabs: %w", err)
}
}
a.mu.Lock()
active = a.tabs[tabID]
if active == nil {
a.mu.Unlock()
return TabMeta{}, fmt.Errorf("tab %q not found", tabID)
}
for id, tab := range a.tabs {
if id != tabID && snapshotted[id] != tab {
a.mu.Unlock()
return TabMeta{}, fmt.Errorf("visible tabs changed while switching; retry")
}
}
a.activeTabID = tabID
removed := make([]*WorkspaceTab, 0, len(candidates))
for _, candidate := range candidates {
id, tab := candidate.id, candidate.tab
if tab == nil || a.tabs[id] != tab {
continue
}
if tab.Ctrl == nil || !tab.hasActiveRuntimeWork() {
a.markTabRemovedLocked(tab)
}
removed = append(removed, tab)
delete(a.tabs, id)
a.removeTabOrderLocked(id)
}
a.tabOrder = []string{tabID}
a.saveTabsLocked()
meta := a.tabMeta(active, true)
a.mu.Unlock()
for _, tab := range removed {
a.removeVisibleTabRuntime(tab)
}
return meta, nil
}()
if err != nil {
return TabMeta{}, err
}
a.emitProjectTreeChanged()
return enrichTabMeta(meta), nil
}
func (a *App) applySingleSurfaceTabPolicy() error {
a.singleSurfaceMu.Lock()
defer a.singleSurfaceMu.Unlock()
a.mu.RLock()
tabID := a.activeTabID
if tabID == "" || a.tabs[tabID] == nil {
for _, id := range a.tabOrder {
if a.tabs[id] != nil {
tabID = id
break
}
}
if tabID == "" {
for id := range a.tabs {
tabID = id
break
}
}
}
a.mu.RUnlock()
if tabID == "" {
return nil
}
_, err := a.keepOnlyVisibleTab(tabID)
return err
}
func (a *App) removeVisibleTabRuntime(tab *WorkspaceTab) {
if tab == nil {
return
}
if err := a.snapshotTab(tab); err != nil {
slog.Warn("desktop: snapshot before removing visible tab runtime failed", "tab", tab.ID, "err", err)
}
discardPath, discardTransientBlank := a.transientBlankSessionArtifactPath(tab)
a.mu.RLock()
ctrl := tab.Ctrl
a.mu.RUnlock()
if ctrl != nil && controllerHasActiveRuntimeWork(ctrl) && a.detachSessionRuntime(tab) {
return
}
a.markTabRemoved(tab)
a.closeTabRuntime(tab)
if discardTransientBlank {
discardTransientBlankSessionArtifacts(discardPath)
}
}
// transientBlankSessionArtifactPath reports the artifact path to discard when
// closing a still-blank tab. It snapshots the racy tab fields under a.mu and
// keeps the file probe (sessionPathHasNoContent) outside the lock. Callers
// must not hold a.mu.
func (a *App) transientBlankSessionArtifactPath(tab *WorkspaceTab) (string, bool) {
if tab == nil {
return "", false
}
snap := a.tabRuntimeSnapshot(tab)
if snap.readOnly || strings.TrimSpace(snap.topicID) != "" || controllerHasActiveRuntimeWork(snap.ctrl) {
return "", false
}
if strings.TrimSpace(snap.sessionPath) == "" {
return "", false
}
dir := sessionDirForSnapshot(snap)
if !sessionPathHasNoContent(dir, snap.sessionPath) {
return "", false
}
path, ok := pinnedTabSessionPath(dir, snap.sessionPath)
if !ok {
return "", false
}
return path, true
}
func discardTransientBlankSessionArtifacts(path string) {
if strings.TrimSpace(path) == "" {
return
}
if err := removeDesktopSessionArtifacts(path); err != nil {
slog.Warn("desktop: discard transient blank session artifacts failed", "path", path, "err", err)
}
}
func (a *App) markTabRemoved(tab *WorkspaceTab) {
a.mu.Lock()
a.markTabRemovedLocked(tab)
a.mu.Unlock()
}
func (a *App) markTabRemovedLocked(tab *WorkspaceTab) {
if tab == nil {
return
}
tab.removed = true
if tab.buildCancel != nil {
tab.buildCancel()
tab.buildCancel = nil
}
}
// tabBuildSupersededLocked reports whether an in-flight build lost ownership
// of its tab: the tab was removed/replaced, or a session rebind bumped
// buildGeneration to invalidate it. Generation 0 marks the synchronous
// rebuild paths, which serialize through runtimeRebuildMu instead and are
// never superseded by generation bumps. Callers must hold a.mu.
func (a *App) tabBuildSupersededLocked(tab *WorkspaceTab, generation uint64) bool {
if tab == nil || tab.removed || a.tabs[tab.ID] != tab {
return true
}
return generation != 0 && tab.buildGeneration != generation
}
func (a *App) tabBuildSuperseded(tab *WorkspaceTab, generation uint64) bool {
if tab == nil {
return true
}
a.mu.RLock()
defer a.mu.RUnlock()
return a.tabBuildSupersededLocked(tab, generation)
}
// supersedeTabBuildLocked invalidates any in-flight startup build and cancels
// its context. A synchronous rebuild (model/effort/token switch) that has
// already installed its controller calls this so a slower blank-session build
// cannot finish afterward, overwrite tab.Ctrl, and release or steal the
// session lease the switch just bound. Callers must hold a.mu.
func (a *App) supersedeTabBuildLocked(tab *WorkspaceTab) {
if tab == nil {
return
}
tab.buildGeneration++
if tab.buildCancel != nil {
tab.buildCancel()
tab.buildCancel = nil
}
}
// abandonSupersededBuild cleans up after a build that lost tab ownership
// mid-flight (removed tab, or a session rebind bumped the generation). It
// releases only what THIS build acquired — its controller, its own
// shared-host reference (rootKey), and the session lease bound to its own
// path (leaseKey) — and never reads or clears the tab's SharedHostKey or
// lease outright: on a live rebound tab the replacement build may already
// have published its own key and lease there, and taking those would leak
// the new runtime's host reference (or close a host still in use) and strip
// the new session's lease. Callers must not hold a.mu.
func (a *App) abandonSupersededBuild(tab *WorkspaceTab, ctrl control.SessionAPI, rootKey, leaseKey string) {
if ctrl != nil {
ctrl.Close()
}
if rootKey != "" {
a.releaseSharedHost(rootKey)
}
tab.releaseSessionLeaseForKey(leaseKey)
}
func (a *App) clearTabBuildCancel(tab *WorkspaceTab, generation uint64, cancel context.CancelFunc, keepContext bool) {
if cancel == nil {
return
}
if !keepContext {
defer cancel()
}
if tab == nil {
return
}
a.mu.Lock()
if tab.buildGeneration == generation {
tab.buildCancel = nil
}
a.mu.Unlock()
}
func (a *App) closeTabRuntime(tab *WorkspaceTab) {
if tab == nil {
return
}
a.mu.RLock()
ctrl := tab.Ctrl
sink := tab.sink
a.mu.RUnlock()
if ctrl != nil {
ctrl.SetSessionPath("") // future snapshots become no-ops
a.quiesceTabAutosave(tab)
ctrl.Cancel()
ctrl.Close()
a.releaseTabSharedHost(tab)
}
if sink != nil {
sink.clearContext()
}
tab.releaseSessionLease()
}
// buildTabController assembles a controller for a tab in the background, the
// same way buildController works for the single-controller App. On success it
// wires the controller and flips Ready; on failure it stores StartupErr.
func (a *App) startTabControllerBuild(tab *WorkspaceTab) {
buildCtx, cancel := context.WithCancel(a.bootContext())
a.mu.Lock()
if tab == nil || tab.removed {
a.mu.Unlock()
cancel()
return
}
tab.buildGeneration++
generation := tab.buildGeneration
tab.buildCancel = cancel
a.mu.Unlock()
if a.ctx == nil {
a.buildTabControllerWithContext(tab, loadedTabSession{}, buildCtx, generation, cancel)
return
}
go a.buildTabControllerWithContext(tab, loadedTabSession{}, buildCtx, generation, cancel)
}
func (a *App) buildTabController(tab *WorkspaceTab) {
a.buildTabControllerWithLoadedSession(tab, loadedTabSession{})
}
type loadedTabSession struct {
Path string
Session *agent.Session
}
func (s loadedTabSession) matches(path string) bool {
return s.Session != nil && sessionRuntimeKey(s.Path) != "" && sessionRuntimeKey(s.Path) == sessionRuntimeKey(path)
}
func (a *App) buildTabControllerWithLoadedSession(tab *WorkspaceTab, loadedSession loadedTabSession) {
a.buildTabControllerWithContext(tab, loadedSession, a.bootContext(), 0, nil)
}
func (a *App) desktopNotificationSender() notify.Sender {
if a == nil {
return notify.NewPlatformSender()
}
a.notificationSenderOnce.Do(func() {
if a.notificationSender == nil {
a.notificationSender = notify.NewPlatformSender()
}
})
return a.notificationSender
}
func (a *App) desktopControllerSink(inner event.Sink, cfg config.NotificationsConfig) event.Sink {
if !cfg.Enabled {
return inner
}
sender := a.desktopNotificationSender()
if sender == nil {
return inner
}
return notify.NewSink(inner, sender, cfg)
}
func setTabStartupError(tab *WorkspaceTab, err error) bool {
if tab == nil {
return false
}
tab.StartupErr = userFacingSessionLeaseError("", err).Error()
tab.StartupErrLeaseHeld = errors.Is(err, agent.ErrSessionLeaseHeld)
return tab.StartupErrLeaseHeld
}
func clearTabStartupError(tab *WorkspaceTab) {
if tab == nil {
return
}
tab.StartupErr = ""
tab.StartupErrLeaseHeld = false
}
func (a *App) buildTabControllerWithContext(tab *WorkspaceTab, loadedSession loadedTabSession, buildCtx context.Context, buildGeneration uint64, buildCancel context.CancelFunc) {
defer a.recoverToPending("buildTabController")
keepBuildContext := false
defer func() {
a.clearTabBuildCancel(tab, buildGeneration, buildCancel, keepBuildContext)
}()
wailsCtx := a.ctx
if a.tabBuildSuperseded(tab, buildGeneration) {
return
}
a.reconcileTabWithPinnedSessionMeta(tab)
// Snapshot the identity/profile fields under a.mu before the off-lock
// stretch: session rebinding, recovery, and topic assignment write them
// under the lock while this goroutine builds.
a.mu.RLock()
tabWorkspaceRoot := tab.WorkspaceRoot
tabScope := tab.Scope
tabTopicID := tab.TopicID
tabSessionPath := tab.SessionPath
tabModel := tab.model
tabSink := tab.sink
a.mu.RUnlock()
root := tabWorkspaceRoot
if root == "" {
if wd, err := os.Getwd(); err == nil {
root = wd
}
}
// Load config for this tab's workspace root.
_ = config.MigrateLegacyCredentialsForRoot(root)
cfg, err := config.LoadForRoot(root)
if err != nil {
leaseHeld := false
a.mu.Lock()
if a.tabBuildSupersededLocked(tab, buildGeneration) {
a.mu.Unlock()
return
}
leaseHeld = setTabStartupError(tab, err)
tab.Ready = true
tab.releaseSessionLease()
a.mu.Unlock()
if leaseHeld {
a.scheduleDeferredStartupBuild(tab.ID)
}
a.emitReady(wailsCtx, tab.ID)
return
}
if a.tabBuildSuperseded(tab, buildGeneration) {
return
}
if tabSink != nil {
tabSink.setContext(wailsCtx)
}
sessionDir := desktopSessionDir(root)
topicID := strings.TrimSpace(tabTopicID)
// Assign Global topics to legacy sessions in the global session dir so
// imported history appears in the project tree regardless of which tab
// triggered the build (the migration now sends everything to global).
migratedGlobalTopics := migrateLegacySessionsIntoGlobalTopics(config.SessionDir())
if len(migratedGlobalTopics) > 0 {
a.emitProjectTreeChanged()
}
if tabScope == "global" && topicID == "" && len(migratedGlobalTopics) > 0 {
topicID = migratedGlobalTopics[0]
topicTitle := topicTitleForTab("global", "", topicID)
a.mu.Lock()
if a.tabBuildSupersededLocked(tab, buildGeneration) {
a.mu.Unlock()
return
}
if strings.TrimSpace(tab.TopicID) == "" {
tab.TopicID = topicID
tab.TopicTitle = topicTitle
a.saveTabsLocked()
} else {
topicID = strings.TrimSpace(tab.TopicID)
}
a.mu.Unlock()
}
if topicID != "" {
if _, dir := a.findTopicSessionForTarget(tabScope, tabWorkspaceRoot, topicID); dir != "" {
sessionDir = dir
}
}
startupSessionPath := ""
if pinnedPath, ok := pinnedTabSessionPath(sessionDir, tabSessionPath); ok {
if !agent.IsCleanupPending(pinnedPath) {
startupSessionPath = pinnedPath
}
} else if topicID != "" {
startupSessionPath = findTopicSession(sessionDir, topicID)
}
model := strings.TrimSpace(tabModel)
if sessionModel, ok := agent.LoadSessionModel(startupSessionPath); ok {
config.NormalizeLegacyMimoCustomProvidersForRefs(cfg, sessionModel)
if _, ok := cfg.ResolveModel(sessionModel); ok {
model = sessionModel
}
}
if model == "" {
model = cfg.DefaultModel
}
config.NormalizeLegacyMimoCustomProvidersForRefs(cfg, model)
requestedModel := model
if resolved, fallback, ok := cfg.ResolveModelWithFallback(model); ok {
if fallback && strings.TrimSpace(tabModel) != "" {
a.noticeForTab(tab.ID, fmt.Sprintf("model %q is no longer available; switched to %s", requestedModel, resolved))
}
model = resolved
}
// Acquire a shared plugin host for this workspace root so MCP processes
// are launched once per root, not once per tab. SharedHostKey is an a.mu-
// guarded field (takeTabSharedHostKey reads it under the lock during
// teardown), so publish it under the lock alongside the model. Capture the
// tab-local runtime profile here too: bound methods (SetModeForTab,
// SetGoalForTab, SetEffortForTab, ...) write these under a.mu, so the
// off-lock boot.Build below must read a locked snapshot, not the live tab.
rootKey := tabWorkspaceRoot
if rootKey == "" {
rootKey = "__global__" // stable key for global workspace tabs
}
a.mu.Lock()
if a.tabBuildSupersededLocked(tab, buildGeneration) {
a.mu.Unlock()
return
}
tab.model = model
tab.Label = model
tab.SharedHostKey = rootKey
buildEffort := cloneStringPtr(tab.effort)
buildTokenMode := boot.NormalizeTokenMode(tab.tokenMode)
buildMode := tab.mode
buildToolApprovalMode := tab.toolApprovalMode
buildGoal := tab.goal
buildSink := tab.sink
a.saveTabsLocked()
a.mu.Unlock()
sharedHost := a.acquireSharedHost(rootKey)
sink := a.desktopControllerSink(buildSink, cfg.Notifications)
ctrl, err := boot.Build(buildCtx, boot.Options{
Model: model,
RequireKey: false,
Sink: sink,
WorkspaceRoot: root,
SessionDir: sessionDir,
EffortOverride: cloneStringPtr(buildEffort),
TokenMode: buildTokenMode,
SharedHost: sharedHost,
CleanupPendingReconciler: reconcileDesktopCleanupPending,
SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab),
OnSessionRecovered: a.handleTabSessionRecovered(tab),
})
if err != nil {
leaseHeld := false
a.mu.Lock()
if a.tabBuildSupersededLocked(tab, buildGeneration) {
a.mu.Unlock()
a.abandonSupersededBuild(tab, nil, rootKey, "")
return
}
leaseHeld = setTabStartupError(tab, err)
tab.Ready = true
hostKey := takeTabSharedHostKey(tab)
tab.releaseSessionLease()
a.mu.Unlock()
if hostKey != "" {
a.releaseSharedHost(hostKey)
}
if leaseHeld {
a.scheduleDeferredStartupBuild(tab.ID)
}
a.emitReady(wailsCtx, tab.ID)
return
}
if a.tabBuildSuperseded(tab, buildGeneration) {
a.abandonSupersededBuild(tab, ctrl, rootKey, "")
return
}
a.bindControllerDisplayRecorder(ctrl)
ctrl.EnableInteractiveApproval()
applyTabModeToController(ctrl, buildMode)
applyTabToolApprovalModeToController(ctrl, buildToolApprovalMode)
ctrl.SetGoal(buildGoal)
acquiredLeaseKey := ""
if dir := ctrl.SessionDir(); dir != "" {
migratedTopics := migrateLegacySessionsIntoGlobalTopics(dir)
if len(migratedTopics) > 0 {
a.emitProjectTreeChanged()
}
// Refresh the topic/session locals under the lock: a rebind or the
// recovery callback may have rewritten them since the early snapshot.
a.mu.RLock()
tabTopicID = strings.TrimSpace(tab.TopicID)
tabSessionPath = tab.SessionPath
a.mu.RUnlock()
if tabScope == "global" && tabTopicID == "" && len(migratedTopics) > 0 {
topicID := migratedTopics[0]
topicTitle := topicTitleForTab("global", "", topicID)
a.mu.Lock()
if !a.tabBuildSupersededLocked(tab, buildGeneration) && strings.TrimSpace(tab.TopicID) == "" {
tab.TopicID = topicID
tab.TopicTitle = topicTitle
tabTopicID = topicID
a.saveTabsLocked()
} else {
tabTopicID = strings.TrimSpace(tab.TopicID)
}
a.mu.Unlock()
}
var path string
var resumeSession *agent.Session
// Prefer the exact session file persisted for this tab. Topic lookup is a
// compatibility fallback for older desktop-tabs.json files that only stored
// topicId and could pick the wrong session when one topic had multiple files.
if loaded, pinnedPath, ok := loadPinnedTabSessionWithPreload(dir, tabSessionPath, loadedSession); ok {
path = pinnedPath
resumeSession = loaded
}
if path == "" && tabTopicID != "" {
existingPath := findTopicSession(dir, tabTopicID)
if existingPath != "" {
if loaded, err := loadResumableSession(existingPath); err == nil {
path = existingPath
resumeSession = loaded
}
}
}
if path == "" {
path = agent.NewSessionPath(dir, ctrl.Label())
}
// Write/update scope/session meta.
if path != "" {
if a.attachExistingSessionRuntime(tab, path, wailsCtx) {
ctrl.Close()
a.releaseSharedHost(rootKey)
a.emitReady(wailsCtx, tab.ID)
return
}
preLeaseKey := tab.sessionLeaseRuntimeKey()
if err := tab.ensureSessionLease(path); err != nil {
leaseHeld := false
a.mu.Lock()
if a.tabBuildSupersededLocked(tab, buildGeneration) {
a.mu.Unlock()
a.abandonSupersededBuild(tab, ctrl, rootKey, "")
return
}
leaseHeld = setTabStartupError(tab, err)
tab.Ready = true
hostKey := takeTabSharedHostKey(tab)
// Release only a lease bound to THIS build's session: a failed
// ensure leaves any prior lease untouched, and that lease may
// belong to a runtime a concurrent switch just installed.
tab.releaseSessionLeaseForKey(sessionRuntimeKey(path))
a.mu.Unlock()
ctrl.Close()
if hostKey != "" {
a.releaseSharedHost(hostKey)
}
if leaseHeld {
a.scheduleDeferredStartupBuild(tab.ID)
}
a.emitReady(wailsCtx, tab.ID)
return
}
// Remember which lease THIS build bound: if the build is later
// superseded, only a lease still carrying this key may be
// released (see abandonSupersededBuild). A fast-path reuse means
// the lease existed before this build (bound by a concurrent
// switch or recovery) — it is not ours to release.
if key := sessionRuntimeKey(path); key != preLeaseKey {
acquiredLeaseKey = key
}
// Re-check ownership right after the (potentially slow) lease
// bind: a rebind that superseded this build while ensure was in
// flight has already retargeted the tab, and continuing into
// Resume/persistTabSessionPath would write the stale session
// path back onto the rebound tab.
if a.tabBuildSuperseded(tab, buildGeneration) {
a.abandonSupersededBuild(tab, ctrl, rootKey, acquiredLeaseKey)
return
}
if resumeSession != nil {
ctrl.Resume(sessionWithFreshSystemPrompt(resumeSession, systemPromptFrom(ctrl.History())), path)
} else {
ctrl.SetSessionPath(path)
}
a.persistTabSessionPath(tab, path)
a.mu.RLock()
indexScope := tab.Scope
indexRoot := tab.WorkspaceRoot
indexTopicID := strings.TrimSpace(tab.TopicID)
indexTopicTitle := tab.TopicTitle
a.mu.RUnlock()
if indexTopicID != "" {
if err := ensureTopicIndexed(indexScope, indexRoot, indexTopicID, indexTopicTitle, loadTopicTitleSource(topicTitleRoot(indexScope, indexRoot), indexTopicID)); err == nil {
a.emitProjectTreeChanged()
}
}
// Key telemetry to the session this build binds: restore its
// persisted sidecar, or start from zero when none exists (fresh
// session, CLI-created session, pre-telemetry session). Keeping
// the previous session's totals here made 会话费用 accumulate
// across sessions and persisted the stale totals into the new
// session's sidecar on the next event (#5850).
snapshot := loadTelemetry(path + ".telemetry.json")
tab.telemMu.Lock()
tab.readTelemetry = snapshot.ReadFiles
tab.usageTelemetry = snapshot.Usage
tab.telemetrySessionKey = sessionRuntimeKey(path)
tab.telemMu.Unlock()
}
}
a.mu.Lock()
if a.tabBuildSupersededLocked(tab, buildGeneration) {
a.mu.Unlock()
a.abandonSupersededBuild(tab, ctrl, rootKey, acquiredLeaseKey)
return
}
tab.Ctrl = ctrl
tab.Label = ctrl.Label()
tab.Ready = true
clearTabStartupError(tab)
keepBuildContext = true
a.mu.Unlock()
a.emitReady(wailsCtx, tab.ID)
}
type sessionBinding struct {
path string
scope string
workspaceRoot string
topicID string
topicTitle string
hasMeta bool
meta agent.BranchMeta
}
func (a *App) reconcileTabWithPinnedSessionMeta(tab *WorkspaceTab) (string, bool) {
if tab == nil {
return "", false
}
a.mu.RLock()
current := a.tabs[tab.ID]
path := strings.TrimSpace(tab.SessionPath)
ctrl := tab.Ctrl
scope := tab.Scope
workspaceRoot := tab.WorkspaceRoot
a.mu.RUnlock()
if current != tab {
return "", false
}
if path != "" {
if resolved, ok := a.reconcileTabWithSessionPath(tab, path); ok {
return resolved, true
}
}
if ctrl == nil {
return "", false
}
path = strings.TrimSpace(ctrl.SessionPath())
if path == "" {
return "", false
}
binding, ok := a.resolveSessionBinding(path)
if !ok {
return "", false
}
if scope == "project" && binding.scope != "project" && normalizeProjectRoot(workspaceRoot) != "" {
if root, ok := safeControllerWorkspaceRoot(ctrl); ok && sameProjectRoot(root, workspaceRoot) {
return "", false
}
}
a.applySessionBindingToTab(tab, binding)
return binding.path, true
}
func (a *App) reconcileTabWithSessionPath(tab *WorkspaceTab, sessionPath string) (string, bool) {
if tab == nil || strings.TrimSpace(sessionPath) == "" {
return "", false
}
binding, ok := a.resolveSessionBinding(sessionPath)
if !ok {
return "", false
}
a.applySessionBindingToTab(tab, binding)
return binding.path, true
}
func (a *App) applySessionBindingToTab(tab *WorkspaceTab, binding sessionBinding) {
if tab == nil || binding.path == "" {
return
}
scope := binding.scope
workspaceRoot := binding.workspaceRoot
if scope == "" {
scope = "global"
}
if scope == "project" {
workspaceRoot = normalizeProjectRoot(workspaceRoot)
if workspaceRoot == "" {
return
}
a.registerProjectRoot(workspaceRoot)
} else {
scope = "global"
workspaceRoot = globalTabWorkspaceRoot()
}
topicID := strings.TrimSpace(binding.topicID)
topicTitle := strings.TrimSpace(binding.topicTitle)
if topicTitle == "" && topicID != "" {
topicTitle = topicTitleForTab(scope, workspaceRoot, topicID)
}
a.mu.Lock()
current := a.tabs[tab.ID]
if current != nil && current != tab {
a.mu.Unlock()
return
}
oldScope := tab.Scope
oldWorkspaceRoot := tab.WorkspaceRoot
changed := tab.Scope != scope ||
tab.WorkspaceRoot != workspaceRoot ||
canonicalTabSessionPath(tab.SessionPath) != canonicalTabSessionPath(binding.path)
// Spelling-only root updates still persist above, but an equivalent root is
// the same workspace — do not warn the user about a switch.
workspaceChanged := tab.Scope != scope || !sameProjectRoot(tab.WorkspaceRoot, workspaceRoot)
tab.Scope = scope
tab.WorkspaceRoot = workspaceRoot
tab.SessionPath = canonicalTabSessionPath(binding.path)
if topicID != "" {
changed = changed || tab.TopicID != topicID
tab.TopicID = topicID
}
if topicTitle != "" {
changed = changed || tab.TopicTitle != topicTitle
tab.TopicTitle = topicTitle
}
if changed && current == tab {
a.saveTabsLocked()
}
sink := tab.sink
a.mu.Unlock()
if workspaceChanged && sink != nil {
sink.Emit(event.Event{
Kind: event.Notice,
Level: event.LevelWarn,
Text: sessionBindingWorkspaceNotice(oldScope, oldWorkspaceRoot, scope, workspaceRoot),
})
}
}
func sessionBindingWorkspaceNotice(oldScope, oldWorkspaceRoot, scope, workspaceRoot string) string {
return "Session belongs to " + describeSessionBindingWorkspace(scope, workspaceRoot) +
"; switched tab from " + describeSessionBindingWorkspace(oldScope, oldWorkspaceRoot) +
" to match the saved session."
}
func describeSessionBindingWorkspace(scope, workspaceRoot string) string {
if strings.TrimSpace(scope) == "project" && strings.TrimSpace(workspaceRoot) != "" {
return fmt.Sprintf("project workspace %q", strings.TrimSpace(workspaceRoot))
}
return "global workspace"
}
func (a *App) resolveSessionBinding(sessionPath string) (sessionBinding, bool) {
sessionPath = strings.TrimSpace(sessionPath)
if sessionPath == "" {
return sessionBinding{}, false
}
for _, dir := range a.knownSessionDirs() {
if binding, ok := sessionBindingInDir(dir, sessionPath); ok {
return binding, true
}
}
if !filepath.IsAbs(sessionPath) {
return sessionBinding{}, false
}
path, err := filepath.Abs(sessionPath)
if err != nil {
return sessionBinding{}, false
}
meta, ok, err := agent.LoadBranchMeta(path)
if err != nil || !ok {
return sessionBinding{}, false
}
for _, dir := range sessionBindingCandidateDirs(meta) {
if binding, ok := sessionBindingInDir(dir, path); ok {
return binding, true
}
}
return sessionBindingFromMeta(path, meta)
}
func sessionBindingCandidateDirs(meta agent.BranchMeta) []string {
if meta.DefaultScope() == "project" {
if root := normalizeProjectRoot(meta.WorkspaceRoot); root != "" {
return []string{desktopSessionDir(root)}
}
return nil
}
return []string{desktopSessionDir(globalWorkspaceRoot()), config.SessionDir()}
}
func sessionBindingInDir(dir, sessionPath string) (sessionBinding, bool) {
path, ok := pinnedTabSessionPath(dir, sessionPath)
if !ok {
return sessionBinding{}, false
}
meta, hasMeta, err := agent.LoadBranchMeta(path)
if err != nil {
return sessionBinding{}, false
}
scope, workspaceRoot, _, ownerOK := legacyMigrationTargetForDir(dir)
if !ownerOK {
if !hasMeta {
return sessionBinding{}, false
}
return sessionBindingFromMeta(path, meta)
}
if scope == "global" {
if !hasMeta {
return sessionBinding{}, false
}
return sessionBindingFromMeta(path, meta)
}
binding := sessionBinding{
path: path,
scope: scope,
workspaceRoot: workspaceRoot,
hasMeta: hasMeta,
meta: meta,
}
if hasMeta {
binding.topicID = strings.TrimSpace(meta.TopicID)
binding.topicTitle = strings.TrimSpace(meta.TopicTitle)
}
if binding.scope == "project" {
binding.workspaceRoot = normalizeProjectRoot(binding.workspaceRoot)
}
return binding, true
}
func sessionBindingFromMeta(path string, meta agent.BranchMeta) (sessionBinding, bool) {
scope := meta.DefaultScope()
workspaceRoot := ""
if scope == "project" {
workspaceRoot = normalizeProjectRoot(meta.WorkspaceRoot)
if workspaceRoot == "" {
return sessionBinding{}, false
}
} else {
scope = "global"
workspaceRoot = globalTabWorkspaceRoot()
}
return sessionBinding{
path: path,
scope: scope,
workspaceRoot: workspaceRoot,
topicID: strings.TrimSpace(meta.TopicID),
topicTitle: strings.TrimSpace(meta.TopicTitle),
hasMeta: true,
meta: meta,
}, true
}
// --- active tab helpers -----------------------------------------------------
// activeTab returns the currently active tab (nil when there are no tabs).
// Self-locking; safe to call from any goroutine without external lock.
func (a *App) activeTab() *WorkspaceTab {
a.mu.RLock()
defer a.mu.RUnlock()
if a.activeTabID == "" {
return nil
}
return a.tabs[a.activeTabID]
}
// activeTabLocked is like activeTab but assumes the caller already holds a.mu
// (either RLock or Lock). Use this inside critical sections that already own
// the lock to avoid double-locking a write-lock holder.
func (a *App) activeTabLocked() *WorkspaceTab {
if a.activeTabID == "" {
return nil
}
return a.tabs[a.activeTabID]
}
// activeCtrl returns the controller of the active tab, or nil.
// Self-locking; safe to call from any goroutine without external lock.
func (a *App) activeCtrl() control.SessionAPI {
a.mu.RLock()
defer a.mu.RUnlock()
return a.activeCtrlLocked()
}
// activeCtrlLocked is like activeCtrl but assumes the caller already holds a.mu.
func (a *App) activeCtrlLocked() control.SessionAPI {
t := a.activeTabLocked()
if t == nil {
return nil
}
return t.Ctrl
}
func (a *App) tabByID(tabID string) *WorkspaceTab {
a.mu.RLock()
defer a.mu.RUnlock()
return a.tabByIDLocked(tabID)
}
func (a *App) tabByIDLocked(tabID string) *WorkspaceTab {
if strings.TrimSpace(tabID) == "" {
return a.activeTabLocked()
}
return a.tabs[tabID]
}
func (a *App) ctrlByTabID(tabID string) control.SessionAPI {
a.mu.RLock()
defer a.mu.RUnlock()
tab := a.tabByIDLocked(tabID)
if tab == nil {
return nil
}
return tab.Ctrl
}
// --- autosave per tab -------------------------------------------------------
const maxTabSnapshotFailureRetries = 2
// autosaveWarnInterval rate-limits the user-facing autosave-failure notice
// per tab; slog keeps recording every failure regardless.
const autosaveWarnInterval = 5 * time.Minute
func tabSnapshotRetryDelay(failures int) time.Duration {
switch {
case failures <= 1:
return 100 * time.Millisecond
case failures == 2:
return 250 * time.Millisecond
default:
return 500 * time.Millisecond
}
}
func (a *App) scheduleTabSnapshot(tabID string) {
a.mu.RLock()
tab := a.tabByEventSinkIDLocked(tabID)
a.mu.RUnlock()
if tab == nil {
return
}
tab.saveMu.Lock()
defer tab.saveMu.Unlock()
if tab.closing {
// Tab is being torn down: don't start new snapshot work that could
// race DeleteSession and resurrect a trashed session file (#4384).
return
}
if tab.saving {
tab.saveAgain = true
return
}
tab.saving = true
tab.saveFailures = 0
go a.tabSnapshotLoop(tab)
}
// quiesceTabAutosave marks the tab as closing and blocks until any in-flight
// tabSnapshotLoop has finished its current (and final) write. After it returns,
// no background goroutine can call Snapshot on this tab's controller again, so
// a subsequent DeleteSession cannot race a late write. Safe to call after the
// controller's session path has been cleared: the loop's Snapshot becomes a
// no-op and it exits on its next iteration.
func (a *App) quiesceTabAutosave(tab *WorkspaceTab) {
if tab == nil {
return
}
tab.saveMu.Lock()
if tab.saveCond == nil {
// saveCond is lazily initialized on first snapshot; if it was never
// set there is no loop to wait for.
tab.closing = true
tab.saveMu.Unlock()
return
}
tab.closing = true
for tab.saving {
tab.saveCond.Wait()
}
tab.saveMu.Unlock()
}
func (a *App) tabSnapshotLoop(tab *WorkspaceTab) {
defer a.recoverToPending("tabSnapshotLoop")
for {
var snapshotErr error
a.mu.RLock()
ctrl := tab.Ctrl
a.mu.RUnlock()
if ctrl != nil {
if err := a.snapshotTab(tab); err == nil {
if !a.maybeAutoTitleTopic(tab) {
a.emitProjectTreeChanged()
}
} else {
snapshotErr = err
}
}
tab.saveMu.Lock()
if tab.saveCond == nil {
tab.saveCond = sync.NewCond(&tab.saveMu)
}
if snapshotErr == nil {
tab.saveFailures = 0
} else {
tab.saveFailures++
}
if tab.closing {
// Tab is being torn down: stop without picking up saveAgain work.
tab.saving = false
tab.saveCond.Broadcast()
tab.saveMu.Unlock()
if snapshotErr != nil {
slog.Warn("desktop: session autosave failed during teardown", "tab", tab.ID, "err", snapshotErr)
}
return
}
if tab.saveAgain {
tab.saveAgain = false
tab.saveMu.Unlock()
if snapshotErr != nil {
slog.Warn("desktop: session autosave failed; newer snapshot queued", "tab", tab.ID, "err", snapshotErr)
}
continue
}
if snapshotErr != nil && tab.saveFailures <= maxTabSnapshotFailureRetries {
delay := tabSnapshotRetryDelay(tab.saveFailures)
attempt := tab.saveFailures
tab.saveMu.Unlock()
// Retries are routine (transient AV/indexer holds); tell the user
// only when the whole burst gives up, not once per attempt.
slog.Warn("desktop: session autosave failed; retrying", "tab", tab.ID, "attempt", attempt, "err", snapshotErr)
time.Sleep(delay)
continue
}
exhausted := snapshotErr
tab.saving = false
tab.saveCond.Broadcast()
tab.saveMu.Unlock()
if exhausted != nil {
a.reportTabSnapshotError(tab, "autosave", exhausted)
}
return
}
}
func (a *App) maybeAutoTitleTopic(tab *WorkspaceTab) bool {
if tab == nil {
return false
}
// Runs on the autosave goroutine; TopicID/Scope/WorkspaceRoot/Ctrl are
// written under a.mu by session switches and recovery.
a.mu.RLock()
topicID := strings.TrimSpace(tab.TopicID)
titleRoot := tab.WorkspaceRoot
if tab.Scope == "global" {
titleRoot = ""
}
ctrl := tab.Ctrl
a.mu.RUnlock()
if topicID == "" || ctrl == nil {
return false
}
if source := loadTopicTitleSource(titleRoot, topicID); source != topicTitleSourceAuto {
return false
}
sessionPath := ctrl.SessionPath()
if sessionPath == "" {
return false
}
if sessionHasManualDisplayTitle(sessionPath) {
return false
}
nextTitle, updated := autoTitleTopicFromSession(titleRoot, topicID, sessionPath)
if !updated {
return false
}
a.updateOpenTopicTitle(topicID, nextTitle)
a.updateTopicSessionTitles(topicID, nextTitle)
a.emitProjectTreeChanged()
return true
}
func autoTitleTopicFromSession(workspaceRoot, topicID, sessionPath string) (string, bool) {
if source := loadTopicTitleSource(workspaceRoot, topicID); source != topicTitleSourceAuto {
return "", false
}
if sessionHasManualDisplayTitle(sessionPath) {
return "", false
}
proposal := autoTopicTitleProposalFromSession(sessionPath)
if proposal.Title == "" {
return "", false
}
if !shouldApplyAutoTopicTitle(workspaceRoot, topicID, proposal) {
return "", false
}
nextTitle := proposal.Title
if nextTitle == strings.TrimSpace(loadTopicTitle(workspaceRoot, topicID)) {
_ = recordTopicAutoTitleMeta(workspaceRoot, topicID, proposal)
return "", false
}
if err := setTopicTitleWithSource(workspaceRoot, topicID, nextTitle, topicTitleSourceAuto); err != nil {
return "", false
}
_ = recordTopicAutoTitleMeta(workspaceRoot, topicID, proposal)
return nextTitle, true
}
type autoTopicTitleProposal struct {
Title string
Stage int
UserTurns int
BasisHash string
}
func autoTopicTitleProposalFromSession(path string) autoTopicTitleProposal {
users := topicTitleUserTurnsFromSession(path)
if len(users) == 0 {
return autoTopicTitleProposal{}
}
stage := 1
if len(users) >= 3 {
stage = 3
}
basis := users
if len(basis) > stage {
basis = basis[:stage]
}
title := topicTitleFromUserTurns(basis)
if title == "" {
return autoTopicTitleProposal{}
}
sum := sha256.Sum256([]byte(fmt.Sprintf("%d\x00%s", stage, strings.Join(basis, "\x00"))))
return autoTopicTitleProposal{
Title: title,
Stage: stage,
UserTurns: len(users),
BasisHash: hex.EncodeToString(sum[:8]),
}
}
func shouldApplyAutoTopicTitle(workspaceRoot, topicID string, proposal autoTopicTitleProposal) bool {
if proposal.Stage <= 0 || proposal.BasisHash == "" {
return false
}
meta := loadTopicAutoTitleMeta(workspaceRoot)[topicID]
if meta.Stage > proposal.Stage {
return false
}
if meta.Stage == proposal.Stage && meta.BasisHash == proposal.BasisHash {
return false
}
return true
}
func sessionHasManualDisplayTitle(sessionPath string) bool {
sessionPath = strings.TrimSpace(sessionPath)
if sessionPath == "" {
return false
}
if meta, ok, err := agent.LoadBranchMeta(sessionPath); err == nil && ok {
if strings.TrimSpace(meta.CustomTitle) != "" {
return true
}
}
dir := filepath.Dir(sessionPath)
if dir == "." || dir == string(filepath.Separator) {
return false
}
return strings.TrimSpace(loadSessionTitles(dir)[filepath.Base(sessionPath)]) != ""
}
func topicTitleFallbackForOpen(workspaceRoot, topicID, sessionPath string) (string, string, bool) {
topicID = strings.TrimSpace(topicID)
sessionPath = strings.TrimSpace(sessionPath)
if topicID == "" || sessionPath == "" {
return "", "", false
}
storedTitle := strings.TrimSpace(loadTopicTitle(workspaceRoot, topicID))
storedSource := strings.TrimSpace(loadTopicTitleSource(workspaceRoot, topicID))
if storedTitle != "" {
if storedSource == topicTitleSourceManual || storedTitle != defaultTopicTitle {
return "", "", false
}
}
if storedTitle == "" {
dir := filepath.Dir(sessionPath)
if meta, ok, err := agent.LoadBranchMeta(sessionPath); err == nil && ok {
if title := storedSessionTopicTitle(dir, sessionPath, meta); title != "" {
return title, topicTitleSourceManual, true
}
} else if title := topicTitleFromText(loadSessionTitles(dir)[filepath.Base(sessionPath)]); title != "" {
return title, topicTitleSourceManual, true
}
}
if storedSource == topicTitleSourceManual {
return "", "", false
}
if storedSource == "" || storedSource == topicTitleSourceAuto {
if title := topicTitleFromSession(sessionPath); title != "" {
return title, topicTitleSourceAuto, true
}
}
return "", "", false
}
func topicTitleFromSession(path string) string {
users := topicTitleUserTurnsFromSession(path)
if len(users) == 0 {
return ""
}
return topicTitleFromText(users[0])
}
func topicTitleUserTurnsFromSession(path string) []string {
// Event-log aware: decoding the .jsonl checkpoint directly would stop
// seeing user turns after the first save, silently disabling the ≥3-turn
// title upgrade.
msgs, err := agent.LoadSessionUserMessages(path)
if err != nil {
return nil
}
var users []string
for _, msg := range msgs {
// Host-injected synthetic turns (readiness nudges, recovery retries) and
// mid-turn steers are persisted as role "user" but are not user-authored:
// counting them inflated userTurns past the stage-3 threshold and let
// "Host final-answer readiness check failed…" become a topic title.
if !agent.IsUserAuthoredTurn(msg.Text) {
continue
}
content := control.StripComposePrefixes(agent.HandoffTask(msg.Text))
content = control.StripReferencedContextPrefix(content)
if strings.TrimSpace(content) != "" {
users = append(users, content)
}
}
return users
}
func topicTitleFromUserTurns(users []string) string {
type candidate struct {
title string
score int
}
best := candidate{score: -1}
for i, text := range users {
title := topicTitleFromText(text)
if title == "" || lowSignalTopicTitle(title) {
continue
}
runes := len([]rune(title))
score := runes
if score > 24 {
score = 24
}
if i == 0 {
score += 3
}
if runes < 5 {
score -= 6
}
if score > best.score {
best = candidate{title: title, score: score}
}
}
if best.title != "" {
return best.title
}
if len(users) > 0 {
return topicTitleFromText(users[0])
}
return ""
}
func lowSignalTopicTitle(title string) bool {
normalized := strings.ToLower(strings.TrimSpace(title))
normalized = strings.Trim(normalized, " \t\r\n,。!?;:、,.!?;:\"'`“”‘’()()[]【】")
switch normalized {
case "", "好", "好的", "好啊", "可以", "嗯", "对", "是的", "继续", "继续吧", "采纳建议", "采用建议", "收到", "明白", "ok", "okay", "yes", "yep", "go on", "continue", "thanks", "thank you":
return true
default:
return false
}
}
func topicTitleFromText(text string) string {
text = strings.TrimSpace(text)
if text == "" {
return ""
}
text = strings.Join(strings.Fields(text), " ")
text = strings.Trim(text, " \t\r\n,。!?;:、,.!?;:\"'`“”‘’()()[]【】")
if text == "" {
return ""
}
const maxRunes = 18
runes := []rune(text)
if len(runes) > maxRunes {
text = strings.TrimRightFunc(string(runes[:maxRunes]), unicode.IsPunct) + "…"
}
if text == defaultTopicTitle {
return ""
}
return text
}
// --- persistence: desktop-projects.json -------------------------------------
const desktopProjectsFile = "desktop-projects.json"
const tabsFileName = "desktop-tabs.json"
const desktopGlobalOrderToken = "__global__"
const legacyProjectSidebarRecoveryMarker = "desktop-projects-legacy-recovered"
var desktopProjectsFileMu sync.Mutex
type desktopProject struct {
Root string `json:"root"`
Title string `json:"title,omitempty"`
Color string `json:"color,omitempty"`
Topics []string `json:"topics"` // ordered topic IDs
PinnedTopics []string `json:"pinnedTopics,omitempty"`
}
type desktopProjectFile struct {
GlobalTitle string `json:"globalTitle,omitempty"`
GlobalColor string `json:"globalColor,omitempty"`
GlobalTopics []string `json:"globalTopics,omitempty"`
GlobalPinnedTopics []string `json:"globalPinnedTopics,omitempty"`
DeletedTopics []string `json:"deletedTopics,omitempty"`
PinnedProjects []string `json:"pinnedProjects,omitempty"`
SidebarOrder []string `json:"sidebarOrder,omitempty"`
Projects []desktopProject `json:"projects"`
}
type desktopTabEntry struct {
ID string `json:"id"`
Scope string `json:"scope"`
WorkspaceRoot string `json:"workspaceRoot"`
TopicID string `json:"topicId"`
SessionPath string `json:"sessionPath,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
Model string `json:"model,omitempty"`
Effort *string `json:"effort,omitempty"`
TokenMode string `json:"tokenMode,omitempty"`
Mode string `json:"mode,omitempty"`
Goal string `json:"goal,omitempty"`
ToolApprovalMode string `json:"toolApprovalMode,omitempty"`
}
type desktopTabsFile struct {
Tabs []desktopTabEntry `json:"tabs"`
ActiveTab string `json:"activeTab"`
}
func singleSurfaceLayoutStyle(style string) bool {
switch strings.ToLower(strings.TrimSpace(style)) {
case "workbench", "creation":
return true
default:
return false
}
}
func singleSurfaceTabsFile(f desktopTabsFile) desktopTabsFile {
if len(f.Tabs) <= 1 {
return f
}
chosen := f.Tabs[0]
if active := strings.TrimSpace(f.ActiveTab); active != "" {
for _, entry := range f.Tabs {
if entry.ID == active {
chosen = entry
break
}
}
}
return desktopTabsFile{Tabs: []desktopTabEntry{chosen}, ActiveTab: chosen.ID}
}
func desktopConfigDir() string {
return config.ReasonixHomeDir()
}
func (a *App) saveTabsLocked() {
dir, entries, activeID, version := a.saveTabsCollectLocked()
a.saveTabsWrite(dir, entries, activeID, version)
}
// saveTabsCollectLocked gathers the tab-snapshot data under the caller's lock
// (it calls orderedTabIDsLocked which requires a.mu). Returns the config dir,
// the serializable entries, the active tab ID, and a monotonic snapshot version.
// The write can happen outside the lock to avoid blocking the UI with disk I/O.
func (a *App) saveTabsCollectLocked() (string, []desktopTabEntry, string, uint64) {
dir := desktopConfigDir()
var entries []desktopTabEntry
for _, id := range a.orderedTabIDsLocked() {
if tab := a.tabs[id]; tab != nil {
entries = append(entries, desktopTabEntry{
ID: tab.ID,
Scope: tab.Scope,
WorkspaceRoot: tab.WorkspaceRoot,
TopicID: tab.TopicID,
SessionPath: tab.currentSessionPath(),
ReadOnly: tab.ReadOnly,
Model: tab.model,
Effort: cloneStringPtr(tab.effort),
TokenMode: persistedTabTokenMode(currentTabTokenMode(tab)),
Mode: persistedTabMode(currentTabMode(tab)),
Goal: persistedTabGoal(tab),
ToolApprovalMode: persistedToolApprovalMode(currentTabToolApprovalMode(tab)),
})
}
}
a.tabsSaveVersion++
return dir, entries, a.activeTabID, a.tabsSaveVersion
}
// saveTabsWrite writes the tab-snapshot to disk. It does not require a.mu, but
// writes must be serialized because every save uses the same destination and
// fixed .tmp path.
func (a *App) saveTabsWrite(dir string, entries []desktopTabEntry, activeID string, version uint64) {
a.tabsSaveMu.Lock()
defer a.tabsSaveMu.Unlock()
if version < a.tabsLastWrittenVersion {
return
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return
}
f := desktopTabsFile{Tabs: entries, ActiveTab: activeID}
b, err := json.MarshalIndent(f, "", " ")
if err != nil {
return
}
path := filepath.Join(dir, tabsFileName)
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return
}
if err := fileutil.ReplaceFile(tmp, path); err != nil {
return
}
a.tabsLastWrittenVersion = version
}
func (a *App) orderedTabIDsLocked() []string {
ordered, needsRepair := a.orderedTabIDsSnapshotLocked()
if needsRepair {
a.tabOrder = append([]string(nil), ordered...)
}
return ordered
}
func (a *App) orderedTabIDsSnapshotLocked() ([]string, bool) {
seen := make(map[string]bool, len(a.tabs))
ordered := make([]string, 0, len(a.tabs))
for _, id := range a.tabOrder {
if _, ok := a.tabs[id]; ok && !seen[id] {
ordered = append(ordered, id)
seen[id] = true
}
}
var missing []string
for id := range a.tabs {
if !seen[id] {
missing = append(missing, id)
}
}
sort.Strings(missing)
ordered = append(ordered, missing...)
return ordered, len(ordered) != len(a.tabOrder) || len(missing) > 0
}
func (a *App) removeTabOrderLocked(tabID string) {
next := a.tabOrder[:0]
for _, id := range a.tabOrder {
if id != tabID {
next = append(next, id)
}
}
a.tabOrder = next
}
func loadTabsFile() desktopTabsFile {
path := filepath.Join(desktopConfigDir(), tabsFileName)
b, err := readFileUTF8(path)
if err != nil {
return desktopTabsFile{}
}
var f desktopTabsFile
_ = json.Unmarshal(b, &f)
return f
}
func desktopMCPMigrationRoots(tabs desktopTabsFile) []string {
seen := map[string]bool{}
var roots []string
add := func(root string) {
root = normalizeProjectRoot(root)
key := projectRootKey(root)
if root == "" || seen[key] {
return
}
seen[key] = true
roots = append(roots, root)
}
if cur := loadWorkspace(); cur != "" {
add(cur)
}
for _, root := range loadWorkspaces() {
add(root)
}
for _, entry := range tabs.Tabs {
if entry.Scope == "project" {
add(entry.WorkspaceRoot)
}
}
for _, project := range loadProjectsFile().Projects {
add(project.Root)
}
return roots
}
func recoverLegacyProjectSidebarRoots(tabs desktopTabsFile) (bool, error) {
markerPath := filepath.Join(desktopConfigDir(), legacyProjectSidebarRecoveryMarker)
if _, err := os.Stat(markerPath); err == nil {
return false, nil
}
changed := false
err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
seen := map[string]bool{}
for _, project := range f.Projects {
root := normalizeProjectRoot(project.Root)
if root != "" {
seen[projectRootKey(root)] = true
}
}
add := func(root string) {
root = normalizeProjectRoot(root)
key := projectRootKey(root)
if root == "" || seen[key] || !existingDirectory(root) {
return
}
seen[key] = true
f.Projects = append(f.Projects, desktopProject{Root: root})
changed = true
}
if cur := loadWorkspace(); cur != "" {
add(cur)
}
for _, root := range loadWorkspaces() {
add(root)
}
for _, entry := range tabs.Tabs {
if entry.Scope == "project" {
add(entry.WorkspaceRoot)
}
}
return changed, nil
})
if err != nil {
return false, err
}
return changed, writeLegacyProjectSidebarRecoveryMarker(markerPath)
}
func existingDirectory(path string) bool {
info, err := os.Stat(path)
return err == nil && info.IsDir()
}
func writeLegacyProjectSidebarRecoveryMarker(path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, []byte("ok\n"), 0o644)
}
func loadProjectsFile() desktopProjectFile {
path := filepath.Join(desktopConfigDir(), desktopProjectsFile)
b, err := readFileUTF8(path)
if err != nil {
return desktopProjectFile{}
}
var f desktopProjectFile
_ = json.Unmarshal(b, &f)
return normalizeProjectsFile(f)
}
func saveProjectsFile(f desktopProjectFile) error {
dir := desktopConfigDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
f = normalizeProjectsFile(f)
b, err := json.MarshalIndent(f, "", " ")
if err != nil {
return err
}
path := filepath.Join(dir, desktopProjectsFile)
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return fileutil.ReplaceFile(tmp, path)
}
func updateProjectsFile(mutator func(*desktopProjectFile) (bool, error)) error {
desktopProjectsFileMu.Lock()
defer desktopProjectsFileMu.Unlock()
f := loadProjectsFile()
changed, err := mutator(&f)
if err != nil {
return err
}
if !changed {
return nil
}
return saveProjectsFile(f)
}
func prependTopicInProjectsFile(workspaceRoot, topicID string, ensureProject bool) error {
// Single-topic prepends are intentional writes (topic creation, a live tab
// indexing its session, restore from trash): they clear any delete
// tombstone so the topic fully returns instead of landing in a half-state
// where only its title resurfaces.
return prependTopicsInProjectsFileOpts(workspaceRoot, []string{topicID}, ensureProject, false)
}
func prependTopicsInProjectsFile(workspaceRoot string, topicIDs []string, ensureProject bool) error {
// Batch prepends come from the legacy migration and index-repair scans:
// they must respect delete tombstones so a scan never resurrects a topic
// the user removed.
return prependTopicsInProjectsFileOpts(workspaceRoot, topicIDs, ensureProject, true)
}
func prependTopicsInProjectsFileOpts(workspaceRoot string, topicIDs []string, ensureProject, respectTombstones bool) error {
workspaceRoot = normalizeProjectRoot(workspaceRoot)
topicIDs = uniqueStrings(topicIDs)
if len(topicIDs) == 0 {
return nil
}
return updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
// Tombstones are checked under the projects-file lock: a DeleteTopic
// that lands between a scan reading DeletedTopics and this write must
// not be resurrected by the stale batch.
live := topicIDs
changed := false
if respectTombstones {
live = make([]string, 0, len(topicIDs))
for _, id := range topicIDs {
if !containsDesktopString(f.DeletedTopics, id) {
live = append(live, id)
}
}
if len(live) == 0 {
return false, nil
}
} else {
for _, id := range topicIDs {
if next := removeString(f.DeletedTopics, id); !sameStringList(next, f.DeletedTopics) {
f.DeletedTopics = next
changed = true
}
}
}
if workspaceRoot == "" {
next := uniqueStrings(append(append([]string(nil), live...), f.GlobalTopics...))
if sameStringList(next, f.GlobalTopics) {
return changed, nil
}
f.GlobalTopics = next
return true, nil
}
for i, p := range f.Projects {
if !sameProjectRoot(p.Root, workspaceRoot) {
continue
}
next := uniqueStrings(append(append([]string(nil), live...), p.Topics...))
if sameStringList(next, p.Topics) {
return changed, nil
}
f.Projects[i].Topics = next
return true, nil
}
if !ensureProject {
return changed, nil
}
f.Projects = append(f.Projects, desktopProject{Root: workspaceRoot, Topics: live})
return true, nil
})
}
func removeTopicFromProjectsFile(topicID string) error {
topicID = strings.TrimSpace(topicID)
if topicID == "" {
return nil
}
return updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
changed := false
if next := removeString(f.GlobalTopics, topicID); !sameStringList(next, f.GlobalTopics) {
f.GlobalTopics = next
changed = true
}
if next := removeString(f.GlobalPinnedTopics, topicID); !sameStringList(next, f.GlobalPinnedTopics) {
f.GlobalPinnedTopics = next
changed = true
}
if next := prependUniqueString(f.DeletedTopics, topicID); !sameStringList(next, f.DeletedTopics) {
f.DeletedTopics = next
changed = true
}
for i, p := range f.Projects {
if next := removeString(p.Topics, topicID); !sameStringList(next, p.Topics) {
f.Projects[i].Topics = next
changed = true
}
if next := removeString(p.PinnedTopics, topicID); !sameStringList(next, p.PinnedTopics) {
f.Projects[i].PinnedTopics = next
changed = true
}
}
return changed, nil
})
}
func normalizeProjectRoot(root string) string {
root = strings.TrimSpace(root)
if root == "" {
return ""
}
if abs, err := filepath.Abs(root); err == nil {
return abs
}
return root
}
func sameProjectRoot(a, b string) bool {
return sameDesktopPath(normalizeProjectRoot(a), normalizeProjectRoot(b))
}
func projectIndexByRoot(projects []desktopProject, root string) int {
root = normalizeProjectRoot(root)
if root == "" {
return -1
}
for i, project := range projects {
if sameProjectRoot(project.Root, root) {
return i
}
}
return -1
}
func projectRootInList(roots []string, root string) bool {
root = normalizeProjectRoot(root)
if root == "" {
return false
}
for _, candidate := range roots {
if sameProjectRoot(candidate, root) {
return true
}
}
return false
}
func normalizeProjectsFile(f desktopProjectFile) desktopProjectFile {
out := desktopProjectFile{
GlobalTitle: strings.TrimSpace(f.GlobalTitle),
GlobalColor: normalizeProjectColor(f.GlobalColor),
GlobalTopics: uniqueStrings(f.GlobalTopics),
GlobalPinnedTopics: uniqueStrings(f.GlobalPinnedTopics),
DeletedTopics: uniqueStrings(f.DeletedTopics),
}
for _, p := range f.Projects {
root := normalizeProjectRoot(p.Root)
if root == "" {
continue
}
p.Root = root
p.Title = strings.TrimSpace(p.Title)
p.Color = normalizeProjectColor(p.Color)
p.Topics = uniqueStrings(p.Topics)
p.PinnedTopics = uniqueStrings(p.PinnedTopics)
if i := projectIndexByRoot(out.Projects, root); i >= 0 {
if out.Projects[i].Title == "" && p.Title != "" {
out.Projects[i].Title = p.Title
}
if out.Projects[i].Color == "" && p.Color != "" {
out.Projects[i].Color = p.Color
}
out.Projects[i].Topics = uniqueStrings(append(out.Projects[i].Topics, p.Topics...))
out.Projects[i].PinnedTopics = uniqueStrings(append(out.Projects[i].PinnedTopics, p.PinnedTopics...))
continue
}
out.Projects = append(out.Projects, p)
}
for _, root := range uniqueStrings(f.PinnedProjects) {
root = normalizeProjectRoot(root)
if i := projectIndexByRoot(out.Projects, root); i >= 0 && !projectRootInList(out.PinnedProjects, out.Projects[i].Root) {
out.PinnedProjects = append(out.PinnedProjects, out.Projects[i].Root)
}
}
out.SidebarOrder = normalizeSidebarOrder(f.SidebarOrder, out.Projects)
return out
}
func normalizeSidebarOrder(order []string, projects []desktopProject) []string {
seenGlobal := false
// Dedupe roots against a roots-only list: out also holds the global order
// token, which must never be path-compared against project roots.
var seenRoots []string
out := make([]string, 0, len(order))
for _, value := range order {
value = strings.TrimSpace(value)
if value == desktopGlobalOrderToken {
if !seenGlobal {
seenGlobal = true
out = append(out, value)
}
continue
}
root := normalizeProjectRoot(value)
i := projectIndexByRoot(projects, root)
if i < 0 {
continue
}
root = projects[i].Root
if projectRootInList(seenRoots, root) {
continue
}
seenRoots = append(seenRoots, root)
out = append(out, root)
}
return out
}
func sameProjectOrder(a, b []desktopProject) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i].Root != b[i].Root {
return false
}
}
return true
}
func uniqueStrings(values []string) []string {
seen := make(map[string]bool, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}
func prependUniqueString(values []string, value string) []string {
value = strings.TrimSpace(value)
if value == "" {
return uniqueStrings(values)
}
return uniqueStrings(append([]string{value}, values...))
}
func removeString(values []string, value string) []string {
value = strings.TrimSpace(value)
if value == "" {
return uniqueStrings(values)
}
out := make([]string, 0, len(values))
for _, item := range uniqueStrings(values) {
if item != value {
out = append(out, item)
}
}
return out
}
func containsDesktopString(values []string, value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return false
}
for _, item := range uniqueStrings(values) {
if item == value {
return true
}
}
return false
}
func pinnedTopicIDs(topicIDs []string, pinned []string) []string {
if len(topicIDs) == 0 || len(pinned) == 0 {
return topicIDs
}
available := make(map[string]bool, len(topicIDs))
for _, tid := range topicIDs {
available[tid] = true
}
out := make([]string, 0, len(topicIDs))
seen := make(map[string]bool, len(topicIDs))
for _, tid := range uniqueStrings(pinned) {
if available[tid] && !seen[tid] {
out = append(out, tid)
seen[tid] = true
}
}
for _, tid := range topicIDs {
if !seen[tid] {
out = append(out, tid)
}
}
return out
}
func orderedTopicIDs(explicit []string, titleMap map[string]string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(explicit)+len(titleMap))
for _, tid := range explicit {
tid = strings.TrimSpace(tid)
if tid == "" || seen[tid] {
continue
}
seen[tid] = true
out = append(out, tid)
}
var remaining []string
for tid := range titleMap {
if !seen[tid] {
remaining = append(remaining, tid)
}
}
sort.Strings(remaining)
return append(out, remaining...)
}
func projectTreeOrderKey(node ProjectNode) string {
switch node.Kind {
case "global_folder":
return desktopGlobalOrderToken
case "project":
return normalizeProjectRoot(node.Root)
default:
return ""
}
}
func applyProjectTreeOrder(nodes []ProjectNode, order []string) []ProjectNode {
if len(order) == 0 {
return nodes
}
byKey := make(map[string]ProjectNode, len(nodes))
for _, node := range nodes {
key := projectTreeOrderKey(node)
if key != "" {
byKey[key] = node
}
}
seen := make(map[string]bool, len(nodes))
out := make([]ProjectNode, 0, len(nodes))
for _, value := range order {
key := strings.TrimSpace(value)
if key != desktopGlobalOrderToken {
key = normalizeProjectRoot(key)
}
if key == "" || seen[key] {
continue
}
node, ok := byKey[key]
if !ok {
continue
}
seen[key] = true
out = append(out, node)
}
for _, node := range nodes {
key := projectTreeOrderKey(node)
if key != "" && seen[key] {
continue
}
if key != "" {
seen[key] = true
}
out = append(out, node)
}
return out
}
func applyPinnedProjectOrder(nodes []ProjectNode, pinnedRoots []string) []ProjectNode {
pinnedRoots = uniqueStrings(pinnedRoots)
if len(pinnedRoots) == 0 {
return nodes
}
byRoot := make(map[string]ProjectNode, len(nodes))
for _, node := range nodes {
if node.Kind == "project" && node.Root != "" {
byRoot[normalizeProjectRoot(node.Root)] = node
}
}
seen := make(map[string]bool, len(pinnedRoots))
out := make([]ProjectNode, 0, len(nodes))
for _, root := range pinnedRoots {
root = normalizeProjectRoot(root)
node, ok := byRoot[root]
if !ok || seen[root] {
continue
}
seen[root] = true
out = append(out, node)
}
for _, node := range nodes {
if node.Kind == "project" && node.Root != "" && seen[normalizeProjectRoot(node.Root)] {
continue
}
out = append(out, node)
}
return out
}
func projectDisplayName(p desktopProject) string {
if title := strings.TrimSpace(p.Title); title != "" {
return title
}
return workspaceName(p.Root)
}
func normalizeProjectColor(color string) string {
switch strings.TrimSpace(strings.ToLower(color)) {
case "red", "orange", "amber", "green", "teal", "blue", "purple", "pink":
return strings.TrimSpace(strings.ToLower(color))
default:
return ""
}
}
func projectColor(root string) string {
root = normalizeProjectRoot(root)
if root == "" {
return globalProjectColor()
}
for _, p := range loadProjectsFile().Projects {
if sameProjectRoot(p.Root, root) {
return normalizeProjectColor(p.Color)
}
}
return ""
}
func globalProjectColor() string {
return normalizeProjectColor(loadProjectsFile().GlobalColor)
}
func globalProjectTitle() string {
if title := strings.TrimSpace(loadProjectsFile().GlobalTitle); title != "" {
return title
}
return "Global"
}
func addProject(root, title string) error {
root = normalizeProjectRoot(root)
if root == "" {
return fmt.Errorf("project root is required")
}
title = strings.TrimSpace(title)
return updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
for i, p := range f.Projects {
if sameProjectRoot(p.Root, root) {
changed := false
if f.Projects[i].Root != root {
f.Projects[i].Root = root
changed = true
}
if title != "" && f.Projects[i].Title != title {
f.Projects[i].Title = title
changed = true
}
if !changed {
return false, nil
}
return true, nil
}
}
f.Projects = append(f.Projects, desktopProject{Root: root, Title: title})
return true, nil
})
}
func renameProject(root, title string) error {
title = strings.TrimSpace(title)
root = normalizeProjectRoot(root)
return updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
if root == "" {
if f.GlobalTitle == title {
return false, nil
}
f.GlobalTitle = title
return true, nil
}
for i, p := range f.Projects {
if sameProjectRoot(p.Root, root) {
if f.Projects[i].Root == root && f.Projects[i].Title == title {
return false, nil
}
f.Projects[i].Root = root
f.Projects[i].Title = title
return true, nil
}
}
f.Projects = append(f.Projects, desktopProject{Root: root, Title: title})
return true, nil
})
}
func setProjectColor(root, color string) error {
root = normalizeProjectRoot(root)
color = normalizeProjectColor(color)
return updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
if root == "" {
if f.GlobalColor == color {
return false, nil
}
f.GlobalColor = color
return true, nil
}
for i, p := range f.Projects {
if sameProjectRoot(p.Root, root) {
if f.Projects[i].Root == root && f.Projects[i].Color == color {
return false, nil
}
f.Projects[i].Root = root
f.Projects[i].Color = color
return true, nil
}
}
f.Projects = append(f.Projects, desktopProject{Root: root, Color: color})
return true, nil
})
}
func removeProject(root string) error {
root = normalizeProjectRoot(root)
return updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
projects := make([]desktopProject, 0, len(f.Projects))
for _, p := range f.Projects {
if !sameProjectRoot(p.Root, root) {
projects = append(projects, p)
}
}
if len(projects) == len(f.Projects) {
return false, nil
}
f.Projects = projects
return true, nil
})
}
// --- topic helpers ----------------------------------------------------------
const (
topicTitlesFile = "desktop-topic-titles.json"
topicTitleSourcesFile = "desktop-topic-title-sources.json"
topicCreatedAtsFile = "desktop-topic-created-at.json"
topicAutoTitlesFile = "desktop-topic-auto-title-meta.json"
defaultTopicTitle = "新的会话"
topicTitleSourceAuto = "auto"
topicTitleSourceManual = "manual"
)
func topicTitlesPath(workspaceRoot string) string {
if workspaceRoot == "" {
return filepath.Join(desktopConfigDir(), "global", topicTitlesFile)
}
return filepath.Join(workspaceRoot, ".reasonix", topicTitlesFile)
}
func topicTitleSourcesPath(workspaceRoot string) string {
if workspaceRoot == "" {
return filepath.Join(desktopConfigDir(), "global", topicTitleSourcesFile)
}
return filepath.Join(workspaceRoot, ".reasonix", topicTitleSourcesFile)
}
func topicCreatedAtsPath(workspaceRoot string) string {
if workspaceRoot == "" {
return filepath.Join(desktopConfigDir(), "global", topicCreatedAtsFile)
}
return filepath.Join(workspaceRoot, ".reasonix", topicCreatedAtsFile)
}
func topicAutoTitleMetaPath(workspaceRoot string) string {
if workspaceRoot == "" {
return filepath.Join(desktopConfigDir(), "global", topicAutoTitlesFile)
}
return filepath.Join(workspaceRoot, ".reasonix", topicAutoTitlesFile)
}
const topicFileReadTimeout = 200 * time.Millisecond
var readFileWithTimeoutSlots = make(chan struct{}, 16)
func readFileWithTimeout(path string, timeout time.Duration) ([]byte, error) {
if timeout <= 0 {
return readFileUTF8(path)
}
select {
case readFileWithTimeoutSlots <- struct{}{}:
default:
return nil, fmt.Errorf("too many pending file reads")
}
type result struct {
data []byte
err error
}
ch := make(chan result, 1)
go func() {
data, err := readFileUTF8(path)
<-readFileWithTimeoutSlots
ch <- result{data: data, err: err}
}()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case r := <-ch:
return r.data, r.err
case <-timer.C:
return nil, fmt.Errorf("timed out after %v reading %s", timeout, filepath.Base(path))
}
}
func loadTopicTitles(workspaceRoot string) map[string]string {
m := map[string]string{}
b, err := readFileWithTimeout(topicTitlesPath(workspaceRoot), topicFileReadTimeout)
if err != nil {
return m
}
_ = json.Unmarshal(b, &m)
return m
}
func loadTopicTitleSources(workspaceRoot string) map[string]string {
m := map[string]string{}
b, err := readFileWithTimeout(topicTitleSourcesPath(workspaceRoot), topicFileReadTimeout)
if err != nil {
return m
}
_ = json.Unmarshal(b, &m)
return m
}
func loadTopicCreatedAts(workspaceRoot string) map[string]int64 {
m := map[string]int64{}
b, err := readFileWithTimeout(topicCreatedAtsPath(workspaceRoot), topicFileReadTimeout)
if err != nil {
return m
}
_ = json.Unmarshal(b, &m)
return m
}
type topicAutoTitleMeta struct {
Stage int `json:"stage,omitempty"`
UserTurns int `json:"userTurns,omitempty"`
BasisHash string `json:"basisHash,omitempty"`
UpdatedAt int64 `json:"updatedAt,omitempty"`
}
func loadTopicAutoTitleMeta(workspaceRoot string) map[string]topicAutoTitleMeta {
m := map[string]topicAutoTitleMeta{}
b, err := readFileWithTimeout(topicAutoTitleMetaPath(workspaceRoot), topicFileReadTimeout)
if err != nil {
return m
}
_ = json.Unmarshal(b, &m)
return m
}
func loadStringMapForUpdate(path string) (map[string]string, error) {
m := map[string]string{}
b, err := readFileUTF8(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return m, nil
}
return nil, err
}
if err := json.Unmarshal(b, &m); err != nil || m == nil {
return map[string]string{}, nil
}
return m, nil
}
func loadTopicAutoTitleMetaForUpdate(workspaceRoot string) (map[string]topicAutoTitleMeta, error) {
m := map[string]topicAutoTitleMeta{}
path := topicAutoTitleMetaPath(workspaceRoot)
b, err := readFileUTF8(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return m, nil
}
return nil, err
}
if err := json.Unmarshal(b, &m); err != nil || m == nil {
return map[string]topicAutoTitleMeta{}, nil
}
return m, nil
}
func loadInt64MapForUpdate(path string) (map[string]int64, error) {
m := map[string]int64{}
b, err := readFileUTF8(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return m, nil
}
return nil, err
}
if err := json.Unmarshal(b, &m); err != nil || m == nil {
return map[string]int64{}, nil
}
return m, nil
}
func loadTopicTitlesForUpdate(workspaceRoot string) (map[string]string, error) {
return loadStringMapForUpdate(topicTitlesPath(workspaceRoot))
}
func loadTopicTitleSourcesForUpdate(workspaceRoot string) (map[string]string, error) {
return loadStringMapForUpdate(topicTitleSourcesPath(workspaceRoot))
}
func loadTopicCreatedAtsForUpdate(workspaceRoot string) (map[string]int64, error) {
return loadInt64MapForUpdate(topicCreatedAtsPath(workspaceRoot))
}
func saveTopicTitles(workspaceRoot string, m map[string]string) error {
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
path := topicTitlesPath(workspaceRoot)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return fileutil.ReplaceFile(tmp, path)
}
func saveTopicTitleSources(workspaceRoot string, m map[string]string) error {
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
path := topicTitleSourcesPath(workspaceRoot)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return fileutil.ReplaceFile(tmp, path)
}
func saveTopicCreatedAts(workspaceRoot string, m map[string]int64) error {
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
path := topicCreatedAtsPath(workspaceRoot)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return fileutil.ReplaceFile(tmp, path)
}
func saveTopicAutoTitleMeta(workspaceRoot string, m map[string]topicAutoTitleMeta) error {
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
path := topicAutoTitleMetaPath(workspaceRoot)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return fileutil.ReplaceFile(tmp, path)
}
func loadTopicTitle(workspaceRoot, topicID string) string {
return loadTopicTitles(workspaceRoot)[topicID]
}
func loadTopicTitleSource(workspaceRoot, topicID string) string {
return loadTopicTitleSources(workspaceRoot)[topicID]
}
func loadTopicCreatedAt(workspaceRoot, topicID string) int64 {
return loadTopicCreatedAts(workspaceRoot)[topicID]
}
func topicIDCreatedAt(topicID string) int64 {
topicID = strings.TrimSpace(topicID)
for _, prefix := range []string{"topic_", "legacy_"} {
if !strings.HasPrefix(topicID, prefix) {
continue
}
stamp := strings.TrimPrefix(topicID, prefix)
if len(stamp) < len("20060102-150405") {
continue
}
stamp = stamp[:len("20060102-150405")]
t, err := time.ParseInLocation("20060102-150405", stamp, time.UTC)
if err != nil {
continue
}
return t.UnixMilli()
}
return 0
}
func topicCreatedAtForTree(createdAts map[string]int64, topicID string) int64 {
if createdAt := createdAts[topicID]; createdAt > 0 {
return createdAt
}
return topicIDCreatedAt(topicID)
}
func topicTitleForTab(scope, workspaceRoot, topicID string) string {
titleRoot := topicTitleRoot(scope, workspaceRoot)
if title := strings.TrimSpace(loadTopicTitle(titleRoot, topicID)); title != "" {
return title
}
if scope == "global" {
return "Global"
}
return defaultTopicTitle
}
func topicTitleRoot(scope, workspaceRoot string) string {
if scope == "global" {
return ""
}
return workspaceRoot
}
func forkTopicTitle(title string) string {
base := strings.TrimSpace(title)
if base == "" || base == defaultTopicTitle || base == "Global" {
return "分叉会话"
}
if strings.HasSuffix(base, " · 分叉") {
return base
}
return base + " · 分叉"
}
type sessionRecoveryEvent struct {
OriginalPath string `json:"originalPath,omitempty"`
RecoveryPath string `json:"recoveryPath"`
Scope string `json:"scope,omitempty"`
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
TopicID string `json:"topicId,omitempty"`
TopicTitle string `json:"topicTitle,omitempty"`
RecoveryReason string `json:"recoveryReason,omitempty"`
RecoveryDigest string `json:"recoveryDigest,omitempty"`
RecoveryParentID string `json:"recoveryParentId,omitempty"`
Existing bool `json:"existing,omitempty"`
}
type sessionRecoveryFailedEvent struct {
Reason string `json:"reason,omitempty"`
}
func (a *App) tabSessionRecoveryMeta(tab *WorkspaceTab) func(control.SessionRecoveryRequest) agent.BranchMeta {
return func(req control.SessionRecoveryRequest) agent.BranchMeta {
if tab == nil {
return agent.BranchMeta{Name: agent.RecoveryBranchDefaultName}
}
// This runs on the snapshot-recovery path, which can fire from the
// controller's autosave goroutine; snapshot the tab fields under a.mu so
// we don't read them mid-mutation. Recovery callbacks never hold a.mu, so
// taking it here can't deadlock. Controller reads happen off-lock.
a.mu.RLock()
ctrl := tab.Ctrl
scope := strings.TrimSpace(tab.Scope)
workspaceRoot := strings.TrimSpace(tab.WorkspaceRoot)
topicID := tab.TopicID
topicTitle := tab.TopicTitle
model := strings.TrimSpace(tab.model)
tokenMode := persistedTabTokenMode(boot.NormalizeTokenMode(tab.tokenMode))
mode := normalizeTabMode(tab.mode)
toolApprovalMode := normalizeToolApprovalMode(tab.toolApprovalMode)
goal := strings.TrimSpace(tab.goal)
a.mu.RUnlock()
if ctrl != nil {
mode = tabModeFromAxes(ctrl.PlanMode(), ctrl.AutoApproveTools())
toolApprovalMode = normalizeToolApprovalMode(ctrl.ToolApprovalMode())
if g := strings.TrimSpace(ctrl.Goal()); g != "" && ctrl.GoalStatus() == control.GoalStatusRunning {
goal = g
} else {
goal = ""
}
}
if scope != "project" {
scope = "global"
}
if scope == "global" {
workspaceRoot = ""
}
return agent.BranchMeta{
Name: agent.RecoveryBranchDefaultName,
Scope: scope,
WorkspaceRoot: workspaceRoot,
TopicID: topicID,
TopicTitle: topicTitle,
Model: model,
TokenMode: tokenMode,
Mode: persistedTabMode(mode),
ToolApprovalMode: persistedToolApprovalMode(toolApprovalMode),
Goal: goal,
}
}
}
func (a *App) handleTabSessionRecovered(tab *WorkspaceTab) func(control.SessionRecoveryInfo) error {
return func(info control.SessionRecoveryInfo) error {
if strings.TrimSpace(info.RecoveryPath) == "" {
return nil
}
if tab != nil && !tab.ReadOnly {
if err := tab.ensureSessionLease(info.RecoveryPath); err != nil {
slog.Warn("desktop: acquire recovery session lease", "path", info.RecoveryPath, "err", err)
reason := "lease_unavailable"
if errors.Is(err, agent.ErrSessionLeaseHeld) {
reason = "lease_held"
}
a.emitRuntimeEvent("session:recovery-failed", sessionRecoveryFailedEvent{Reason: reason})
// This error propagates out through ctrl.Snapshot() into chat
// notices and bridge returns (reportTabSnapshotError). The raw
// path/holder id stayed in the slog line above; keep it out of
// the surfaced message.
return fmt.Errorf("acquire recovery session lease: %w",
userFacingSessionLeaseError("", err))
}
}
meta := info.Meta
scope := strings.TrimSpace(meta.Scope)
if scope != "project" {
scope = "global"
}
workspaceRoot := strings.TrimSpace(meta.WorkspaceRoot)
if scope == "global" {
workspaceRoot = ""
}
invalidateTopicSessionIndexForPath(info.RecoveryPath)
a.mu.Lock()
if tab != nil && !tab.removed {
oldKey := sessionRuntimeKey(info.OriginalPath)
newKey := sessionRuntimeKey(info.RecoveryPath)
if oldKey != "" && newKey != "" && a.detachedSessions[oldKey] == tab {
delete(a.detachedSessions, oldKey)
a.ensureDetachedSessionsLocked()
a.detachedSessions[newKey] = tab
}
tab.SessionPath = canonicalTabSessionPath(info.RecoveryPath)
if a.tabs[tab.ID] == tab {
a.saveTabsLocked()
}
}
a.mu.Unlock()
// The fork continues the same conversation, so its cost history moves
// with it: re-key the in-memory telemetry from the original session to
// the recovery path and persist the sidecar right away. Without this
// the fork's sidecar stays empty until the next usage event (cost
// shows 0 after a restart), and the sync-by-key reload would wipe the
// carried totals as "another session's" numbers.
if tab != nil && !tab.removed {
origKey := sessionRuntimeKey(info.OriginalPath)
newKey := sessionRuntimeKey(info.RecoveryPath)
carried := false
if newKey != "" {
tab.telemMu.Lock()
if tab.telemetrySessionKey == origKey || tab.telemetrySessionKey == "" {
tab.telemetrySessionKey = newKey
carried = true
}
tab.telemMu.Unlock()
}
if carried {
_ = saveTelemetry(info.RecoveryPath+".telemetry.json", tab.telemetrySnapshot())
}
}
a.emitProjectTreeChanged()
a.emitRuntimeEvent("session:recovered", sessionRecoveryEvent{
OriginalPath: info.OriginalPath,
RecoveryPath: info.RecoveryPath,
Scope: scope,
WorkspaceRoot: workspaceRoot,
TopicID: meta.TopicID,
TopicTitle: meta.TopicTitle,
RecoveryReason: meta.RecoveryReason,
RecoveryDigest: meta.RecoveryDigest,
RecoveryParentID: string(meta.ParentID),
Existing: info.Existing,
})
a.invalidatePromptHistoryCache()
return nil
}
}
func setTopicTitle(workspaceRoot, topicID, title string) error {
return setTopicTitleWithSource(workspaceRoot, topicID, title, topicTitleSourceManual)
}
func setTopicTitleWithSource(workspaceRoot, topicID, title, source string) error {
m, err := loadTopicTitlesForUpdate(workspaceRoot)
if err != nil {
return err
}
if strings.TrimSpace(title) == "" {
delete(m, topicID)
} else {
m[topicID] = strings.TrimSpace(title)
}
if err := saveTopicTitles(workspaceRoot, m); err != nil {
return err
}
sources, err := loadTopicTitleSourcesForUpdate(workspaceRoot)
if err != nil {
return err
}
if strings.TrimSpace(title) == "" || strings.TrimSpace(source) == "" {
delete(sources, topicID)
} else {
sources[topicID] = strings.TrimSpace(source)
}
if err := saveTopicTitleSources(workspaceRoot, sources); err != nil {
return err
}
if strings.TrimSpace(source) == topicTitleSourceManual ||
(strings.TrimSpace(source) == topicTitleSourceAuto && strings.TrimSpace(title) == defaultTopicTitle) {
_ = deleteTopicAutoTitleMeta(workspaceRoot, topicID)
}
return nil
}
func recordTopicAutoTitleMeta(workspaceRoot, topicID string, proposal autoTopicTitleProposal) error {
topicID = strings.TrimSpace(topicID)
if topicID == "" || proposal.Stage <= 0 || proposal.BasisHash == "" {
return nil
}
m, err := loadTopicAutoTitleMetaForUpdate(workspaceRoot)
if err != nil {
return err
}
m[topicID] = topicAutoTitleMeta{
Stage: proposal.Stage,
UserTurns: proposal.UserTurns,
BasisHash: proposal.BasisHash,
UpdatedAt: time.Now().UnixMilli(),
}
return saveTopicAutoTitleMeta(workspaceRoot, m)
}
func deleteTopicAutoTitleMeta(workspaceRoot, topicID string) error {
topicID = strings.TrimSpace(topicID)
if topicID == "" {
return nil
}
m, err := loadTopicAutoTitleMetaForUpdate(workspaceRoot)
if err != nil {
return err
}
if _, ok := m[topicID]; !ok {
return nil
}
delete(m, topicID)
return saveTopicAutoTitleMeta(workspaceRoot, m)
}
func setTopicCreatedAt(workspaceRoot, topicID string, createdAt int64) error {
created, err := loadTopicCreatedAtsForUpdate(workspaceRoot)
if err != nil {
return err
}
topicID = strings.TrimSpace(topicID)
if topicID == "" || createdAt <= 0 {
delete(created, topicID)
} else {
created[topicID] = createdAt
}
return saveTopicCreatedAts(workspaceRoot, created)
}
func deleteTopicCreatedAt(workspaceRoot, topicID string) {
created, err := loadTopicCreatedAtsForUpdate(workspaceRoot)
if err != nil {
return
}
delete(created, topicID)
_ = saveTopicCreatedAts(workspaceRoot, created)
}
// topicIndexMu serializes recovery writes to desktop-projects.json and topic
// title indexes. Startup builds restored tabs concurrently, and each tab may
// repair its missing index.
var topicIndexMu sync.Mutex
func ensureTopicIndexed(scope, workspaceRoot, topicID, title, source string) error {
topicID = strings.TrimSpace(topicID)
if topicID == "" {
return fmt.Errorf("topicID is required")
}
topicIndexMu.Lock()
defer topicIndexMu.Unlock()
if strings.TrimSpace(scope) == "global" {
workspaceRoot = ""
} else {
workspaceRoot = normalizeProjectRoot(workspaceRoot)
}
title = strings.TrimSpace(title)
if title == "" {
title = defaultTopicTitle
}
source = strings.TrimSpace(source)
if source == "" {
source = topicTitleSourceManual
}
if err := setTopicTitleWithSource(workspaceRoot, topicID, title, source); err != nil {
return err
}
return prependTopicInProjectsFile(workspaceRoot, topicID, true)
}
// --- telemetry --------------------------------------------------------------
func saveTelemetry(path string, snapshot tabTelemetrySnapshot) error {
if snapshot.Version == 0 {
snapshot.Version = 2
}
if snapshot.ReadFiles == nil {
snapshot.ReadFiles = []readFileRecord{}
}
b, err := json.MarshalIndent(snapshot, "", " ")
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return fileutil.ReplaceFile(tmp, path)
}
func loadTelemetry(path string) tabTelemetrySnapshot {
b, err := readFileUTF8(path)
if err != nil {
return tabTelemetrySnapshot{Version: 2, ReadFiles: []readFileRecord{}}
}
var snapshot tabTelemetrySnapshot
if err := json.Unmarshal(b, &snapshot); err == nil && (snapshot.Version > 0 || snapshot.ReadFiles != nil) {
if snapshot.ReadFiles == nil {
snapshot.ReadFiles = []readFileRecord{}
}
if snapshot.Usage.SessionCost == 0 && snapshot.Usage.SessionCostUsd > 0 {
snapshot.Usage.SessionCost = snapshot.Usage.SessionCostUsd
}
return snapshot
}
var records []readFileRecord
if err := json.Unmarshal(b, &records); err != nil || records == nil {
records = []readFileRecord{}
}
return tabTelemetrySnapshot{Version: 1, ReadFiles: records}
}
// --- project tree -----------------------------------------------------------
// ProjectNode is one node in the sidebar project tree (a project folder or a
// topic leaf).
type ProjectNode struct {
Key string `json:"key"` // stable key for React
Kind string `json:"kind"` // "project" | "topic" | "session" | "global_folder" | "global_topic" | "global_session"
Label string `json:"label"`
Root string `json:"root,omitempty"` // project workspace root
TopicID string `json:"topicId,omitempty"`
SessionPath string `json:"sessionPath,omitempty"`
ProjectColor string `json:"projectColor,omitempty"`
Turns int `json:"turns,omitempty"`
CreatedAt int64 `json:"createdAt,omitempty"`
LastActivityAt int64 `json:"lastActivityAt,omitempty"`
Open bool `json:"open,omitempty"`
Running bool `json:"running,omitempty"`
Status string `json:"status,omitempty"`
Pinned bool `json:"pinned,omitempty"`
Recovered bool `json:"recovered,omitempty"`
RecoveryReason string `json:"recoveryReason,omitempty"`
RecoveryDigest string `json:"recoveryDigest,omitempty"`
RecoveryParentID string `json:"recoveryParentId,omitempty"`
Children []ProjectNode `json:"children,omitempty"`
}
func normalizeTopicStatus(status string) string {
switch status {
case topicStatusThinking, topicStatusStreaming, topicStatusWaitingConfirmation, topicStatusBackgroundJob, topicStatusPaused, topicStatusError:
return status
default:
return ""
}
}
func activityStatusForTab(tab *WorkspaceTab) string {
if tab == nil {
return ""
}
status := normalizeTopicStatus(tab.ActivityStatus)
if tab.Ctrl == nil {
return status
}
runtimeStatus := tab.Ctrl.RuntimeStatus()
if runtimeStatus.PendingPrompt {
return topicStatusWaitingConfirmation
}
if runtimeStatus.Running {
if status == "" || status == topicStatusError || status == topicStatusPaused {
return topicStatusThinking
}
return status
}
if runtimeStatus.BackgroundJobs > 0 {
return topicStatusBackgroundJob
}
if status == topicStatusError || status == topicStatusPaused {
return status
}
return ""
}
// migrateLegacySessionsIntoGlobalTopics makes pre-topic desktop history visible
// in the v2 sidebar. Imported v0.x sessions and older desktop sessions are plain
// .jsonl files, sometimes with branch metadata but no topic metadata; the
// history panel can list them, but the project tree cannot. Give each such
// session a deterministic Global topic so every old conversation has a direct
// sidebar entry without guessing a project workspace.
// legacyMigrationMu serializes the lockless load-modify-save of the projects /
// topic-title files: this migration runs from every concurrent buildTabController
// and from ListProjectTree, so without it parallel runs lose each other's appends.
var legacyMigrationMu sync.Mutex
// topicMigrationMarker, once written into a session dir, records that the
// pre-topic → Global-topic migration pass completed for that dir. Later
// ListProjectTree calls can skip the full session decode while the marker is
// newer than the directory's session files, but a newly-created CLI session
// invalidates the marker and gets a bounded re-scan.
// It is stamped only when the pass left nothing deferred (an empty legacy
// session that could gain content later keeps the dir unmarked), so the gate
// never hides a session that should still be migrated.
// v2 re-evaluates recovery-named sessions that v1 skipped solely by filename.
// The old marker cannot safely suppress this one-time data-preservation pass.
const topicMigrationMarker = ".topics-migrated-v2"
const topicIndexRepairMarker = ".topic-indexes-repaired-v2"
func invalidateTopicDirMarkers(dir string) error {
dir = strings.TrimSpace(dir)
if dir == "" {
return nil
}
var errs []error
for _, marker := range []string{topicMigrationMarker, topicIndexRepairMarker} {
if err := os.Remove(filepath.Join(dir, marker)); err != nil && !os.IsNotExist(err) {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func topicDirMarkerDone(dir, marker string) bool {
dir = strings.TrimSpace(dir)
marker = strings.TrimSpace(marker)
if dir == "" || marker == "" {
return false
}
markerInfo, err := os.Stat(filepath.Join(dir, marker))
if err != nil {
return false
}
entries, err := os.ReadDir(dir)
if err != nil {
return true
}
markerTime := markerInfo.ModTime()
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !store.IsSessionTranscriptName(name) && !strings.HasSuffix(name, ".jsonl.meta") {
continue
}
info, err := entry.Info()
if err != nil {
return false
}
if info.ModTime().After(markerTime) {
return false
}
}
return true
}
func topicMigrationDone(dir string) bool {
return topicDirMarkerDone(dir, topicMigrationMarker)
}
func topicIndexRepairDone(dir string) bool {
return topicDirMarkerDone(dir, topicIndexRepairMarker)
}
func markTopicDirMarkerDone(dir, marker string) {
dir = strings.TrimSpace(dir)
marker = strings.TrimSpace(marker)
if dir == "" || marker == "" {
return
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return
}
_ = os.WriteFile(filepath.Join(dir, marker), nil, 0o644)
}
func markTopicMigrationDone(dir string) {
markTopicDirMarkerDone(dir, topicMigrationMarker)
}
func markTopicIndexRepairDone(dir string) {
markTopicDirMarkerDone(dir, topicIndexRepairMarker)
}
func migrateLegacySessionsIntoGlobalTopics(dir string) []string {
if strings.TrimSpace(dir) == "" {
return nil
}
repairedTopicIDs := repairIndexedSessionTopics(dir)
// One-shot per dir: once the migration pass has completed, skip the full
// per-render session scan entirely.
if topicMigrationDone(dir) {
return repairedTopicIDs
}
scope, workspaceRoot, topicTitleRoot, ok := legacyMigrationTargetForDir(dir)
if !ok {
return nil
}
legacyMigrationMu.Lock()
defer legacyMigrationMu.Unlock()
// Re-check under the lock: another render may have completed the pass while
// this one waited.
if topicMigrationDone(dir) {
return nil
}
infos, err := agent.ListSessionOrder(dir)
if err != nil {
return nil // transient read error — retry on the next render, leave unmarked
}
var migratedTopicIDs []string
var titles map[string]string
var topicTitles map[string]string
var topicSources map[string]string
// deferred stays false only when every session was either migrated or is
// permanently non-migratable. A transient skip (unreadable meta, empty
// session that may gain content, failed write) sets it, keeping the dir
// unmarked so the next render retries instead of the gate hiding it forever.
deferred := false
for _, info := range infos {
if sessionOrderInfoIsUnmodifiedRecoveryCopy(info, dir) {
continue
}
if strings.TrimSpace(info.TopicID) != "" {
continue
}
if meta, ok, err := agent.LoadBranchMeta(info.Path); err != nil {
deferred = true
continue
} else if ok && !legacySessionMetaMatchesMigrationTarget(meta, scope, workspaceRoot) {
continue
}
topicID := legacySessionTopicID(info.Path)
if topicID == "" {
continue
}
preview, turns := agent.SessionPreview(info.Path)
if turns == 0 {
deferred = true // empty now, but a later turn could make it migratable
continue
}
if titles == nil {
titles = loadSessionTitles(dir)
}
title := strings.TrimSpace(titles[filepath.Base(info.Path)])
if title == "" {
title = topicTitleFromText(preview)
} else if normalized := topicTitleFromText(title); normalized != "" {
title = normalized
}
if title == "" {
when := info.LastActivityAt
if when.IsZero() {
when = info.ModTime
}
if when.IsZero() {
title = "历史会话"
} else {
title = "历史会话 " + when.Local().Format("2006-01-02")
}
}
migrated, err := func() (bool, error) {
// Read-modify-write on the branch-meta sidecar: hold the per-path
// meta lock so agent-side writers (autosave revision bumps,
// in-flight markers) can't interleave between the load and save
// below and lose their fields.
unlock := agent.LockSessionMetaPath(info.Path)
defer unlock()
meta, err := agent.EnsureBranchMeta(info.Path)
if err != nil {
return false, err
}
// Preserve scoped sessions only when their existing ownership matches
// the directory being migrated.
if !legacySessionMetaMatchesMigrationTarget(meta, scope, workspaceRoot) {
return false, nil
}
meta.Scope = scope
meta.WorkspaceRoot = workspaceRoot
meta.TopicID = topicID
meta.TopicTitle = title
return true, agent.SaveBranchMetaPreserveUpdated(info.Path, meta)
}()
if err != nil {
deferred = true
continue
}
if !migrated {
continue
}
if topicTitles == nil {
topicTitles = loadTopicTitles(topicTitleRoot)
}
if topicSources == nil {
topicSources = loadTopicTitleSources(topicTitleRoot)
}
if strings.TrimSpace(topicTitles[topicID]) == "" {
topicTitles[topicID] = title
topicSources[topicID] = topicTitleSourceManual
}
migratedTopicIDs = append(migratedTopicIDs, topicID)
}
if len(migratedTopicIDs) == 0 {
if !deferred {
markTopicMigrationDone(dir) // nothing left to migrate — gate future scans
}
return repairedTopicIDs
}
_ = prependTopicsInProjectsFile(workspaceRoot, migratedTopicIDs, false)
// Same fresh tombstone re-check as the repair pass: these are whole-map
// saves, so a concurrent DeleteTopic of an unrelated topic must not have
// its title written back by this migration batch.
pruneDeletedTopicEntries(topicTitles, topicSources)
if topicTitles != nil {
_ = saveTopicTitles(topicTitleRoot, topicTitles)
}
if topicSources != nil {
_ = saveTopicTitleSources(topicTitleRoot, topicSources)
}
invalidateTopicSessionIndex(dir)
projectSessionCache.invalidate()
if !deferred {
markTopicMigrationDone(dir) // pass complete with nothing deferred
}
return uniqueStrings(append(repairedTopicIDs, migratedTopicIDs...))
}
// pruneDeletedTopicEntries drops tombstoned topics from scan-built title and
// source maps just before they are persisted, re-reading DeletedTopics so a
// DeleteTopic that landed after the scan snapshot wins. The maps are loaded
// whole at scan start and saved whole at the end; without this re-check the
// save would write the deleted topic's stale entries back, and a title-map
// entry alone resurrects a topic in the sidebar (orderedTopicIDs lists topics
// that exist only in the title map). Returns the tombstone list so callers can
// filter their returned topic-ID slices against the same snapshot.
func pruneDeletedTopicEntries(maps ...map[string]string) []string {
deleted := loadProjectsFile().DeletedTopics
if len(deleted) == 0 {
return nil
}
for _, m := range maps {
for _, id := range deleted {
delete(m, id)
}
}
return deleted
}
func repairIndexedSessionTopics(dir string) []string {
if strings.TrimSpace(dir) == "" || topicIndexRepairDone(dir) {
return nil
}
scope, workspaceRoot, topicTitleRoot, ok := legacyMigrationTargetForDir(dir)
if !ok {
return nil
}
legacyMigrationMu.Lock()
defer legacyMigrationMu.Unlock()
if topicIndexRepairDone(dir) {
return nil
}
infos, err := agent.ListSessionOrder(dir)
if err != nil {
return nil
}
topicTitles, err := loadTopicTitlesForUpdate(topicTitleRoot)
if err != nil {
return nil
}
topicSources, err := loadTopicTitleSourcesForUpdate(topicTitleRoot)
if err != nil {
return nil
}
projects := loadProjectsFile()
deletedTopics := projects.DeletedTopics
// Repair only topics missing from the sidebar index. Skipping topics that
// are already listed and titled keeps steady-state rescans write-free:
// otherwise every rescan (any session activity invalidates the marker)
// would prepend the full topic list back in most-recently-active order,
// reordering the user's sidebar and handing already-visible topics to the
// blank-tab binding in the migration callers.
indexedTopics := projects.GlobalTopics
if scope == "project" {
indexedTopics = nil
if i := projectIndexByRoot(projects.Projects, workspaceRoot); i >= 0 {
indexedTopics = projects.Projects[i].Topics
}
}
indexedSet := make(map[string]bool, len(indexedTopics))
for _, id := range indexedTopics {
indexedSet[id] = true
}
var repairedTopicIDs []string
var sessionTitles map[string]string
titlesChanged := false
sourcesChanged := false
deferred := false
for _, info := range infos {
if sessionOrderInfoIsUnmodifiedRecoveryCopy(info, dir) {
continue
}
topicID := strings.TrimSpace(info.TopicID)
if topicID == "" {
continue
}
if indexedSet[topicID] && strings.TrimSpace(topicTitles[topicID]) != "" {
continue // fully indexed already — nothing to repair, skip the meta read
}
meta, ok, err := agent.LoadBranchMeta(info.Path)
if err != nil {
deferred = true
continue
}
if !ok || strings.TrimSpace(meta.TopicID) == "" {
continue
}
if containsDesktopString(deletedTopics, topicID) {
continue
}
if !legacySessionScopeMatchesMigrationTarget(meta, scope, workspaceRoot) {
continue
}
repairedTopicIDs = append(repairedTopicIDs, topicID)
if strings.TrimSpace(topicTitles[topicID]) == "" {
if sessionTitles == nil {
sessionTitles = loadSessionTitles(dir)
}
title := indexedSessionTopicTitle(sessionTitles, info, meta)
if title == "" {
title = defaultTopicTitle
}
topicTitles[topicID] = title
titlesChanged = true
}
if strings.TrimSpace(topicSources[topicID]) == "" {
topicSources[topicID] = topicTitleSourceManual
sourcesChanged = true
}
}
if len(repairedTopicIDs) > 0 {
// Re-check tombstones right before persisting: a DeleteTopic landing
// after the scan snapshot must win. The prepend re-filters under the
// projects-file lock; the whole-map title/source saves and the
// returned IDs (callers bind blank Global tabs to the first entry)
// need the same fresh read.
if deletedNow := pruneDeletedTopicEntries(topicTitles, topicSources); len(deletedNow) > 0 {
deletedSet := make(map[string]bool, len(deletedNow))
for _, id := range deletedNow {
deletedSet[id] = true
}
live := repairedTopicIDs[:0]
for _, id := range repairedTopicIDs {
if !deletedSet[id] {
live = append(live, id)
}
}
repairedTopicIDs = live
}
}
if len(repairedTopicIDs) > 0 {
if err := prependTopicsInProjectsFile(workspaceRoot, repairedTopicIDs, false); err != nil {
deferred = true
}
if titlesChanged {
if err := saveTopicTitles(topicTitleRoot, topicTitles); err != nil {
deferred = true
}
}
if sourcesChanged {
if err := saveTopicTitleSources(topicTitleRoot, topicSources); err != nil {
deferred = true
}
}
if !deferred {
projectSessionCache.invalidate()
}
}
if !deferred {
markTopicIndexRepairDone(dir)
return uniqueStrings(repairedTopicIDs)
}
return nil
}
func indexedSessionTopicTitle(sessionTitles map[string]string, info agent.SessionOrderInfo, meta agent.BranchMeta) string {
if title := topicTitleFromText(meta.TopicTitle); title != "" {
return title
}
if title := topicTitleFromText(info.TopicTitle); title != "" {
return title
}
if title := topicTitleFromText(sessionTitles[filepath.Base(info.Path)]); title != "" {
return title
}
return topicTitleFromText(info.Preview)
}
func sessionOrderInfoIsAutomaticRecovery(info agent.SessionOrderInfo) bool {
return info.Recovered ||
strings.TrimSpace(info.RecoveryDigest) != "" ||
isAutomaticRecoverySessionPath(info.Path)
}
func sessionInfoIsAutomaticRecovery(info agent.SessionInfo) bool {
return info.Recovered ||
strings.TrimSpace(info.RecoveryDigest) != "" ||
isAutomaticRecoverySessionPath(info.Path)
}
func sessionOrderInfoIsUnmodifiedRecoveryCopy(info agent.SessionOrderInfo, parentDir string) bool {
return sessionOrderInfoIsAutomaticRecovery(info) &&
agent.RecoveryBranchCoveredByParent(info.Path, parentDir)
}
func sessionInfoIsUnmodifiedRecoveryCopy(info agent.SessionInfo, parentDir string) bool {
return sessionInfoIsAutomaticRecovery(info) &&
agent.RecoveryBranchCoveredByParent(info.Path, parentDir)
}
func isAutomaticRecoverySessionPath(path string) bool {
id := agent.BranchID(path)
idx := strings.LastIndex(id, "-recovery-")
if idx <= 0 {
return false
}
suffix := id[idx+len("-recovery-"):]
if len(suffix) != 16 {
return false
}
for _, r := range suffix {
if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') {
continue
}
return false
}
return true
}
func legacyMigrationTargetForDir(dir string) (scope, workspaceRoot, topicTitleRoot string, ok bool) {
dir = cleanDesktopPath(dir)
if dir == "" {
return "", "", "", false
}
if sameDesktopPath(dir, config.SessionDir()) || sameDesktopPath(dir, desktopSessionDir(globalWorkspaceRoot())) {
return "global", "", "", true
}
for _, p := range loadProjectsFile().Projects {
if sameDesktopPath(config.ProjectSessionDir(p.Root), dir) {
return "project", p.Root, p.Root, true
}
}
return "", "", "", false
}
func legacySessionMetaMatchesMigrationTarget(meta agent.BranchMeta, scope, workspaceRoot string) bool {
if strings.TrimSpace(meta.TopicID) != "" {
return false
}
return legacySessionScopeMatchesMigrationTarget(meta, scope, workspaceRoot)
}
func legacySessionScopeMatchesMigrationTarget(meta agent.BranchMeta, scope, workspaceRoot string) bool {
metaScope := strings.TrimSpace(meta.Scope)
if metaScope != "" && metaScope != scope {
return false
}
metaRoot := normalizeProjectRoot(meta.WorkspaceRoot)
if scope == "project" {
return metaRoot == "" || sameProjectRoot(workspaceRoot, metaRoot)
}
return metaRoot == "" || sameProjectRoot(globalWorkspaceRoot(), metaRoot)
}
func cleanDesktopPath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
if abs, err := filepath.Abs(path); err == nil {
path = abs
}
return filepath.Clean(path)
}
func sameDesktopPath(a, b string) bool {
a = cleanDesktopPath(a)
b = cleanDesktopPath(b)
if a == "" || b == "" {
return false
}
if os.PathSeparator == '\\' {
return strings.EqualFold(a, b)
}
return a == b
}
// projectRootKey is the map-key form of a project root: cleaned, absolute,
// and case-folded on Windows — the same key form agent.CanonicalSessionPath
// uses for session paths, so equivalent spellings never split lookups.
func projectRootKey(root string) string {
root = cleanDesktopPath(root)
if os.PathSeparator == '\\' {
return strings.ToLower(root)
}
return root
}
func restoreSessionTopicIndex(dir, sessionPath string) error {
sessionPath = strings.TrimSpace(sessionPath)
if sessionPath == "" {
return nil
}
meta, ok, err := agent.LoadBranchMeta(sessionPath)
if err != nil {
return err
}
if !ok || strings.TrimSpace(meta.TopicID) == "" {
// The migration pass takes per-session meta locks itself, so it must
// run outside the lock taken below.
migrateLegacySessionsIntoGlobalTopics(dir)
return nil
}
// Read-modify-write on the branch-meta sidecar: re-read and save under the
// per-path meta lock so a concurrent save's revision bump can't land in
// between and get rolled back by the write at the end.
unlock := agent.LockSessionMetaPath(sessionPath)
defer unlock()
meta, ok, err = agent.LoadBranchMeta(sessionPath)
if err != nil {
return err
}
if !ok || strings.TrimSpace(meta.TopicID) == "" {
return nil
}
topicID := strings.TrimSpace(meta.TopicID)
scope := strings.TrimSpace(meta.Scope)
workspaceRoot := strings.TrimSpace(meta.WorkspaceRoot)
if scope != "global" && scope != "project" {
if workspaceRoot == "" {
scope = "global"
} else {
scope = "project"
}
}
if scope == "global" {
workspaceRoot = ""
} else {
workspaceRoot = normalizeProjectRoot(workspaceRoot)
if workspaceRoot == "" {
scope = "global"
}
}
title := restoredSessionTopicTitle(dir, sessionPath, meta)
if title == "" {
title = defaultTopicTitle
}
if err := setTopicTitleWithSource(workspaceRoot, topicID, title, topicTitleSourceManual); err != nil {
return err
}
if scope == "global" {
meta.Scope = "global"
meta.WorkspaceRoot = ""
} else {
meta.Scope = "project"
meta.WorkspaceRoot = workspaceRoot
}
meta.TopicID = topicID
meta.TopicTitle = title
if err := prependTopicInProjectsFile(workspaceRoot, topicID, scope == "project"); err != nil {
return err
}
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, meta); err != nil {
return err
}
invalidateTopicSessionIndexForPath(sessionPath)
return nil
}
func restoredSessionTopicTitle(dir, sessionPath string, meta agent.BranchMeta) string {
if title := storedSessionTopicTitle(dir, sessionPath, meta); title != "" {
return title
}
if s, err := agent.LoadSession(sessionPath); err == nil {
for _, msg := range s.Messages {
if msg.Role == provider.RoleUser {
if title := topicTitleFromText(control.StripComposePrefixes(agent.HandoffTask(msg.Content))); title != "" {
return title
}
}
}
}
return ""
}
func storedSessionTopicTitle(dir, sessionPath string, meta agent.BranchMeta) string {
if title := topicTitleFromText(meta.TopicTitle); title != "" {
return title
}
return topicTitleFromText(loadSessionTitles(dir)[filepath.Base(sessionPath)])
}
func legacySessionTopicID(path string) string {
id := agent.BranchID(path)
id = strings.TrimSpace(id)
if id == "" {
return ""
}
sum := sha256.Sum256([]byte(id))
var b strings.Builder
b.WriteString("legacy_")
for _, r := range id {
switch {
case unicode.IsLetter(r), unicode.IsDigit(r):
b.WriteRune(r)
case r == '-', r == '_':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
prefix := strings.TrimRight(b.String(), "_")
if prefix == "legacy" {
prefix = "legacy_session"
}
return prefix + "_" + hex.EncodeToString(sum[:])[:12]
}
// TopicMeta describes a topic for the project tree.
type TopicMeta struct {
ID string `json:"id"`
Title string `json:"title"`
CreatedAt int64 `json:"createdAt"`
}
// CreateTopic creates a new topic under a project workspace and returns its metadata.
func (a *App) CreateTopic(scope, workspaceRoot, title string) (TopicMeta, error) {
trimmedTitle := strings.TrimSpace(title)
titleSource := topicTitleSourceManual
if trimmedTitle == "" {
trimmedTitle = defaultTopicTitle
titleSource = topicTitleSourceAuto
}
topicID := newTopicID()
createdAt := time.Now().UnixMilli()
if scope == "global" {
workspaceRoot = ""
}
if workspaceRoot != "" {
if abs, err := filepath.Abs(workspaceRoot); err == nil {
workspaceRoot = abs
}
}
if err := setTopicTitleWithSource(workspaceRoot, topicID, trimmedTitle, titleSource); err != nil {
return TopicMeta{}, err
}
if err := setTopicCreatedAt(workspaceRoot, topicID, createdAt); err != nil {
return TopicMeta{}, err
}
// New topics should appear first in their project/global group so the item
// just created is immediately visible and selected in the sidebar.
_ = prependTopicInProjectsFile(workspaceRoot, topicID, workspaceRoot != "")
a.emitProjectTreeChanged()
return TopicMeta{ID: topicID, Title: trimmedTitle, CreatedAt: createdAt}, nil
}
// RenameProject updates the sidebar-only display title for a project folder.
// Empty title clears the override and falls back to the folder name.
func (a *App) RenameProject(workspaceRoot, title string) error {
if err := renameProject(workspaceRoot, title); err != nil {
return err
}
a.syncTabWorkspaceRootSpellings()
a.emitProjectTreeChanged()
return nil
}
// SetProjectColor updates the project-level accent color used by project topics
// in the sidebar and tabs. Empty color restores the default accent.
func (a *App) SetProjectColor(workspaceRoot, color string) error {
if err := setProjectColor(workspaceRoot, color); err != nil {
return err
}
a.syncTabWorkspaceRootSpellings()
a.emitProjectTreeChanged()
return nil
}
// SetProjectPinned controls whether a project folder is pinned above the rest of
// the desktop project tree.
func (a *App) SetProjectPinned(workspaceRoot string, pinned bool) error {
root := normalizeProjectRoot(workspaceRoot)
if root == "" {
return fmt.Errorf("workspaceRoot is required")
}
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
i := projectIndexByRoot(f.Projects, root)
if i < 0 {
return false, fmt.Errorf("project %q not found", root)
}
root = f.Projects[i].Root
next := make([]string, 0, len(f.PinnedProjects))
for _, pinnedRoot := range f.PinnedProjects {
if !sameProjectRoot(pinnedRoot, root) {
next = append(next, pinnedRoot)
}
}
if pinned {
next = prependUniqueString(next, root)
}
if sameStringList(next, f.PinnedProjects) {
return false, nil
}
f.PinnedProjects = next
return true, nil
}); err != nil {
return err
}
a.emitProjectTreeChanged()
return nil
}
// ReorderProjects persists the user-defined order of project folders and,
// when present, the virtual Global sidebar section.
func (a *App) ReorderProjects(workspaceRoots []string) error {
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
var seenProjects []string
next := make([]desktopProject, 0, len(workspaceRoots))
sidebarOrder := make([]string, 0, len(workspaceRoots))
hasGlobalOrder := false
for _, root := range workspaceRoots {
root = strings.TrimSpace(root)
if root == desktopGlobalOrderToken {
if hasGlobalOrder {
return false, fmt.Errorf("duplicate global section")
}
hasGlobalOrder = true
sidebarOrder = append(sidebarOrder, root)
continue
}
root = normalizeProjectRoot(root)
i := projectIndexByRoot(f.Projects, root)
if i < 0 {
return false, fmt.Errorf("project %q not found", root)
}
project := f.Projects[i]
if projectRootInList(seenProjects, project.Root) {
return false, fmt.Errorf("duplicate project %q", root)
}
seenProjects = append(seenProjects, project.Root)
next = append(next, project)
sidebarOrder = append(sidebarOrder, project.Root)
}
if len(next) != len(f.Projects) {
return false, fmt.Errorf("project order length mismatch")
}
changed := !sameProjectOrder(next, f.Projects)
f.Projects = next
if hasGlobalOrder {
if !sameStringList(sidebarOrder, f.SidebarOrder) {
changed = true
}
f.SidebarOrder = sidebarOrder
} else {
if len(f.SidebarOrder) > 0 {
changed = true
}
f.SidebarOrder = nil
}
return changed, nil
}); err != nil {
return err
}
a.emitProjectTreeChanged()
return nil
}
// RenameTopic updates a topic's display title.
func (a *App) RenameTopic(topicID, title string) error {
trimmed := strings.TrimSpace(title)
if trimmed == "" {
trimmed = defaultTopicTitle
}
// Find which workspace this topic belongs to by scanning all project topic titles.
f := loadProjectsFile()
for _, p := range f.Projects {
m := loadTopicTitles(p.Root)
if _, ok := m[topicID]; ok {
if err := setTopicTitle(p.Root, topicID, trimmed); err != nil {
return err
}
a.updateOpenTopicTitle(topicID, trimmed)
a.updateTopicSessionTitles(topicID, trimmed)
a.emitProjectTreeChanged()
return nil
}
}
// Check global.
m := loadTopicTitles("")
if _, ok := m[topicID]; ok {
if err := setTopicTitle("", topicID, trimmed); err != nil {
return err
}
a.updateOpenTopicTitle(topicID, trimmed)
a.updateTopicSessionTitles(topicID, trimmed)
a.emitProjectTreeChanged()
return nil
}
if scope, workspaceRoot, ok := a.findTopicLocation(topicID); ok {
if err := ensureTopicIndexed(scope, workspaceRoot, topicID, trimmed, topicTitleSourceManual); err != nil {
return err
}
a.updateOpenTopicTitle(topicID, trimmed)
a.updateTopicSessionTitles(topicID, trimmed)
a.emitProjectTreeChanged()
return nil
}
return fmt.Errorf("topic %q not found", topicID)
}
func (a *App) findTopicLocation(topicID string) (string, string, bool) {
topicID = strings.TrimSpace(topicID)
if topicID == "" {
return "", "", false
}
a.mu.RLock()
for _, tab := range a.tabs {
if tab == nil || tab.TopicID != topicID {
continue
}
scope := tab.Scope
workspaceRoot := tab.WorkspaceRoot
a.mu.RUnlock()
if scope == "global" {
return "global", "", true
}
return "project", normalizeProjectRoot(workspaceRoot), true
}
a.mu.RUnlock()
infos, err := agent.ListSessions(config.SessionDir())
if err != nil {
return "", "", false
}
for _, info := range infos {
if strings.TrimSpace(info.TopicID) != topicID {
continue
}
scope := strings.TrimSpace(info.Scope)
if scope == "" {
scope = "global"
}
if scope == "global" {
return "global", "", true
}
return "project", normalizeProjectRoot(info.WorkspaceRoot), true
}
return "", "", false
}
func (a *App) updateOpenTopicTitle(topicID, title string) {
if strings.TrimSpace(topicID) == "" || strings.TrimSpace(title) == "" {
return
}
a.mu.Lock()
defer a.mu.Unlock()
for _, tab := range a.tabs {
if tab != nil && tab.TopicID == topicID {
tab.TopicTitle = title
}
}
}
func (a *App) updateTopicSessionTitles(topicID, title string) {
if strings.TrimSpace(topicID) == "" || strings.TrimSpace(title) == "" {
return
}
for _, dir := range a.knownSessionDirs() {
for _, match := range topicSessionMatches(dir, topicID) {
// Read-modify-write on the branch-meta sidecar: hold the per-path
// meta lock so a concurrent save's revision bump can't land between
// the load and save below and get rolled back by this write.
unlock := agent.LockSessionMetaPath(match.path)
meta, ok, err := agent.LoadBranchMeta(match.path)
if err != nil || !ok {
unlock()
continue
}
meta.TopicTitle = title
err = agent.SaveBranchMetaPreserveUpdated(match.path, meta)
unlock()
if err == nil {
invalidateTopicSessionIndex(dir)
}
}
}
}
func (a *App) setTabActivityStatus(tabID, status string) bool {
a.mu.Lock()
defer a.mu.Unlock()
tab := a.tabByEventSinkIDLocked(tabID)
if tab == nil {
return false
}
status = normalizeTopicStatus(status)
if tab.ActivityStatus == status {
return false
}
tab.ActivityStatus = status
return true
}
func (a *App) emitProjectTreeChanged() {
projectSessionCache.invalidate()
if a.projectTreeChangedHook != nil {
a.projectTreeChangedHook()
return
}
a.emitRuntimeEvent("project-tree:changed")
}
func (a *App) emitRuntimeEvent(name string, payload ...interface{}) {
if a == nil || a.ctx == nil {
return
}
a.runtimeEvents.Emit(a.ctx, name, payload...)
}
// DeleteTopic removes a topic and its title metadata.
func (a *App) DeleteTopic(topicID string) error {
return friendlySessionFileError(a.deleteTopic(topicID))
}
func (a *App) deleteTopic(topicID string) error {
f := loadProjectsFile()
found := false
for _, p := range f.Projects {
m, err := loadTopicTitlesForUpdate(p.Root)
if err != nil {
return err
}
if _, ok := m[topicID]; ok {
delete(m, topicID)
if err := saveTopicTitles(p.Root, m); err != nil {
return err
}
sources, err := loadTopicTitleSourcesForUpdate(p.Root)
if err != nil {
return err
}
delete(sources, topicID)
if err := saveTopicTitleSources(p.Root, sources); err != nil {
return err
}
deleteTopicCreatedAt(p.Root, topicID)
found = true
break
}
}
if !found {
m, err := loadTopicTitlesForUpdate("")
if err != nil {
return err
}
if _, ok := m[topicID]; ok {
delete(m, topicID)
if err := saveTopicTitles("", m); err != nil {
return err
}
sources, err := loadTopicTitleSourcesForUpdate("")
if err != nil {
return err
}
delete(sources, topicID)
if err := saveTopicTitleSources("", sources); err != nil {
return err
}
deleteTopicCreatedAt("", topicID)
found = true
}
}
if !found {
return fmt.Errorf("topic %q not found", topicID)
}
if err := removeTopicFromProjectsFile(topicID); err != nil {
return err
}
a.emitProjectTreeChanged()
return nil
}
// SetTopicPinned controls whether a topic is pinned to the top of its project
// or Global section in the desktop project tree.
func (a *App) SetTopicPinned(topicID string, pinned bool) error {
topicID = strings.TrimSpace(topicID)
if topicID == "" {
return fmt.Errorf("topicID is required")
}
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
for i, p := range f.Projects {
m := loadTopicTitles(p.Root)
if _, ok := m[topicID]; !ok && !containsDesktopString(p.Topics, topicID) {
continue
}
next := removeString(f.Projects[i].PinnedTopics, topicID)
if pinned {
next = prependUniqueString(f.Projects[i].PinnedTopics, topicID)
}
if sameStringList(next, f.Projects[i].PinnedTopics) {
return false, nil
}
f.Projects[i].PinnedTopics = next
return true, nil
}
globalTitles := loadTopicTitles("")
if _, ok := globalTitles[topicID]; !ok && !containsDesktopString(f.GlobalTopics, topicID) {
return false, fmt.Errorf("topic %q not found", topicID)
}
next := removeString(f.GlobalPinnedTopics, topicID)
if pinned {
next = prependUniqueString(f.GlobalPinnedTopics, topicID)
}
if sameStringList(next, f.GlobalPinnedTopics) {
return false, nil
}
f.GlobalPinnedTopics = next
return true, nil
}); err != nil {
return err
}
a.emitProjectTreeChanged()
return nil
}
// TrashTopic removes a topic from the project tree and moves its saved session
// records into the session trash. Any in-process runtimes for the topic are
// cancelled and detached from the app first, so their autosave/jobs cannot
// recreate state after the topic is gone.
func (a *App) TrashTopic(topicID string) error {
return friendlySessionFileError(a.trashTopic(topicID))
}
func (a *App) trashTopic(topicID string) error {
if strings.TrimSpace(topicID) == "" {
return fmt.Errorf("topicID is required")
}
var fallback fallbackRuntimeTarget
if err := func() error {
a.sessionRemovalMu.Lock()
defer a.sessionRemovalMu.Unlock()
targets, err := a.topicTrashTargets(topicID)
if err != nil {
return err
}
removed, nextFallback := a.removeTopicRuntimeBindings(topicID)
fallback = nextFallback
if err := a.prepareRemovedSessionRuntimes(removed); err != nil {
a.closeRemovedSessionRuntimes(removed)
return err
}
destroyBegun := false
closedRemoved := map[control.SessionAPI]bool{}
defer func() {
if destroyBegun {
a.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)
return
}
a.closeRemovedSessionRuntimes(removed)
}()
for _, target := range targets {
destroys := a.destroyHandlesForSession(target.dir, target.sessionPath, removed)
if len(destroys) > 0 {
destroyBegun = true
}
teardownTimedOut := waitDestroyHandles(destroys)
a.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, target.dir, target.sessionPath, closedRemoved)
if teardownTimedOut {
if err := agent.MarkCleanupPending(target.sessionPath, "delete"); err != nil {
return err
}
go delayedDesktopSessionTrash(target.dir, target.sessionPath, target.key, destroys)
} else {
err := trashSessionArtifacts(target.dir, target.sessionPath, target.key)
finishDestroyHandles(destroys)
if err != nil {
return err
}
}
}
return a.deleteTopic(topicID)
}(); err != nil {
return err
}
if fallback.needs {
fallback.topicID = ""
if err := a.openFallbackRuntime(fallback); err != nil {
return err
}
}
a.emitProjectTreeChanged()
return nil
}
type topicTrashTarget struct {
dir string
sessionPath string
key string
}
func (a *App) topicTrashTargets(topicID string) ([]topicTrashTarget, error) {
topicID = strings.TrimSpace(topicID)
var targets []topicTrashTarget
seen := map[string]bool{}
addTarget := func(dir, path string) error {
sessionPath, key, err := validateSessionPath(dir, path)
if err != nil {
return err
}
id := dir + "\x00" + sessionPath
if seen[id] {
return nil
}
seen[id] = true
if err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {
return err
}
targets = append(targets, topicTrashTarget{dir: dir, sessionPath: sessionPath, key: key})
return nil
}
for _, dir := range a.knownSessionDirs() {
index, err := topicSessionIndexForDir(dir)
if err != nil {
return nil, err
}
for _, match := range index.byTopic[topicID] {
if agent.IsCleanupPending(match.path) {
continue
}
if err := addTarget(dir, match.path); err != nil {
return nil, err
}
}
}
a.mu.RLock()
var runtimeTargets []struct {
dir string
path string
}
for _, tab := range a.runtimeTabsLocked() {
if tab == nil || tab.TopicID != topicID {
continue
}
if path := canonicalTabSessionPath(tab.currentSessionPath()); path != "" {
dir := tabSessionDir(tab)
if filepath.IsAbs(path) {
dir = filepath.Dir(path)
}
runtimeTargets = append(runtimeTargets, struct {
dir string
path string
}{dir: dir, path: path})
}
}
a.mu.RUnlock()
for _, target := range runtimeTargets {
if err := addTarget(target.dir, target.path); err != nil {
return nil, err
}
}
return targets, nil
}
// ListProjectTree builds the sidebar tree: project folders each containing
// their topics, plus a Global section.
// topicSummary is used by ListProjectTree and mergeSessionInfos to track
// per-topic turn count and last activity.
type topicSummary struct {
turns int
adoptedRecoveryTurns int
lastActivityAt int64
hasNormalSession bool
hasRecoveryOnly bool
hasAdoptedRecovery bool
}
func (s topicSummary) displayTurns() int {
if s.adoptedRecoveryTurns > s.turns {
return s.adoptedRecoveryTurns
}
return s.turns
}
// runtimeSessionStatus is one open or detached runtime session, as shown in
// the sidebar tree.
type runtimeSessionStatus struct {
sessionPath string
label string
turns int
createdAt int64
lastActivityAt int64
open bool
running bool
status string
recovered bool
recoveryReason string
recoveryDigest string
recoveryParentID string
}
// topicHiddenAsRecoveryOnly hides topics whose only on-disk sessions are
// conflict-recovery copies: they stay reachable from History, but must not sit
// in the tree as regular conversations. Pinned topics and topics with any
// open or running runtime session remain visible — note topicRuntimeStatus
// reports open/running only for single-session topics, so it must not gate
// topic existence.
func topicHiddenAsRecoveryOnly(summary topicSummary, pinned bool, runtimeSessions []runtimeSessionStatus) bool {
if !summary.hasRecoveryOnly || summary.hasNormalSession || summary.hasAdoptedRecovery || pinned {
return false
}
for _, session := range runtimeSessions {
if session.open || session.running {
return false
}
}
return true
}
var listProjectTreeMu sync.Mutex
func (a *App) ListProjectTree() []ProjectNode {
listProjectTreeMu.Lock()
defer listProjectTreeMu.Unlock()
knownDirs := a.knownSessionDirs()
for _, dir := range knownDirs {
migrateLegacySessionsIntoGlobalTopics(dir)
}
f := loadProjectsFile()
// Render-side tombstone guard: a deleted topic whose title survived a racy
// whole-map save would otherwise reappear via the orderedTopicIDs title-map
// fallback. The tombstone is authoritative until an intentional
// single-topic write (create/restore/tab indexing) clears it.
deletedTopicSet := make(map[string]bool, len(f.DeletedTopics))
for _, id := range f.DeletedTopics {
deletedTopicSet[id] = true
}
out := []ProjectNode{}
topicSummaries := map[string]topicSummary{}
sessionInfos := map[string]agent.SessionInfo{}
sessionTitles := map[string]string{}
// Read session listings from all known directories concurrently, since
// each dir is independent I/O. With N workspaces × dozens of sessions,
// sequential reads add up to seconds of wall time on cold start.
cacheToken := projectSessionCache.versionToken()
type sessionDirLoadResult struct {
dir string
infos []agent.SessionInfo
titles map[string]string
ok bool
}
results := make(chan sessionDirLoadResult, len(knownDirs))
pendingLoads := 0
for _, dir := range knownDirs {
infos, titles, ok := projectSessionCache.get(dir)
if ok {
mergeSessionInfos(dir, infos, titles, sessionInfos, sessionTitles, topicSummaries)
continue
}
pendingLoads++
dir := dir // capture
go func() {
result := sessionDirLoadResult{dir: dir}
defer func() {
if recover() != nil {
result.ok = false
}
results <- result
}()
// Sidecar-backed listing: ListSessions reads turn count + preview from
// each session's .meta sidecar, so even large directories list in a few
// milliseconds without decoding any .jsonl body. The in-memory
// projectSessionCache still elides repeat listings within a session.
infos, err := agent.ListSessions(dir)
if err != nil {
return
}
titles := loadSessionTitles(dir)
projectSessionCache.put(dir, infos, titles, cacheToken)
result.infos = infos
result.titles = titles
result.ok = true
}()
}
if pendingLoads > 0 {
timer := time.NewTimer(5 * time.Second)
for received := 0; received < pendingLoads; {
select {
case result := <-results:
received++
if result.ok {
mergeSessionInfos(result.dir, result.infos, result.titles, sessionInfos, sessionTitles, topicSummaries)
}
case <-timer.C:
received = pendingLoads
}
}
timer.Stop()
}
runtimeSessionsByTopic := map[string][]runtimeSessionStatus{}
a.mu.RLock()
seenRuntimePaths := map[string]bool{}
addRuntimeSession := func(tab *WorkspaceTab, open bool) {
if tab == nil || strings.TrimSpace(tab.TopicID) == "" {
return
}
sessionPath := sessionRuntimeKey(tab.currentSessionPath())
if sessionPath == "" || seenRuntimePaths[sessionPath] {
return
}
seenRuntimePaths[sessionPath] = true
info := sessionInfos[sessionPath]
recovered := sessionInfoIsAutomaticRecovery(info) || isAutomaticRecoverySessionPath(sessionPath)
label := runtimeSessionTreeLabel(tab, info, sessionTitles[sessionPath])
status := activityStatusForTab(tab)
runtimeStatus := control.RuntimeStatus{}
if tab.Ctrl != nil {
runtimeStatus = tab.Ctrl.RuntimeStatus()
}
running := status != "" || runtimeStatus.Running || runtimeStatus.PendingPrompt || runtimeStatus.BackgroundJobs > 0
runtimeSessionsByTopic[topicSummaryKey(tab.Scope, tab.WorkspaceRoot, tab.TopicID)] = append(runtimeSessionsByTopic[topicSummaryKey(tab.Scope, tab.WorkspaceRoot, tab.TopicID)], runtimeSessionStatus{
sessionPath: sessionPath,
label: label,
turns: info.Turns,
createdAt: unixMilliOrZero(info.CreatedAt),
lastActivityAt: unixMilliOrZero(info.LastActivityAt),
open: open,
running: running,
status: status,
recovered: recovered,
recoveryReason: info.RecoveryReason,
recoveryDigest: info.RecoveryDigest,
recoveryParentID: string(info.ParentID),
})
}
for _, tab := range a.tabs {
addRuntimeSession(tab, true)
}
for _, tab := range a.detachedSessions {
addRuntimeSession(tab, false)
}
a.mu.RUnlock()
for key := range runtimeSessionsByTopic {
sort.Slice(runtimeSessionsByTopic[key], func(i, j int) bool {
left := runtimeSessionsByTopic[key][i]
right := runtimeSessionsByTopic[key][j]
if left.lastActivityAt != right.lastActivityAt {
return left.lastActivityAt > right.lastActivityAt
}
return left.sessionPath < right.sessionPath
})
}
topicRuntimeStatus := func(key string) (open, running bool, status string) {
sessions := runtimeSessionsByTopic[key]
if len(sessions) != 1 {
return false, false, ""
}
session := sessions[0]
return session.open, session.running, session.status
}
runtimeSessionNodes := func(scope, workspaceRoot, topicID, projectColor string) []ProjectNode {
key := topicSummaryKey(scope, workspaceRoot, topicID)
sessions := runtimeSessionsByTopic[key]
if len(sessions) <= 1 {
return nil
}
nodes := make([]ProjectNode, 0, len(sessions))
for _, session := range sessions {
kind := "session"
if scope == "global" {
kind = "global_session"
}
nodes = append(nodes, ProjectNode{
Key: projectSessionNodeKey(scope, session.sessionPath),
Kind: kind,
Label: session.label,
Root: workspaceRoot,
TopicID: topicID,
SessionPath: session.sessionPath,
ProjectColor: projectColor,
Turns: session.turns,
CreatedAt: session.createdAt,
LastActivityAt: session.lastActivityAt,
Open: session.open,
Running: session.running,
Status: session.status,
Recovered: session.recovered,
RecoveryReason: session.recoveryReason,
RecoveryDigest: session.recoveryDigest,
RecoveryParentID: session.recoveryParentID,
})
}
return nodes
}
// Global section.
globalTitleMap := loadTopicTitles("")
globalCreatedMap := loadTopicCreatedAts("")
if len(globalTitleMap) > 0 || len(f.Projects) == 0 {
globalTitle := strings.TrimSpace(f.GlobalTitle)
if globalTitle == "" {
globalTitle = "Global"
}
globalColor := normalizeProjectColor(f.GlobalColor)
globalTopicIDs := pinnedTopicIDs(orderedTopicIDs(f.GlobalTopics, globalTitleMap), f.GlobalPinnedTopics)
children := make([]ProjectNode, 0, len(globalTopicIDs))
for _, id := range globalTopicIDs {
if deletedTopicSet[id] {
continue
}
title := globalTitleMap[id]
summaryKey := topicSummaryKey("global", "", id)
summary := topicSummaries[summaryKey]
open, running, status := topicRuntimeStatus(summaryKey)
pinned := containsDesktopString(f.GlobalPinnedTopics, id)
if topicHiddenAsRecoveryOnly(summary, pinned, runtimeSessionsByTopic[summaryKey]) {
continue
}
children = append(children, ProjectNode{
Key: "global_topic_" + id,
Kind: "global_topic",
Label: title,
TopicID: id,
ProjectColor: globalColor,
Turns: summary.displayTurns(),
CreatedAt: topicCreatedAtForTree(globalCreatedMap, id),
LastActivityAt: summary.lastActivityAt,
Open: open,
Running: running,
Status: status,
Pinned: pinned,
Children: runtimeSessionNodes("global", "", id, globalColor),
})
}
out = append(out, ProjectNode{
Key: "global_folder",
Kind: "global_folder",
Label: globalTitle,
Root: globalWorkspaceRoot(),
ProjectColor: globalColor,
Children: children,
})
}
// Project sections.
type projectTopics struct {
project desktopProject
titles map[string]string
createdAts map[string]int64
}
projectTopicResults := make([]projectTopics, len(f.Projects))
var topicLoadWg sync.WaitGroup
for i, p := range f.Projects {
i, p := i, p
topicLoadWg.Add(1)
go func() {
defer topicLoadWg.Done()
projectTopicResults[i] = projectTopics{
project: p,
titles: loadTopicTitles(p.Root),
createdAts: loadTopicCreatedAts(p.Root),
}
}()
}
topicLoadWg.Wait()
for _, loaded := range projectTopicResults {
p := loaded.project
title := p.Title
if title == "" {
title = workspaceName(p.Root)
}
node := ProjectNode{
Key: "project_" + p.Root,
Kind: "project",
Root: p.Root,
Pinned: containsDesktopString(f.PinnedProjects, p.Root),
}
// Gather topics: explicit topic list + all known topic titles.
titleMap := loaded.titles
createdMap := loaded.createdAts
topicIDs := pinnedTopicIDs(orderedTopicIDs(p.Topics, titleMap), p.PinnedTopics)
children := make([]ProjectNode, 0, len(topicIDs))
for _, tid := range topicIDs {
if deletedTopicSet[tid] {
continue
}
topicTitle := strings.TrimSpace(titleMap[tid])
if topicTitle == "" {
topicTitle = defaultTopicTitle
}
summaryKey := topicSummaryKey("project", p.Root, tid)
summary := topicSummaries[summaryKey]
open, running, status := topicRuntimeStatus(summaryKey)
pinned := containsDesktopString(p.PinnedTopics, tid)
if topicHiddenAsRecoveryOnly(summary, pinned, runtimeSessionsByTopic[summaryKey]) {
continue
}
children = append(children, ProjectNode{
Key: "topic_" + tid,
Kind: "topic",
Label: topicTitle,
Root: p.Root,
TopicID: tid,
ProjectColor: p.Color,
Turns: summary.displayTurns(),
CreatedAt: topicCreatedAtForTree(createdMap, tid),
LastActivityAt: summary.lastActivityAt,
Open: open,
Running: running,
Status: status,
Pinned: pinned,
Children: runtimeSessionNodes("project", p.Root, tid, p.Color),
})
}
node.Label = title
node.ProjectColor = p.Color
node.Children = children
out = append(out, node)
}
return applyPinnedProjectOrder(applyProjectTreeOrder(out, f.SidebarOrder), f.PinnedProjects)
}
func topicSummaryKey(scope, workspaceRoot, topicID string) string {
if scope == "global" {
return "global::" + topicID
}
// Producers key by the live tab's root spelling while the sidebar keys by
// the registry's canonical spelling; fold both so runtime status never
// splits across equivalent roots.
return "project:" + projectRootKey(workspaceRoot) + ":" + topicID
}
func projectSessionNodeKey(scope, sessionPath string) string {
sum := sha256.Sum256([]byte(sessionRuntimeKey(sessionPath)))
return scope + "_session_" + hex.EncodeToString(sum[:8])
}
func runtimeSessionTreeLabel(tab *WorkspaceTab, info agent.SessionInfo, title string) string {
if title = strings.TrimSpace(title); title != "" {
return title
}
if preview := topicTitleFromText(info.Preview); preview != "" {
return preview
}
if tab != nil {
if title := strings.TrimSpace(tab.TopicTitle); title != "" {
return title
}
}
if path := strings.TrimSpace(info.Path); path != "" {
return strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
if tab != nil {
if path := strings.TrimSpace(tab.currentSessionPath()); path != "" {
return strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
}
return defaultTopicTitle
}
func unixMilliOrZero(t time.Time) int64 {
if t.IsZero() {
return 0
}
return t.UnixMilli()
}
// ContextPanelInfo is the right-side panel's data for one tab.
type ContextPanelInfo struct {
UsedTokens int `json:"usedTokens"`
WindowTokens int `json:"windowTokens"`
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
ReasoningTokens int `json:"reasoningTokens"`
CacheHitTokens int `json:"cacheHitTokens"`
CacheMissTokens int `json:"cacheMissTokens"`
// Session-cumulative token counts (from telemetry, atomic snapshot).
// Separate from the per-turn fields above so existing consumers (status bar
// turn tokens, donut chart) are unaffected.
SessionCacheHitTokens int `json:"sessionCacheHitTokens"`
SessionCacheMissTokens int `json:"sessionCacheMissTokens"`
SessionCompletionTokens int `json:"sessionCompletionTokens"`
RequestCount int `json:"requestCount"`
ElapsedMs int64 `json:"elapsedMs"`
SessionCost float64 `json:"sessionCost"`
SessionCurrency string `json:"sessionCurrency,omitempty"`
SessionCostUsd float64 `json:"sessionCostUsd,omitempty"`
Sources map[string]usageSourceStats `json:"sources,omitempty"`
Mock bool `json:"mock,omitempty"`
ReadFiles []readFileRecord `json:"readFiles"`
ChangedFiles []ChangedFileInfo `json:"changedFiles"`
}
type ChangedFileInfo struct {
Path string `json:"path"`
OldPath string `json:"oldPath,omitempty"`
Sources []string `json:"sources"`
GitStatus string `json:"gitStatus,omitempty"`
Turns []int `json:"turns"`
LatestPrompt string `json:"latestPrompt,omitempty"`
LatestTime int64 `json:"latestTime,omitempty"`
}
// ContextPanel returns the context usage, read files, and changed files for a
// specific tab.
func (a *App) ContextPanel(tabID string) ContextPanelInfo {
a.mu.RLock()
tab, ok := a.tabs[tabID]
var ctrl control.SessionAPI
if ok && tab != nil {
ctrl = tab.Ctrl
}
a.mu.RUnlock()
if !ok {
return ContextPanelInfo{ReadFiles: []readFileRecord{}, ChangedFiles: []ChangedFileInfo{}}
}
info := ContextPanelInfo{ReadFiles: []readFileRecord{}, ChangedFiles: []ChangedFileInfo{}}
if ctrl != nil {
if sp := ctrl.SessionPath(); sp != "" {
tab.syncTelemetryToSession(sp)
}
used, window := ctrl.ContextSnapshot()
info.UsedTokens = used
info.WindowTokens = window
// Per-turn token breakdown from LastUsage (same snapshot as UsedTokens)
// so the donut segments are proportional to the current context fill,
// not inflated by cumulative session totals.
if u := ctrl.LastUsage(); u != nil {
info.PromptTokens = u.PromptTokens
info.CompletionTokens = u.CompletionTokens
info.ReasoningTokens = u.ReasoningTokens
info.CacheHitTokens = u.CacheHitTokens
info.CacheMissTokens = u.CacheMissTokens
}
}
telemetry := tab.telemetrySnapshot()
if records := telemetry.ReadFiles; records != nil {
info.ReadFiles = records
}
usage := telemetry.Usage
info.TotalTokens = usage.TotalTokens
info.RequestCount = usage.RequestCount
info.ElapsedMs = usage.ElapsedMs
info.SessionCost = usage.SessionCost
info.SessionCurrency = usage.SessionCurrency
info.SessionCostUsd = usage.SessionCostUsd
info.Sources = usage.Sources
info.SessionCacheHitTokens = usage.CacheHitTokens
info.SessionCacheMissTokens = usage.CacheMissTokens
info.SessionCompletionTokens = usage.CompletionTokens
// Gather workspace changes for this tab's root.
if ctrl != nil && tab.WorkspaceRoot != "" {
for _, meta := range ctrl.Checkpoints() {
for _, path := range meta.Paths {
info.ChangedFiles = append(info.ChangedFiles, ChangedFileInfo{
Path: path,
Sources: []string{"session"},
Turns: []int{meta.Turn},
LatestPrompt: meta.Prompt,
LatestTime: meta.Time.UnixMilli(),
})
}
}
}
return info
}
// --- utility ----------------------------------------------------------------
func (a *App) newUniqueTabIDLocked() string {
for {
id := newTabID()
if _, exists := a.tabs[id]; !exists {
return id
}
}
}
func (a *App) restoredTabIDLocked(id string) string {
id = strings.TrimSpace(id)
if id == "" {
return a.newUniqueTabIDLocked()
}
if _, exists := a.tabs[id]; exists {
return a.newUniqueTabIDLocked()
}
return id
}
func normalizeTabMode(mode string) string {
switch mode {
case "plan", "yolo", "plan-yolo", "yolo-plan":
if mode == "yolo-plan" {
return "plan-yolo"
}
return mode
default:
return "normal"
}
}
func tabModeFromAxes(plan, autoApproveTools bool) string {
switch {
case plan && autoApproveTools:
return "plan-yolo"
case plan:
return "plan"
case autoApproveTools:
return "yolo"
default:
return "normal"
}
}
func tabModeHasPlan(mode string) bool {
switch normalizeTabMode(mode) {
case "plan", "plan-yolo":
return true
default:
return false
}
}
func tabModeHasAutoApproveTools(mode string) bool {
switch normalizeTabMode(mode) {
case "yolo", "plan-yolo":
return true
default:
return false
}
}
func currentTabMode(tab *WorkspaceTab) string {
if tab == nil {
return "normal"
}
if tab.Ctrl != nil {
return tabModeFromAxes(tab.Ctrl.PlanMode(), tab.Ctrl.AutoApproveTools())
}
return normalizeTabMode(tab.mode)
}
func currentTabGoal(tab *WorkspaceTab) string {
if tab == nil {
return ""
}
if tab.Ctrl != nil {
return tab.Ctrl.Goal()
}
return strings.TrimSpace(tab.goal)
}
func currentTabGoalStatus(tab *WorkspaceTab) string {
if tab == nil {
return control.GoalStatusStopped
}
if tab.Ctrl != nil {
return tab.Ctrl.GoalStatus()
}
if strings.TrimSpace(tab.goal) != "" {
return control.GoalStatusRunning
}
return control.GoalStatusStopped
}
func currentTabCollaborationMode(tab *WorkspaceTab) string {
if tab == nil {
return "normal"
}
if tabModeHasPlan(currentTabMode(tab)) {
return "plan"
}
if strings.TrimSpace(currentTabGoal(tab)) != "" && currentTabGoalStatus(tab) == control.GoalStatusRunning {
return "goal"
}
return "normal"
}
func currentTabToolApprovalMode(tab *WorkspaceTab) string {
if tab == nil {
return control.ToolApprovalAsk
}
if tab.Ctrl != nil {
return tab.Ctrl.ToolApprovalMode()
}
return normalizeToolApprovalMode(tab.toolApprovalMode)
}
func currentTabTokenMode(tab *WorkspaceTab) string {
if tab == nil {
return boot.TokenModeFull
}
return boot.NormalizeTokenMode(tab.tokenMode)
}
// tabRuntimeSnapshot is a consistent under-a.mu copy of the per-tab fields
// that bound methods and rebuild paths need after releasing the lock. The
// build/rebuild goroutines write these fields under a.mu, so lock-free reads
// from other goroutines are data races (same class as the sessionLease race
// fixed for #5955). Controller methods are invoked on the snapshot's ctrl
// AFTER unlocking, never while holding a.mu.
type tabRuntimeSnapshot struct {
ctrl control.SessionAPI
sink *tabEventSink
label string
ready bool
readOnly bool
startupErr string
scope string
workspaceRoot string
sessionPath string
topicID string
topicTitle string
sharedHostKey string
model string
effort *string
tokenMode string
mode string
goal string
toolApprovalMode string
}
// snapshotTabRuntimeLocked copies the racy per-tab fields. Callers must hold
// a.mu (read or write side).
func snapshotTabRuntimeLocked(tab *WorkspaceTab) tabRuntimeSnapshot {
if tab == nil {
return tabRuntimeSnapshot{}
}
return tabRuntimeSnapshot{
ctrl: tab.Ctrl,
sink: tab.sink,
label: tab.Label,
ready: tab.Ready,
readOnly: tab.ReadOnly,
startupErr: tab.StartupErr,
scope: tab.Scope,
workspaceRoot: tab.WorkspaceRoot,
sessionPath: tab.SessionPath,
topicID: tab.TopicID,
topicTitle: tab.TopicTitle,
sharedHostKey: tab.SharedHostKey,
model: tab.model,
effort: cloneStringPtr(tab.effort),
tokenMode: tab.tokenMode,
mode: tab.mode,
goal: tab.goal,
toolApprovalMode: tab.toolApprovalMode,
}
}
func (a *App) tabRuntimeSnapshot(tab *WorkspaceTab) tabRuntimeSnapshot {
if tab == nil {
return tabRuntimeSnapshot{}
}
a.mu.RLock()
defer a.mu.RUnlock()
return snapshotTabRuntimeLocked(tab)
}
// Snapshot-based forms of the currentTabX helpers, for callers that already
// hold a consistent tabRuntimeSnapshot.
func (s tabRuntimeSnapshot) currentMode() string {
if s.ctrl != nil {
return tabModeFromAxes(s.ctrl.PlanMode(), s.ctrl.AutoApproveTools())
}
return normalizeTabMode(s.mode)
}
func (s tabRuntimeSnapshot) currentGoal() string {
if s.ctrl != nil {
return s.ctrl.Goal()
}
return strings.TrimSpace(s.goal)
}
func (s tabRuntimeSnapshot) currentGoalStatus() string {
if s.ctrl != nil {
return s.ctrl.GoalStatus()
}
if strings.TrimSpace(s.goal) != "" {
return control.GoalStatusRunning
}
return control.GoalStatusStopped
}
func (s tabRuntimeSnapshot) collaborationMode() string {
if tabModeHasPlan(s.currentMode()) {
return "plan"
}
if strings.TrimSpace(s.currentGoal()) != "" && s.currentGoalStatus() == control.GoalStatusRunning {
return "goal"
}
return "normal"
}
func (s tabRuntimeSnapshot) currentToolApprovalMode() string {
if s.ctrl != nil {
return s.ctrl.ToolApprovalMode()
}
return normalizeToolApprovalMode(s.toolApprovalMode)
}
func (s tabRuntimeSnapshot) currentTokenMode() string {
return boot.NormalizeTokenMode(s.tokenMode)
}
func persistedTabTokenMode(mode string) string {
mode = boot.NormalizeTokenMode(mode)
if mode == boot.TokenModeEconomy || mode == boot.TokenModeDelivery {
return mode
}
return ""
}
func normalizeToolApprovalMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case control.ToolApprovalAuto:
return control.ToolApprovalAuto
case control.ToolApprovalYolo, "full", "full-access", "bypass":
return control.ToolApprovalYolo
default:
return control.ToolApprovalAsk
}
}
func persistedToolApprovalMode(mode string) string {
switch normalizeToolApprovalMode(mode) {
case control.ToolApprovalAuto, control.ToolApprovalYolo:
return normalizeToolApprovalMode(mode)
default:
return ""
}
}
// persistedTabMode is the composer mode saved with a tab so it survives reload
// and app relaunch. plan, yolo, and plan-yolo are remembered (a restored yolo
// tab keeps its status-bar indicator); "normal" is the default and isn't
// persisted. (#3517)
func persistedTabMode(mode string) string {
switch normalizeTabMode(mode) {
case "plan", "yolo", "plan-yolo":
return normalizeTabMode(mode)
}
return ""
}
func newTabID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
now := time.Now().UTC()
return "tab_" + now.Format("20060102150405") + "_" + fmt.Sprintf("%09d", now.Nanosecond())
}
return "tab_" + hex.EncodeToString(b[:])
}
func newTopicID() string {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
now := time.Now().UTC()
return "topic_" + now.Format("20060102-150405") + "_" + fmt.Sprintf("%09d", now.Nanosecond())
}
return "topic_" + time.Now().UTC().Format("20060102-150405") + "_" + hex.EncodeToString(b[:])
}
func globalWorkspaceRoot() string {
return filepath.Join(desktopConfigDir(), "global-workspace")
}
func ensureGlobalWorkspaceRoot() (string, error) {
root := globalWorkspaceRoot()
if err := os.MkdirAll(root, 0o755); err != nil {
return "", err
}
return root, nil
}
func globalTabWorkspaceRoot() string {
root, err := ensureGlobalWorkspaceRoot()
if err != nil {
return globalWorkspaceRoot()
}
return root
}
func loadPinnedTabSession(dir, sessionPath string) (*agent.Session, string, bool) {
return loadPinnedTabSessionWithPreloadAndMigrationFallback(dir, sessionPath, loadedTabSession{}, true)
}
func loadPinnedTabSessionWithPreload(dir, sessionPath string, preloaded loadedTabSession) (*agent.Session, string, bool) {
return loadPinnedTabSessionWithPreloadAndMigrationFallback(dir, sessionPath, preloaded, false)
}
func loadPinnedTabSessionWithPreloadAndMigrationFallback(dir, sessionPath string, preloaded loadedTabSession, allowMigrationFallback bool) (*agent.Session, string, bool) {
path, ok := pinnedTabSessionPath(dir, sessionPath)
if !ok && allowMigrationFallback {
path, ok = migratedPinnedTabSessionPath(dir, sessionPath)
}
if !ok {
return nil, "", false
}
if agent.IsCleanupPending(path) {
return nil, "", false
}
if preloaded.matches(path) {
if preloaded.Session != nil && len(preloaded.Session.Snapshot()) == 0 {
return nil, path, true
}
return preloaded.Session, path, true
}
loaded, err := agent.LoadSession(path)
if err != nil {
if os.IsNotExist(err) {
return nil, path, true
}
return nil, "", false
}
// An empty file (0 messages) is a pre-created placeholder, not a real
// session to resume. Treating it as valid would make ctrl.Resume replace
// the executor's live session (with system prompt) with the empty one,
// causing the saved transcript to lack the agent identity contract.
if len(loaded.Snapshot()) == 0 {
return nil, path, true
}
return loaded, path, true
}
func migratedPinnedTabSessionPath(dir, sessionPath string) (string, bool) {
sessionPath = strings.TrimSpace(sessionPath)
if sessionPath == "" || dir == "" || !filepath.IsAbs(sessionPath) {
return "", false
}
if _, err := os.Stat(sessionPath); err == nil || !os.IsNotExist(err) {
return "", false
}
base := filepath.Base(sessionPath)
if base == "." || base == string(filepath.Separator) || !strings.HasSuffix(base, ".jsonl") {
return "", false
}
path, _, err := validateSessionPath(dir, filepath.Join(dir, base))
if err != nil {
return "", false
}
return path, true
}
func pinnedTabSessionPath(dir, sessionPath string) (string, bool) {
sessionPath = strings.TrimSpace(sessionPath)
if sessionPath == "" || dir == "" {
return "", false
}
path, _, err := validateSessionPath(dir, sessionPath)
if err != nil {
if filepath.IsAbs(sessionPath) {
return "", false
}
base := filepath.Base(sessionPath)
if base == "." || base == string(filepath.Separator) || !strings.HasSuffix(base, ".jsonl") {
return "", false
}
path, _, err = validateSessionPath(dir, filepath.Join(dir, base))
if err != nil {
return "", false
}
}
return path, true
}
// saveTabSessionMeta persists the tab's scope/topic/mode fields into the
// session's branch-meta sidecar at path. Tab fields are snapshotted under a.mu
// (controller reads happen off-lock) so a concurrent tab mutation can't tear
// the persisted record.
func (a *App) saveTabSessionMeta(tab *WorkspaceTab, path string) error {
if tab == nil || strings.TrimSpace(path) == "" {
return nil
}
a.mu.RLock()
ctrl := tab.Ctrl
snap := tabSessionMetaSnapshot{
path: path,
scope: tab.Scope,
workspaceRoot: tab.WorkspaceRoot,
topicID: tab.TopicID,
topicTitle: tab.TopicTitle,
tokenMode: boot.NormalizeTokenMode(tab.tokenMode),
mode: normalizeTabMode(tab.mode),
toolApprovalMode: normalizeToolApprovalMode(tab.toolApprovalMode),
goal: strings.TrimSpace(tab.goal),
}
a.mu.RUnlock()
if ctrl != nil {
snap.mode = tabModeFromAxes(ctrl.PlanMode(), ctrl.AutoApproveTools())
snap.toolApprovalMode = normalizeToolApprovalMode(ctrl.ToolApprovalMode())
if goal := strings.TrimSpace(ctrl.Goal()); goal != "" && ctrl.GoalStatus() == control.GoalStatusRunning {
snap.goal = goal
} else {
snap.goal = ""
}
}
return saveTabSessionMetaSnapshot(snap)
}
type tabSessionMetaSnapshot struct {
path string
scope string
workspaceRoot string
topicID string
topicTitle string
tokenMode string
mode string
toolApprovalMode string
goal string
}
func (a *App) saveTabSessionMetaForCurrentSession(tab *WorkspaceTab) error {
snap, ok := a.tabSessionMetaSnapshotForCurrentSession(tab)
if !ok {
return nil
}
return saveTabSessionMetaSnapshot(snap)
}
func (a *App) tabSessionMetaSnapshotForCurrentSession(tab *WorkspaceTab) (tabSessionMetaSnapshot, bool) {
if tab == nil {
return tabSessionMetaSnapshot{}, false
}
a.mu.RLock()
if tab.ID != "" && a.tabs[tab.ID] != tab {
a.mu.RUnlock()
return tabSessionMetaSnapshot{}, false
}
readOnly := tab.ReadOnly
ctrl := tab.Ctrl
storedPath := strings.TrimSpace(tab.SessionPath)
scope := tab.Scope
workspaceRoot := tab.WorkspaceRoot
topicID := tab.TopicID
topicTitle := tab.TopicTitle
tokenMode := boot.NormalizeTokenMode(tab.tokenMode)
mode := normalizeTabMode(tab.mode)
toolApprovalMode := normalizeToolApprovalMode(tab.toolApprovalMode)
goal := strings.TrimSpace(tab.goal)
a.mu.RUnlock()
if readOnly {
return tabSessionMetaSnapshot{}, false
}
ctrlPath := ""
ctrlDir := ""
activeWork := false
if ctrl != nil {
ctrlPath = strings.TrimSpace(ctrl.SessionPath())
if dir, ok := safeControllerSessionDir(ctrl); ok {
ctrlDir = strings.TrimSpace(dir)
}
status := ctrl.RuntimeStatus()
activeWork = status.Running || status.PendingPrompt || status.BackgroundJobs > 0
mode = tabModeFromAxes(ctrl.PlanMode(), ctrl.AutoApproveTools())
toolApprovalMode = normalizeToolApprovalMode(ctrl.ToolApprovalMode())
if ctrl.GoalStatus() == control.GoalStatusRunning {
goal = strings.TrimSpace(ctrl.Goal())
} else {
goal = ""
}
}
currentPath := ctrlPath
if currentPath == "" {
currentPath = storedPath
}
if currentPath == "" {
return tabSessionMetaSnapshot{}, false
}
sessionDir := desktopSessionDir("")
if workspaceRoot != "" {
sessionDir = desktopSessionDir(workspaceRoot)
} else if ctrlDir != "" {
sessionDir = ctrlDir
}
runtimeDir := sessionDir
if ctrlDir != "" {
if _, _, err := validateSessionPath(ctrlDir, currentPath); err == nil {
runtimeDir = ctrlDir
}
}
if topicID == "" && !activeWork && storedPath != "" && sessionPathHasNoContent(sessionDir, storedPath) {
return tabSessionMetaSnapshot{}, false
}
path := tabSessionMetaPathForSession(runtimeDir, sessionDir, currentPath)
if path == "" {
return tabSessionMetaSnapshot{}, false
}
return tabSessionMetaSnapshot{
path: path,
scope: scope,
workspaceRoot: workspaceRoot,
topicID: topicID,
topicTitle: topicTitle,
tokenMode: tokenMode,
mode: mode,
toolApprovalMode: toolApprovalMode,
goal: goal,
}, true
}
func saveTabSessionMetaSnapshot(snap tabSessionMetaSnapshot) error {
if strings.TrimSpace(snap.path) == "" {
return nil
}
// Read-modify-write on the branch-meta sidecar: hold the per-path meta lock
// so agent-side writers (autosave UpdateSessionMeta, in-flight markers)
// can't interleave and drop fields.
unlock := agent.LockSessionMetaPath(snap.path)
defer unlock()
m, err := agent.EnsureBranchMeta(snap.path)
if err != nil {
return err
}
scope := snap.scope
workspaceRoot := snap.workspaceRoot
if ownerScope, ownerRoot, _, ok := legacyMigrationTargetForDir(filepath.Dir(snap.path)); ok {
if ownerScope == "project" {
scope = ownerScope
workspaceRoot = ownerRoot
}
}
if scope == "project" {
workspaceRoot = normalizeProjectRoot(workspaceRoot)
} else {
scope = "global"
workspaceRoot = ""
}
m.Scope = scope
m.WorkspaceRoot = workspaceRoot
m.TopicID = snap.topicID
m.TopicTitle = snap.topicTitle
m.TokenMode = persistedTabTokenMode(snap.tokenMode)
m.Mode = persistedTabMode(snap.mode)
m.ToolApprovalMode = persistedToolApprovalMode(snap.toolApprovalMode)
m.Goal = strings.TrimSpace(snap.goal)
if err := agent.SaveBranchMetaPreserveUpdated(snap.path, m); err != nil {
return err
}
invalidateTopicSessionIndexForPath(snap.path)
return nil
}
func tabSessionMetaPathForSession(runtimeDir, sessionDir, sessionPath string) string {
sessionPath = strings.TrimSpace(sessionPath)
if sessionPath == "" {
return ""
}
for _, dir := range []string{runtimeDir, sessionDir} {
if resolved, ok := pinnedTabSessionPath(dir, sessionPath); ok {
return resolved
}
}
path := canonicalTabSessionPath(sessionPath)
if filepath.IsAbs(path) {
return path
}
return ""
}
type tabSessionProfile struct {
tokenMode string
mode string
toolApprovalMode string
goal string
}
func defaultTabSessionProfile() tabSessionProfile {
return tabSessionProfile{
tokenMode: boot.TokenModeFull,
mode: "normal",
toolApprovalMode: control.ToolApprovalAsk,
}
}
func tabSessionProfileFromMeta(sessionPath string, meta agent.BranchMeta) tabSessionProfile {
profile := defaultTabSessionProfile()
profile.tokenMode = boot.NormalizeTokenMode(meta.TokenMode)
profile.mode = normalizeTabMode(meta.Mode)
profile.toolApprovalMode = normalizeToolApprovalMode(meta.ToolApprovalMode)
if profile.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(meta.Mode) {
profile.toolApprovalMode = control.ToolApprovalYolo
}
profile.goal = runningTabSessionGoal(sessionPath, meta.Goal)
return profile
}
func loadTabSessionProfile(sessionPath string) tabSessionProfile {
meta, ok, err := agent.LoadBranchMeta(sessionPath)
if err != nil || !ok {
return defaultTabSessionProfile()
}
return tabSessionProfileFromMeta(sessionPath, meta)
}
func applyTabSessionProfile(tab *WorkspaceTab, profile tabSessionProfile) {
if tab == nil {
return
}
tab.tokenMode = boot.NormalizeTokenMode(profile.tokenMode)
tab.mode = normalizeTabMode(profile.mode)
tab.toolApprovalMode = normalizeToolApprovalMode(profile.toolApprovalMode)
if tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(tab.mode) {
tab.toolApprovalMode = control.ToolApprovalYolo
}
tab.mode = tabModeFromAxes(tabModeHasPlan(tab.mode), tab.toolApprovalMode == control.ToolApprovalYolo)
tab.goal = strings.TrimSpace(profile.goal)
}
func persistedTabGoal(tab *WorkspaceTab) string {
goal := strings.TrimSpace(currentTabGoal(tab))
if goal == "" || currentTabGoalStatus(tab) != control.GoalStatusRunning {
return ""
}
return goal
}
type tabSessionGoalState struct {
Goal string `json:"goal,omitempty"`
Status string `json:"status,omitempty"`
}
func runningTabSessionGoal(sessionPath, fallback string) string {
fallback = strings.TrimSpace(fallback)
if fallback == "" {
return ""
}
data, err := readFileUTF8(store.SessionGoalState(sessionPath))
if err != nil {
return fallback
}
var state tabSessionGoalState
if err := json.Unmarshal(data, &state); err != nil {
return fallback
}
switch state.Status {
case control.GoalStatusRunning:
if goal := strings.TrimSpace(state.Goal); goal != "" {
return goal
}
return fallback
case "", control.GoalStatusStopped:
return ""
default:
return ""
}
}
func canonicalTabSessionPath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
if validPath, _, err := validateSessionPath(config.SessionDir(), path); err == nil {
return validPath
}
// Project-scope sessions live outside config.SessionDir(), so validation
// against it always fails. Still normalize the shape: without clean+abs,
// the same file spelled with a different separator or a relative prefix
// splits into distinct runtime keys.
cleaned := filepath.Clean(path)
if abs, err := filepath.Abs(cleaned); err == nil {
return abs
}
return cleaned
}
func (a *App) rememberTabSessionPath(tab *WorkspaceTab, path string) {
path = canonicalTabSessionPath(path)
if tab == nil || path == "" {
return
}
a.mu.Lock()
if current := a.tabs[tab.ID]; current == tab {
tab.SessionPath = path
a.saveTabsLocked()
} else {
tab.SessionPath = path
}
a.mu.Unlock()
}
func (a *App) persistTabSessionPath(tab *WorkspaceTab, path string) {
path = canonicalTabSessionPath(path)
if tab == nil || path == "" {
return
}
if reconciled, ok := a.reconcileTabWithSessionPath(tab, path); ok {
path = canonicalTabSessionPath(reconciled)
}
_ = a.saveTabSessionMeta(tab, path)
a.rememberTabSessionPath(tab, path)
}
func (a *App) knownSessionDirs() []string {
seen := map[string]bool{}
out := []string{}
add := func(dir string) {
dir = strings.TrimSpace(dir)
if dir == "" {
return
}
if abs, err := filepath.Abs(dir); err == nil {
dir = abs
}
if seen[dir] {
return
}
seen[dir] = true
out = append(out, dir)
}
add(config.SessionDir()) // legacy/global sessions from earlier desktop builds
add(desktopSessionDir(globalWorkspaceRoot()))
for _, project := range loadProjectsFile().Projects {
dir := desktopSessionDir(project.Root)
if _, err := os.Stat(dir); os.IsNotExist(err) {
continue // project dir removed or external volume unmounted
}
add(dir)
}
a.mu.RLock()
for _, tab := range a.tabs {
add(tabSessionDir(tab))
}
for _, tab := range a.detachedSessions {
add(tabSessionDir(tab))
}
a.mu.RUnlock()
return out
}
func topicSessionMatchMatchesTarget(match topicSessionMatch, scope, workspaceRoot string) bool {
if scope == "project" {
return match.scope == "project" && sameProjectRoot(match.workspaceRoot, workspaceRoot)
}
return match.scope == "" || match.scope == "global"
}
func (a *App) findTopicSessionForTarget(scope, workspaceRoot, topicID string) (string, string) {
return a.findTopicSessionForTargetByContent(scope, workspaceRoot, topicID, false)
}
func (a *App) findTopicContentSessionForTarget(scope, workspaceRoot, topicID string) (string, string) {
return a.findTopicSessionForTargetByContent(scope, workspaceRoot, topicID, true)
}
func (a *App) findTopicSessionForTargetByContent(scope, workspaceRoot, topicID string, requireContent bool) (string, string) {
topicID = strings.TrimSpace(topicID)
if topicID == "" {
return "", ""
}
var bestPath string
var bestDir string
var bestTime time.Time
for _, dir := range a.knownSessionDirs() {
for _, match := range topicSessionMatches(dir, topicID) {
if requireContent && !sessionFileHasConversationContent(match.path) {
continue
}
if !topicSessionMatchMatchesTarget(match, scope, workspaceRoot) {
continue
}
if bestPath == "" || match.updatedAt.After(bestTime) ||
(match.updatedAt.Equal(bestTime) && match.path < bestPath) {
bestPath = match.path
bestDir = dir
bestTime = match.updatedAt
}
}
}
return bestPath, bestDir
}
type topicSessionFileSignature struct {
Name string `json:"name"`
Size int64 `json:"size"`
ModTime int64 `json:"mod_time"`
}
type topicSessionMatch struct {
path string
updatedAt time.Time
scope string
workspaceRoot string
}
type topicSessionDirIndex struct {
signature []topicSessionFileSignature
byTopic map[string][]topicSessionMatch
}
// sessionListCache caches ListSessions results per directory so that
// ListProjectTree (called on every sidebar render) does not re-read every
// session dir from disk. Invalidated by emitProjectTreeChanged — any create/
// delete/rename session bumps the project tree version.
type sessionListCacheEntry struct {
infos []agent.SessionInfo
titles map[string]string
}
type sessionListCache struct {
mu sync.Mutex
byDir map[string]sessionListCacheEntry
version atomic.Uint64
}
func (c *sessionListCache) get(dir string) ([]agent.SessionInfo, map[string]string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.byDir[dir]
if !ok {
return nil, nil, false
}
return e.infos, e.titles, true
}
func (c *sessionListCache) versionToken() uint64 {
return c.version.Load()
}
func (c *sessionListCache) put(dir string, infos []agent.SessionInfo, titles map[string]string, token uint64) {
if c.version.Load() != token {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if c.version.Load() != token {
return
}
c.byDir[dir] = sessionListCacheEntry{infos: infos, titles: titles}
}
func (c *sessionListCache) invalidate() {
c.version.Add(1)
c.mu.Lock()
c.byDir = map[string]sessionListCacheEntry{}
c.mu.Unlock()
}
var projectSessionCache = &sessionListCache{byDir: map[string]sessionListCacheEntry{}}
// mergeSessionInfos merges one directory's session listing into the maps used by
// ListProjectTree. The result collection loop calls it serially.
func mergeSessionInfos(dir string, infos []agent.SessionInfo, titles map[string]string, sessionInfos map[string]agent.SessionInfo, sessionTitles map[string]string, topicSummaries map[string]topicSummary) {
for _, info := range infos {
sessionKey := sessionRuntimeKey(info.Path)
if sessionKey != "" {
sessionInfos[sessionKey] = info
title := strings.TrimSpace(info.CustomTitle)
if title == "" {
title = titles[filepath.Base(info.Path)]
}
sessionTitles[sessionKey] = title
}
if strings.TrimSpace(info.TopicID) == "" {
continue
}
key := topicSummaryKey(info.Scope, info.WorkspaceRoot, info.TopicID)
summary := topicSummaries[key]
lastActivityAt := info.LastActivityAt.UnixMilli()
if sessionInfoIsAutomaticRecovery(info) {
// A covered conflict copy duplicates its parent, so its turns must not
// be added. Any branch with unique content keeps the topic visible.
if sessionInfoIsUnmodifiedRecoveryCopy(info, dir) {
summary.hasRecoveryOnly = true
} else {
summary.hasAdoptedRecovery = true
if info.Turns > summary.adoptedRecoveryTurns {
summary.adoptedRecoveryTurns = info.Turns
}
}
if lastActivityAt > summary.lastActivityAt {
summary.lastActivityAt = lastActivityAt
}
topicSummaries[key] = summary
continue
}
summary.hasNormalSession = true
summary.turns += info.Turns
if lastActivityAt > summary.lastActivityAt {
summary.lastActivityAt = lastActivityAt
}
topicSummaries[key] = summary
}
}
var topicSessionIndexCache = struct {
sync.Mutex
byDir map[string]topicSessionDirIndex
}{byDir: map[string]topicSessionDirIndex{}}
func topicSessionDirKey(dir string) string {
dir = strings.TrimSpace(dir)
if dir == "" {
return ""
}
if abs, err := filepath.Abs(dir); err == nil {
return abs
}
return dir
}
func topicSessionDirSnapshot(dir string) ([]topicSessionFileSignature, []string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, nil, err
}
signature := []topicSessionFileSignature{}
sessionNames := []string{}
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() {
continue
}
isSession := store.IsSessionTranscriptName(name)
isMeta := strings.HasSuffix(name, ".jsonl.meta")
if !isSession && !isMeta {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
signature = append(signature, topicSessionFileSignature{
Name: name,
Size: info.Size(),
ModTime: info.ModTime().UnixNano(),
})
if isSession {
sessionNames = append(sessionNames, name)
}
}
sort.Slice(signature, func(i, j int) bool {
return signature[i].Name < signature[j].Name
})
sort.Strings(sessionNames)
return signature, sessionNames, nil
}
func topicSessionSignaturesEqual(a, b []topicSessionFileSignature) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func topicSessionIndexForDir(dir string) (topicSessionDirIndex, error) {
key := topicSessionDirKey(dir)
if key == "" {
return topicSessionDirIndex{}, nil
}
signature, sessionNames, err := topicSessionDirSnapshot(key)
if err != nil {
if os.IsNotExist(err) {
return topicSessionDirIndex{}, nil
}
return topicSessionDirIndex{}, err
}
topicSessionIndexCache.Lock()
cached, ok := topicSessionIndexCache.byDir[key]
if ok && topicSessionSignaturesEqual(cached.signature, signature) {
topicSessionIndexCache.Unlock()
return cached, nil
}
topicSessionIndexCache.Unlock()
index := topicSessionDirIndex{
signature: signature,
byTopic: map[string][]topicSessionMatch{},
}
for _, name := range sessionNames {
path := filepath.Join(key, name)
meta, ok, err := agent.LoadBranchMeta(path)
if err != nil || !ok {
continue
}
topicID := strings.TrimSpace(meta.TopicID)
if topicID == "" {
continue
}
index.byTopic[topicID] = append(index.byTopic[topicID], topicSessionMatch{
path: path,
updatedAt: meta.UpdatedAt,
scope: meta.DefaultScope(),
workspaceRoot: meta.WorkspaceRoot,
})
}
topicSessionIndexCache.Lock()
topicSessionIndexCache.byDir[key] = index
topicSessionIndexCache.Unlock()
return index, nil
}
func topicSessionIndexHasContentTopic(index topicSessionDirIndex, topicID string) bool {
matches := index.byTopic[strings.TrimSpace(topicID)]
for _, match := range matches {
if sessionFileHasConversationContent(match.path) {
return true
}
}
return false
}
// topicSessionIndexHasForeignLeaseTopic reports whether any session file
// indexed under topicID is currently lease-held by a runtime other than this
// process. A blank topic can still be lease-held — its session lease keeper
// keeps a leftover blank tab's lease alive across a hide-to-tray close, and a
// stale-but-live holder blocks a genuinely new session from ever settling on
// this path. Reusing it anyway would make the "new" tab collide with that
// holder: every lease-gated switch (effort/model/token mode) would fail as if
// a foreign window owned it, and creating another "new" conversation would
// keep re-picking the same stuck topic (#6028, #6109).
func topicSessionIndexHasForeignLeaseTopic(index topicSessionDirIndex, topicID string) bool {
matches := index.byTopic[strings.TrimSpace(topicID)]
for _, match := range matches {
if agent.SessionLeaseHeldByOtherRuntime(match.path) {
return true
}
}
return false
}
func topicSessionMatches(dir, topicID string) []topicSessionMatch {
index, err := topicSessionIndexForDir(dir)
if err != nil {
return nil
}
matches := index.byTopic[strings.TrimSpace(topicID)]
if len(matches) == 0 {
return nil
}
out := make([]topicSessionMatch, 0, len(matches))
for _, match := range matches {
if agent.IsCleanupPending(match.path) {
continue
}
out = append(out, match)
}
if len(out) == 0 {
return nil
}
return out
}
func invalidateTopicSessionIndex(dir string) {
key := topicSessionDirKey(dir)
if key == "" {
return
}
topicSessionIndexCache.Lock()
delete(topicSessionIndexCache.byDir, key)
topicSessionIndexCache.Unlock()
}
func invalidateTopicSessionIndexForPath(path string) {
path = strings.TrimSpace(path)
if path == "" {
return
}
invalidateTopicSessionIndex(filepath.Dir(path))
}
// findTopicSession returns the most recently updated .jsonl file whose .meta
// carries the given topicID, using a directory-level sidecar index cache.
func findTopicSession(dir, topicID string) string {
if topicID == "" || dir == "" {
return ""
}
var bestPath string
var bestTime time.Time
for _, match := range topicSessionMatches(dir, topicID) {
if match.updatedAt.After(bestTime) {
bestTime = match.updatedAt
bestPath = match.path
}
}
return bestPath
}