8200 lines
238 KiB
Go
8200 lines
238 KiB
Go
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
|
||
}
|