项目文件夹

文件
2026-07-13 13:00:08 +08:00

8200 行
238 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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
}