diff --git a/.gitignore b/.gitignore index 162ad80f3..661b74028 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,6 @@ mem.prof # Wails desktop artifacts /desktop/build/* !/desktop/build/appicon.png -!/desktop/build/appicon.svg /desktop/desktop /desktop/frontend/dist/* !/desktop/frontend/dist/.gitkeep diff --git a/desktop/.gitignore b/desktop/.gitignore index 100ecee2c..d4f6c4e5f 100644 --- a/desktop/.gitignore +++ b/desktop/.gitignore @@ -1,8 +1,7 @@ # Wails build assets/output — mostly auto-generated (plists, manifests, compiled -# binary); ignore them but keep the committed designed app icon source and PNG. +# binary); ignore them but keep the committed designed app icon. /build/* !/build/appicon.png -!/build/appicon.svg # Commit only our customized per-user NSIS installer template. wails_tools.nsh and # everything else under build/ are regenerated by `wails build`, so stay ignored. diff --git a/desktop/README.md b/desktop/README.md index a90e63c18..b5ee3fd8b 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -220,5 +220,5 @@ includes conversations, API keys, file contents, or paths. Opt out any time: Settings > Updates > "Anonymous usage ping", or set `telemetry = false` under `[desktop]` in the global config. Dev builds -never ping. Crash and performance-pressure reports are separate and only -ever sent when the user clicks "Send report" on the diagnostic UI. +never ping. Crash reports are separate and only ever sent when the user +clicks "Send report" on the crash screen. diff --git a/desktop/app.go b/desktop/app.go index e32708287..3817770d9 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -18,7 +18,6 @@ import ( "path/filepath" goruntime "runtime" "sort" - "strconv" "strings" "sync" "sync/atomic" @@ -31,17 +30,14 @@ import ( "reasonix/internal/billing" "reasonix/internal/boot" "reasonix/internal/builtinmcp" - "reasonix/internal/codegraph" "reasonix/internal/config" "reasonix/internal/control" "reasonix/internal/event" - "reasonix/internal/evidence" "reasonix/internal/fileref" fileenc "reasonix/internal/fileutil/encoding" "reasonix/internal/i18n" "reasonix/internal/mcpdiag" "reasonix/internal/memory" - "reasonix/internal/netclient" "reasonix/internal/plugin" "reasonix/internal/provider" "reasonix/internal/skill" @@ -58,26 +54,6 @@ const eventChannel = "agent:event" // reopen behavior remains predictable on every platform. const singleInstanceID = "com.reasonix.desktop" -var updateBuiltInCodegraph = codegraph.UpdateWithClient - -// PromptHistoryEntry is one user prompt extracted from a session JSONL file. -// The frontend uses these for ↑/↓ prompt-history navigation. -type PromptHistoryEntry struct { - Text string `json:"text"` - At int64 `json:"at"` // unix ms - SessionPath string `json:"sessionPath"` - Turn int `json:"turn"` -} - -// PromptHistoryResult is returned as one Wails value. It carries one loaded tape -// segment plus the cursor needed to keep walking toward older prompts. -type PromptHistoryResult struct { - Entries []PromptHistoryEntry `json:"entries"` - Nonce string `json:"nonce"` - OlderCursor string `json:"olderCursor,omitempty"` - HasOlder bool `json:"hasOlder"` -} - // App is the Wails-bound application object: the desktop frontend's command // surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS // bindings. The app manages multiple WorkspaceTabs — each with its own controller @@ -89,16 +65,11 @@ type App struct { // mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read // from bound methods. All bound methods that touch a controller use activeCtrl(). - mu sync.RWMutex - tabs map[string]*WorkspaceTab - tabOrder []string - activeTabID string - readyHook func() - projectTreeChangedHook func() - - // detachedSessions keeps live session runtimes whose visible tab was closed. - // It is process-local by design: shutdown closes every detached controller. - detachedSessions map[string]*WorkspaceTab + mu sync.RWMutex + tabs map[string]*WorkspaceTab + tabOrder []string + activeTabID string + readyHook func() // tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path. tabsSaveMu sync.Mutex @@ -114,18 +85,7 @@ type App struct { botInstalls map[string]*botInstallSession botRuntime *desktopBotRuntime - builtInMCPUpdatesMu sync.RWMutex - builtInMCPUpdates map[string]BuiltInMCPUpdateStatus - metrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics - - runtimeEvents asyncRuntimeEmitter - - // promptHistoryTape is a lazy, cursor-addressed view of prompt history. It - // stores session order and per-session parsed entries only after that session is - // reached by ↑ navigation. See ScanPromptHistory. - promptHistoryMu sync.Mutex - promptHistoryTape *promptHistoryTape } // mediaTokenEntry holds metadata for a workspace media file served via temporary URL. @@ -294,13 +254,7 @@ func (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler { // NewApp constructs the bound object. Tabs are restored in startup from the // last session's desktop-tabs.json. func NewApp() *App { - return &App{ - tabs: map[string]*WorkspaceTab{}, - detachedSessions: map[string]*WorkspaceTab{}, - mediaTokens: newMediaTokenStore(), - botInstalls: map[string]*botInstallSession{}, - botRuntime: newDesktopBotRuntime(), - } + return &App{tabs: map[string]*WorkspaceTab{}, mediaTokens: newMediaTokenStore(), botInstalls: map[string]*botInstallSession{}, botRuntime: newDesktopBotRuntime()} } func (a *App) bootContext() context.Context { @@ -330,7 +284,6 @@ func (a *App) startup(ctx context.Context) { go a.restoreOrBuildTabs() a.goSafe("refreshBotRuntime", a.refreshBotRuntime) - a.goSafe("checkBuiltInMCPUpdates", a.checkBuiltInMCPUpdates) a.goSafe("sendStartupPing", a.sendStartupPing) a.goSafe("flushMetrics", a.flushMetrics) a.goSafe("flushPendingCrash", a.flushPendingCrash) @@ -516,7 +469,10 @@ func (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *Wo func (a *App) snapshotAllTabs() { a.mu.RLock() - tabs := a.runtimeTabsLocked() + tabs := make([]*WorkspaceTab, 0, len(a.tabs)) + for _, t := range a.tabs { + tabs = append(tabs, t) + } a.mu.RUnlock() for _, t := range tabs { if t.Ctrl != nil { @@ -534,7 +490,10 @@ func (a *App) shutdown(context.Context) { a.saveWindowStateSync() a.mu.RLock() - tabs := a.runtimeTabsLocked() + tabs := make([]*WorkspaceTab, 0, len(a.tabs)) + for _, t := range a.tabs { + tabs = append(tabs, t) + } a.mu.RUnlock() for _, t := range tabs { if t.Ctrl != nil { @@ -697,7 +656,10 @@ func (a *App) ApproveTab(tabID, id string, allow, session, persist bool) { // "waiting" status with no way to answer — and no way to stop. func (a *App) ReplayPendingPrompts() { a.mu.RLock() - tabs := a.runtimeTabsLocked() + tabs := make([]*WorkspaceTab, 0, len(a.tabs)) + for _, t := range a.tabs { + tabs = append(tabs, t) + } a.mu.RUnlock() for _, t := range tabs { if t.Ctrl != nil { @@ -903,7 +865,6 @@ func (a *App) NewSession() error { return err } a.persistTabSessionPath(tab, ctrl.SessionPath()) - a.invalidatePromptHistoryCache() return nil } @@ -925,103 +886,13 @@ func (a *App) ClearSession() error { if ctrl == nil { return workspaceNotReadyErr(tab) } - if controllerHasActiveRuntimeWork(ctrl) { - return a.clearActiveSessionRuntime(tab, ctrl) - } if err := ctrl.ClearSession(); err != nil { return err } a.persistTabSessionPath(tab, ctrl.SessionPath()) - a.invalidatePromptHistoryCache() return nil } -func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl *control.Controller) error { - if tab == nil || oldCtrl == nil { - return fmt.Errorf("workspace is still starting") - } - oldPath := oldCtrl.SessionPath() - oldSink := tab.sink - if oldSink != nil { - oldSink.tabID = detachedRuntimeTabID(oldPath) - oldSink.ctx = nil - } - if oldCtrl.Running() { - oldCtrl.Cancel() - if err := waitControllerStopped(oldCtrl); err != nil { - return err - } - } - destroy := oldCtrl.BeginDestroySession(oldPath) - waitDestroyHandles([]control.SessionDestroyHandle{destroy}) - - newSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx} - newCtrl, err := boot.Build(a.bootContext(), boot.Options{ - Model: tab.model, - RequireKey: false, - Sink: newSink, - WorkspaceRoot: tab.WorkspaceRoot, - SessionDir: tabSessionDir(tab), - EffortOverride: cloneStringPtr(tab.effort), - TokenMode: currentTabTokenMode(tab), - }) - if err != nil { - finishDestroyHandles([]control.SessionDestroyHandle{destroy}) - if oldSink != nil { - oldSink.tabID = tab.ID - oldSink.ctx = a.ctx - } - return err - } - if err := removeDesktopSessionArtifacts(oldPath); err != nil { - finishDestroyHandles([]control.SessionDestroyHandle{destroy}) - newCtrl.Close() - return err - } - finishDestroyHandles([]control.SessionDestroyHandle{destroy}) - a.bindControllerDisplayRecorder(newCtrl) - newCtrl.EnableInteractiveApproval() - applyTabModeToController(newCtrl, tab.mode) - applyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode) - newCtrl.SetGoal(tab.goal) - path := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label()) - newCtrl.SetSessionPath(path) - - a.mu.Lock() - if current := a.tabs[tab.ID]; current == tab { - tab.Ctrl = newCtrl - tab.sink = newSink - tab.SessionPath = path - tab.Label = newCtrl.Label() - tab.Ready = true - tab.StartupErr = "" - a.saveTabsLocked() - } - a.mu.Unlock() - oldCtrl.Close() - a.emitProjectTreeChanged() - return nil -} - -func removeDesktopSessionArtifacts(path string) error { - if strings.TrimSpace(path) == "" { - return nil - } - paths := []string{path, agent.BranchMetaPath(path)} - if strings.HasSuffix(path, ".jsonl") { - paths = append(paths, strings.TrimSuffix(path, ".jsonl")+".ckpt") - } - for _, p := range paths { - if strings.TrimSpace(p) == "" { - continue - } - if err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) { - return err - } - } - return agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)) -} - // CheckpointMeta summarises one rewind point (a user turn) for the desktop. type CheckpointMeta struct { Turn int `json:"turn"` @@ -1162,7 +1033,6 @@ func (a *App) Fork(turn int) (TabMeta, error) { if err := agent.SaveBranchMeta(newPath, m); err != nil { return TabMeta{}, err } - invalidateTopicSessionIndexForPath(newPath) a.mu.Lock() tabID := a.newUniqueTabIDLocked() @@ -1357,274 +1227,62 @@ func sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, } } -// DeleteSession moves a saved session to the local trash. If the session still -// has an in-process runtime, the runtime is cancelled and removed first so -// autosave cannot recreate or append to the deleted file later. +// DeleteSession moves a saved session to the local trash. It refuses any open +// session because tab auto-save would recreate or append to the file later. func (a *App) DeleteSession(path string) error { dir := a.activeSessionDir() sessionPath, key, err := validateSessionPath(dir, path) if err != nil { return err } - if err := validateSessionTrashTarget(dir, sessionPath, key); err != nil { - return err - } - removed, fallback := a.removeSessionRuntimeBindings(dir, sessionPath) - if err := prepareRemovedSessionRuntimes(removed); err != nil { - a.closeRemovedSessionRuntimes(removed) - return err + if _, ok := a.openSessionPaths(dir)[sessionPath]; ok { + return errActiveSession } + var destroys []control.SessionDestroyHandle err = trashSessionArtifactsBeforeMove(dir, sessionPath, key, func() { - destroys := a.destroyHandlesForSession(dir, sessionPath, removed) - waitDestroyHandles(destroys) - finishDestroyHandles(destroys) + destroys = a.beginDestroySessionJobs(dir, sessionPath) }) if err != nil { - a.closeRemovedSessionRuntimes(removed) + if len(destroys) > 0 { + go runDestroyHandles(destroys) + } return err } - a.closeRemovedSessionRuntimes(removed) - if fallback.needs { - if err := a.openFallbackRuntime(fallback); err != nil { - return err - } + if len(destroys) > 0 { + go runDestroyHandles(destroys) } a.emitProjectTreeChanged() - a.invalidatePromptHistoryCache() return nil } -type removedSessionRuntime struct { - tab *WorkspaceTab - ctrl *control.Controller - sink *tabEventSink - sessionDir string - sessionPath string - scope string - workspaceRoot string - topicID string -} - -type fallbackRuntimeTarget struct { - needs bool - scope string - workspaceRoot string - topicID string -} - -func (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) { - var removed []removedSessionRuntime - var fallback fallbackRuntimeTarget - - a.mu.Lock() - for id, tab := range a.tabs { - if !tabMatchesSession(tab, dir, sessionPath) { - continue - } - if len(removed) == 0 { - fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID} - } - removed = append(removed, removedRuntimeFromTab(tab, dir, sessionPath)) - delete(a.tabs, id) - a.removeTabOrderLocked(id) - if a.activeTabID == id { - a.activeTabID = "" - } - } - for key, tab := range a.detachedSessions { - if !tabMatchesSession(tab, dir, sessionPath) { - continue - } - if len(removed) == 0 { - fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID} - } - removed = append(removed, removedRuntimeFromTab(tab, dir, sessionPath)) - delete(a.detachedSessions, key) - } - if a.activeTabID == "" && len(a.tabOrder) > 0 { - a.activeTabID = a.tabOrder[0] - } - fallback.needs = len(removed) > 0 && len(a.tabs) == 0 - a.saveTabsLocked() - a.mu.Unlock() - - return removed, fallback -} - -func (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) { - var removed []removedSessionRuntime - var fallback fallbackRuntimeTarget - - a.mu.Lock() - for id, tab := range a.tabs { - if tab == nil || tab.TopicID != topicID { - continue - } - sessionDir := tabSessionDir(tab) - sessionPath := canonicalTabSessionPath(tab.currentSessionPath()) - if len(removed) == 0 { - fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot} - } - removed = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath)) - delete(a.tabs, id) - a.removeTabOrderLocked(id) - if a.activeTabID == id { - a.activeTabID = "" - } - } - for key, tab := range a.detachedSessions { - if tab == nil || tab.TopicID != topicID { - continue - } - sessionDir := tabSessionDir(tab) - sessionPath := canonicalTabSessionPath(tab.currentSessionPath()) - if len(removed) == 0 { - fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot} - } - removed = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath)) - delete(a.detachedSessions, key) - } - if a.activeTabID == "" && len(a.tabOrder) > 0 { - a.activeTabID = a.tabOrder[0] - } - fallback.needs = len(removed) > 0 && len(a.tabs) == 0 - a.saveTabsLocked() - a.mu.Unlock() - - return removed, fallback -} - -func removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime { - return removedSessionRuntime{ - tab: tab, - ctrl: tab.Ctrl, - sink: tab.sink, - sessionDir: dir, - sessionPath: sessionPath, - scope: tab.Scope, - workspaceRoot: tab.WorkspaceRoot, - topicID: tab.TopicID, - } -} - -func tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool { - if tab == nil || tabSessionDir(tab) != dir { - return false - } - currentPath, _, err := validateSessionPath(dir, tab.currentSessionPath()) - return err == nil && currentPath == sessionPath -} - -func prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error { - for _, item := range removed { - if item.sink != nil { - item.sink.ctx = nil - } - if item.ctrl == nil { - continue - } - if item.ctrl.Running() { - item.ctrl.Cancel() - if err := waitControllerStopped(item.ctrl); err != nil { - return err - } - } - if err := item.ctrl.Snapshot(); err != nil { - return err - } - } - return nil -} - -func waitControllerStopped(ctrl *control.Controller) error { - deadline := time.Now().Add(5 * time.Second) - for ctrl.Running() { - if time.Now().After(deadline) { - return fmt.Errorf("timed out waiting for cancelled session work to stop") - } - time.Sleep(10 * time.Millisecond) - } - return nil -} - -func (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle { - destroys := a.beginDestroySessionJobs(dir, sessionPath) - for _, item := range removed { - if item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath { +func (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle { + a.mu.RLock() + defer a.mu.RUnlock() + var destroys []control.SessionDestroyHandle + for _, tab := range a.tabs { + if tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir { continue } - destroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath)) + destroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath)) } return destroys } -func waitDestroyHandles(destroys []control.SessionDestroyHandle) { +func runDestroyHandles(destroys []control.SessionDestroyHandle) { for _, destroy := range destroys { if destroy.Wait != nil { destroy.Wait() } - } -} - -func finishDestroyHandles(destroys []control.SessionDestroyHandle) { - for _, destroy := range destroys { if destroy.Finish != nil { destroy.Finish() } } } -func (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) { - seen := map[*control.Controller]bool{} - for _, item := range removed { - if item.ctrl == nil || seen[item.ctrl] { - continue - } - seen[item.ctrl] = true - item.ctrl.Close() - } -} - -func (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error { - scope := target.scope - root := target.workspaceRoot - topicID := strings.TrimSpace(target.topicID) - if scope == "global" { - root = "" - } - if topicID == "" { - topic, err := a.CreateTopic(scope, root, "") - if err != nil { - return err - } - topicID = topic.ID - } - var err error - if scope == "global" { - _, err = a.OpenGlobalTab(topicID) - } else { - _, err = a.OpenProjectTab(root, topicID) - } - return err -} - -func (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle { - a.mu.RLock() - defer a.mu.RUnlock() - var destroys []control.SessionDestroyHandle - for _, tab := range a.runtimeTabsLocked() { - if tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir { - continue - } - destroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath)) - } - return destroys -} - func (a *App) openSessionPaths(dir string) map[string]struct{} { a.mu.RLock() - paths := make([]string, 0, len(a.tabs)+len(a.detachedSessions)) - for _, tab := range a.runtimeTabsLocked() { + paths := make([]string, 0, len(a.tabs)) + for _, tab := range a.tabs { if tab != nil { paths = append(paths, tab.currentSessionPath()) } @@ -1676,14 +1334,13 @@ func (a *App) RestoreSession(path string) error { return err } a.emitProjectTreeChanged() - a.invalidatePromptHistoryCache() return nil } func (a *App) sessionDestroying(dir, sessionPath string) bool { a.mu.RLock() defer a.mu.RUnlock() - for _, tab := range a.runtimeTabsLocked() { + for _, tab := range a.tabs { if tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir { continue } @@ -1701,21 +1358,13 @@ func (a *App) PurgeTrashedSession(path string) error { if err != nil { return err } - if err := purgeTrashedSessionFile(dir, path); err != nil { - return err - } - a.invalidatePromptHistoryCache() - return nil + return purgeTrashedSessionFile(dir, path) } // RenameSession sets a custom display name for a session (empty clears it back to // the preview). It only affects the history panel; the file on disk is unchanged. func (a *App) RenameSession(path, title string) error { - if err := setSessionTitle(a.activeSessionDir(), path, title); err != nil { - return err - } - a.invalidatePromptHistoryCache() - return nil + return setSessionTitle(a.activeSessionDir(), path, title) } // ResumeSession snapshots the current conversation, then loads the session at @@ -1726,9 +1375,10 @@ func (a *App) ResumeSession(path string) ([]HistoryMessage, error) { return a.ResumeSessionForTab("", path) } -// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session -// path is a runtime identity, so changing to a different path must replace the -// tab's controller binding rather than mutating the current controller in place. +// ResumeSessionForTab is the tab-scoped form of ResumeSession. History rows +// carry scope/workspace/topic metadata, so callers that opened or selected a +// matching tab should resume on that exact controller instead of whichever tab is +// active by the time the async call reaches the backend. func (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) { tab := a.tabByID(tabID) if tab == nil || tab.Ctrl == nil { @@ -1739,81 +1389,14 @@ func (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) if err != nil { return nil, err } - if _, err := agent.LoadSession(sessionPath); err != nil { - return nil, err - } - if sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) { - return a.HistoryForTab(tabID), nil - } - - if err := a.rebindTabToSessionPath(tab, sessionPath); err != nil { + loaded, err := agent.LoadSession(sessionPath) + if err != nil { return nil, err } - return a.HistoryForTab(tab.ID), nil -} - -func (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error { - if tab == nil { - return fmt.Errorf("tab is not ready") - } - sessionPath = canonicalTabSessionPath(sessionPath) - if sessionPath == "" { - return fmt.Errorf("session path is required") - } - if _, err := agent.LoadSession(sessionPath); err != nil { - return err - } - if sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) { - return nil - } - - ctrl := tab.Ctrl - if ctrl == nil { - a.mu.Lock() - tab.SessionPath = sessionPath - tab.Ready = false - tab.StartupErr = "" - tab.ActivityStatus = "" - tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx} - a.saveTabsLocked() - a.mu.Unlock() - a.buildTabController(tab) - if tab.Ctrl == nil { - if tab.StartupErr != "" { - return fmt.Errorf("resume session: %s", tab.StartupErr) - } - return fmt.Errorf("resume session: controller was not built") - } - return nil - } - - _ = ctrl.Snapshot() // persist the current session before switching the view. - if tab.hasActiveRuntimeWork() { - if !a.detachRuntimeForReplacement(tab) { - return fmt.Errorf("current session runtime cannot be detached") - } - } else { - ctrl.Close() - } - - a.mu.Lock() - tab.Ctrl = nil - tab.SessionPath = sessionPath - tab.Ready = false - tab.StartupErr = "" - tab.ActivityStatus = "" - tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx} - a.saveTabsLocked() - a.mu.Unlock() - - a.buildTabController(tab) - if tab.Ctrl == nil { - if tab.StartupErr != "" { - return fmt.Errorf("resume session: %s", tab.StartupErr) - } - return fmt.Errorf("resume session: controller was not built") - } - return nil + _ = ctrl.Snapshot() // persist the current session before switching away + ctrl.Resume(loaded, sessionPath) + a.rememberTabSessionPath(tab, sessionPath) + return a.HistoryForTab(tabID), nil } // PreviewSession reads a saved session for display only. It does not snapshot or @@ -1826,465 +1409,6 @@ func (a *App) PreviewSession(path string) ([]HistoryMessage, error) { return previewSessionMessages(sessionDir, sessionPath) } -// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next -// ScanPromptHistory call rebuilds session order and reloads sessions on demand. -// Called from every session-mutating path: NewSession, ClearSession, -// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession. -func (a *App) invalidatePromptHistoryCache() { - a.promptHistoryMu.Lock() - a.promptHistoryTape = nil - a.promptHistoryMu.Unlock() -} - -const ( - promptHistoryPageLimit = 50 - promptHistoryMaxPageLimit = 200 -) - -type promptHistoryRequest struct { - Nonce string `json:"nonce,omitempty"` - Cursor string `json:"cursor,omitempty"` - Limit int `json:"limit,omitempty"` - legacy bool -} - -type promptHistoryCursor struct { - Nonce string `json:"n"` - Session int `json:"s"` - Offset int `json:"o"` -} - -type promptHistoryTape struct { - nonce string - dir string - currentPath string - displays sessionDisplayMap - sessions []promptHistorySessionFile - loaded map[string][]PromptHistoryEntry -} - -// ScanPromptHistory returns the next prompt-history tape segment. The request is -// a JSON string so the Wails binding stays one-argument while the protocol can -// carry a cursor and page limit. Older clients may still pass a bare nonce; that -// path keeps the old cache-hit behavior. -func (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) { - req := parsePromptHistoryRequest(rawRequest) - dir := a.activeSessionDir() - sessionPath := a.activeSessionPath(dir) - - a.promptHistoryMu.Lock() - tape, err := a.promptHistoryTapeForLocked(dir, sessionPath) - if err != nil { - a.promptHistoryMu.Unlock() - return PromptHistoryResult{}, err - } - if req.legacy && req.Nonce != "" && req.Nonce == tape.nonce { - a.promptHistoryMu.Unlock() - return PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil - } - result := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit)) - a.promptHistoryMu.Unlock() - return result, nil -} - -func parsePromptHistoryRequest(raw string) promptHistoryRequest { - raw = strings.TrimSpace(raw) - if raw == "" { - return promptHistoryRequest{} - } - if strings.HasPrefix(raw, "{") { - var req promptHistoryRequest - if err := json.Unmarshal([]byte(raw), &req); err == nil { - return req - } - } - return promptHistoryRequest{Nonce: raw, legacy: true} -} - -func promptHistoryLimit(limit int) int { - if limit <= 0 { - return promptHistoryPageLimit - } - if limit > promptHistoryMaxPageLimit { - return promptHistoryMaxPageLimit - } - return limit -} - -func (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) { - currentPath := "" - if path, _, err := validateSessionPath(dir, sessionPath); err == nil { - currentPath = path - } - if a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath { - return a.promptHistoryTape, nil - } - tape, err := newPromptHistoryTape(dir, currentPath) - if err != nil { - return nil, err - } - a.promptHistoryTape = tape - return tape, nil -} - -func (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) { - tape, err := newPromptHistoryTape(dir, "") - if err != nil { - return nil, err - } - return tape.readAll(), nil -} - -func newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) { - tape := &promptHistoryTape{ - nonce: fmt.Sprintf("%d", time.Now().UnixNano()), - dir: dir, - currentPath: currentPath, - displays: loadSessionDisplays(dir), - loaded: map[string][]PromptHistoryEntry{}, - } - sessions, err := promptHistorySessionFiles(dir) - if err != nil { - return nil, err - } - if currentPath != "" { - currentPath = filepath.Clean(currentPath) - currentSession := promptHistorySessionFile{} - currentIndex := -1 - for i, session := range sessions { - if filepath.Clean(session.path) == currentPath { - currentSession = session - currentIndex = i - break - } - } - if currentIndex >= 0 { - sessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...) - } else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() { - sessions = append([]promptHistorySessionFile{{ - path: currentPath, - }}, sessions...) - } - } - tape.sessions = sessions - return tape, nil -} - -func (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult { - c := promptHistoryCursor{Nonce: t.nonce} - if decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce { - c = decoded - } - if c.Session < 0 { - c.Session = 0 - } - if c.Offset < 0 { - c.Offset = 0 - } - - out := make([]PromptHistoryEntry, 0, limit) - sessionIndex := c.Session - offset := c.Offset - for sessionIndex < len(t.sessions) && len(out) < limit { - entries, err := t.entriesForSession(sessionIndex) - if err != nil || offset >= len(entries) { - sessionIndex++ - offset = 0 - continue - } - - end := min(len(entries), offset+limit-len(out)) - out = append(out, entries[offset:end]...) - offset = end - if offset >= len(entries) && len(out) < limit { - sessionIndex++ - offset = 0 - } - } - - if sessionIndex < len(t.sessions) { - if entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) { - sessionIndex++ - offset = 0 - } - } - hasOlder := sessionIndex < len(t.sessions) - olderCursor := "" - if hasOlder { - olderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset}) - } - return PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder} -} - -func (t *promptHistoryTape) readAll() []PromptHistoryEntry { - out := []PromptHistoryEntry{} - cursor := "" - for { - page := t.readOlder(cursor, promptHistoryMaxPageLimit) - out = append(out, page.Entries...) - if !page.HasOlder || page.OlderCursor == "" { - return out - } - cursor = page.OlderCursor - } -} - -func (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) { - if index < 0 || index >= len(t.sessions) { - return nil, nil - } - path := t.sessions[index].path - if entries, ok := t.loaded[path]; ok { - return entries, nil - } - info, err := os.Stat(path) - if err != nil { - t.loaded[path] = nil - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - entries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path)) - if err != nil { - t.loaded[path] = nil - return nil, err - } - t.loaded[path] = entries - return entries, nil -} - -func encodePromptHistoryCursor(cursor promptHistoryCursor) string { - b, err := json.Marshal(cursor) - if err != nil { - return "" - } - return base64.RawURLEncoding.EncodeToString(b) -} - -func decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) { - if strings.TrimSpace(value) == "" { - return promptHistoryCursor{}, false - } - b, err := base64.RawURLEncoding.DecodeString(value) - if err != nil { - return promptHistoryCursor{}, false - } - var cursor promptHistoryCursor - if err := json.Unmarshal(b, &cursor); err != nil { - return promptHistoryCursor{}, false - } - return cursor, true -} - -func scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) { - entries, err := collectPromptHistoryEntries(path, info, resolveUserContent) - if err != nil { - return nil, err - } - sortPromptHistoryNewestFirst(entries) - return entries, nil -} - -type promptHistorySessionFile struct { - path string -} - -func promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) { - infos, err := agent.ListSessionOrder(dir) - if err != nil { - return nil, err - } - sessions := make([]promptHistorySessionFile, 0, len(infos)) - for _, info := range infos { - sessions = append(sessions, promptHistorySessionFile{path: info.Path}) - } - return sessions, nil -} - -func promptHistoryEntryNewer(a, b PromptHistoryEntry) bool { - if a.At != b.At { - return a.At > b.At - } - if a.SessionPath != b.SessionPath { - return a.SessionPath > b.SessionPath - } - return a.Turn > b.Turn -} - -func sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) { - sort.Slice(entries, func(i, j int) bool { - return promptHistoryEntryNewer(entries[i], entries[j]) - }) -} - -func collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) { - var out []PromptHistoryEntry - err := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) { - out = append(out, entry) - }) - return out, err -} - -func collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error { - f, err := os.Open(path) - if err != nil { - return err - } - defer f.Close() - - fallbackAt := promptHistoryFallbackMillis(path, info) - - dec := json.NewDecoder(f) - turn := 0 - for { - var rec previewEventRecord - if err := dec.Decode(&rec); err != nil { - if errors.Is(err, io.EOF) { - break - } - return nil // partial results are better than none - } - // Format compatibility: - // 1) Legacy event format: {"kind":"user.message","text":"..."} - // 2) Early event format: {"type":"user.message","text":"..."} - // 3) Current provider.Message format: {"role":"user","content":"..."} - text := "" - kindOrType := strings.TrimSpace(rec.Kind) - if kindOrType == "" { - kindOrType = strings.TrimSpace(rec.Type) - } - if kindOrType == "user.message" { - text = strings.TrimSpace(rec.Text) - } else if strings.TrimSpace(rec.Role) == "user" { - text = strings.TrimSpace(rec.Content) - } - if text != "" { - text = resolveUserContent(text) - text = strings.TrimSpace(text) - if text == "" { - continue - } - if control.IsSyntheticUserMessage(text) { - continue - } - at := fallbackAt - if eventAt, ok := promptHistoryEventMillis(rec); ok { - at = eventAt - } - entry := PromptHistoryEntry{ - Text: text, - At: at, - SessionPath: path, - Turn: turn, - } - emit(entry) - turn++ - } - } - return nil -} - -func promptHistoryFallbackMillis(path string, info os.FileInfo) int64 { - if meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() { - return meta.UpdatedAt.UnixMilli() - } - if info != nil { - return info.ModTime().UnixMilli() - } - return 0 -} - -func promptHistoryEventMillis(rec previewEventRecord) (int64, bool) { - for _, raw := range []json.RawMessage{ - rec.Time, - rec.Timestamp, - rec.CreatedAt, - rec.CreatedAtSnake, - rec.UpdatedAt, - rec.UpdatedAtSnake, - } { - if at, ok := parseJSONTimestampMillis(raw); ok { - return at, true - } - } - return 0, false -} - -func parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) { - if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { - return 0, false - } - - var s string - if err := json.Unmarshal(raw, &s); err == nil { - s = strings.TrimSpace(s) - if s == "" { - return 0, false - } - if n, err := strconv.ParseInt(s, 10, 64); err == nil { - return normalizeTimestampMillis(n) - } - if f, err := strconv.ParseFloat(s, 64); err == nil { - return normalizeTimestampMillisFloat(f) - } - if t, err := time.Parse(time.RFC3339Nano, s); err == nil { - return t.UnixMilli(), true - } - return 0, false - } - - dec := json.NewDecoder(bytes.NewReader(raw)) - dec.UseNumber() - var n json.Number - if err := dec.Decode(&n); err != nil { - return 0, false - } - if i, err := strconv.ParseInt(n.String(), 10, 64); err == nil { - return normalizeTimestampMillis(i) - } - if f, err := strconv.ParseFloat(n.String(), 64); err == nil { - return normalizeTimestampMillisFloat(f) - } - return 0, false -} - -func normalizeTimestampMillis(v int64) (int64, bool) { - if v <= 0 { - return 0, false - } - switch { - case v >= 1_000_000_000_000_000_000: - return v / 1_000_000, true // nanoseconds - case v >= 1_000_000_000_000_000: - return v / 1_000, true // microseconds - case v >= 100_000_000_000: - return v, true // milliseconds - case v >= 1_000_000_000: - return v * 1_000, true // seconds - default: - return 0, false - } -} - -func normalizeTimestampMillisFloat(v float64) (int64, bool) { - if v <= 0 { - return 0, false - } - switch { - case v >= 1_000_000_000_000_000_000: - return int64(v / 1_000_000), true - case v >= 1_000_000_000_000_000: - return int64(v / 1_000), true - case v >= 100_000_000_000: - return int64(v), true - case v >= 1_000_000_000: - return int64(v * 1_000), true - default: - return 0, false - } -} - // PickWorkspace opens a folder chooser and, on a pick, opens a new project tab // scoped to that folder. Returns the chosen path ("" if cancelled). func (a *App) PickWorkspace() (string, error) { @@ -2502,7 +1626,6 @@ func (a *App) HistoryForTab(tabID string) []HistoryMessage { func historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage { out := make([]HistoryMessage, 0, len(msgs)) - replayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs) for _, m := range msgs { content := m.Content if m.Role == provider.RoleUser { @@ -2529,13 +1652,7 @@ func historyMessages(msgs []provider.Message, resolveUserContent func(string) st if m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 { hm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls)) for i, tc := range m.ToolCalls { - args := tc.Arguments - if tc.Name == "todo_write" { - if replayed, ok := replayedTodoArgs[tc.ID]; ok { - args = replayed - } - } - hm.ToolCalls[i] = HistoryToolCall{ID: tc.ID, Name: tc.Name, Arguments: args} + hm.ToolCalls[i] = HistoryToolCall{ID: tc.ID, Name: tc.Name, Arguments: tc.Arguments} } } if m.Role == provider.RoleTool { @@ -2547,98 +1664,6 @@ func historyMessages(msgs []provider.Message, resolveUserContent func(string) st return out } -func historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string { - successful := successfulHistoryToolCallIDs(msgs) - out := map[string]string{} - var todos []evidence.TodoItem - latestTodoID := "" - for _, m := range msgs { - for _, tc := range m.ToolCalls { - if tc.ID == "" || !successful[tc.ID] { - continue - } - switch tc.Name { - case "todo_write": - rec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true) - if len(rec.Todos) == 0 { - continue - } - todos = append([]evidence.TodoItem(nil), rec.Todos...) - latestTodoID = tc.ID - if args, ok := todoArgsJSON(todos); ok { - out[latestTodoID] = args - } - case "complete_step": - if latestTodoID == "" || len(todos) == 0 { - continue - } - rec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true) - match, ok := evidence.MatchStep(rec.Step, todos) - if !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == "completed" { - continue - } - todos[match.Index-1].Status = "completed" - promoteNextHistoryTodo(todos) - if args, ok := todoArgsJSON(todos); ok { - out[latestTodoID] = args - } - } - } - } - return out -} - -func successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool { - successful := map[string]bool{} - for _, msg := range msgs { - if msg.Role != provider.RoleTool || msg.ToolCallID == "" { - continue - } - if !historyToolResultFailed(msg.Content) { - successful[msg.ToolCallID] = true - } - } - return successful -} - -func historyToolResultFailed(content string) bool { - content = strings.TrimSpace(content) - return strings.HasPrefix(content, "error:") || - strings.HasPrefix(content, "blocked:") || - strings.HasPrefix(content, "Error:") || - strings.HasPrefix(content, "[error") -} - -func todoArgsJSON(todos []evidence.TodoItem) (string, bool) { - b, err := json.Marshal(map[string]any{"todos": todos}) - if err != nil { - return "", false - } - return string(b), true -} - -func promoteNextHistoryTodo(todos []evidence.TodoItem) { - for _, todo := range todos { - if todoStatusForHistory(todo.Status) == "in_progress" { - return - } - } - for i := range todos { - if todoStatusForHistory(todos[i].Status) == "pending" { - todos[i].Status = "in_progress" - return - } - } -} - -func todoStatusForHistory(status string) string { - status = strings.TrimSpace(status) - if status == "" { - return "pending" - } - return status -} - func previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) { sessionPath, _, err := validateSessionPath(sessionDir, path) if err != nil { @@ -2658,12 +1683,6 @@ type previewEventRecord struct { Kind string `json:"kind"` Type string `json:"type"` Role string `json:"role"` - Time json.RawMessage `json:"time"` - Timestamp json.RawMessage `json:"timestamp"` - CreatedAt json.RawMessage `json:"createdAt"` - CreatedAtSnake json.RawMessage `json:"created_at"` - UpdatedAt json.RawMessage `json:"updatedAt"` - UpdatedAtSnake json.RawMessage `json:"updated_at"` Text string `json:"text"` Content string `json:"content"` Reasoning string `json:"reasoning"` @@ -3182,12 +2201,6 @@ type ToolView struct { Description string `json:"description"` } -type BuiltInMCPUpdateResult struct { - Name string `json:"name"` - Version string `json:"version"` - Path string `json:"path"` -} - // SkillView is one discoverable skill for the drawer. type SkillView struct { Name string `json:"name"` @@ -3404,11 +2417,6 @@ func withCodegraphConfig(v ServerView, c config.CodegraphConfig) ServerView { v.Configured = true v.AutoStart = c.ShouldAutoStart() v.Tier = c.ResolvedTier() - v.Command = strings.TrimSpace(c.Path) - if v.Command == "" { - v.Command = "codegraph" - } - v.Args = []string{"serve", "--mcp"} v.AuthStatus = mcpdiag.AuthNone return v } @@ -3803,57 +2811,6 @@ func (a *App) ReconnectMCPServer(name string) error { return nil } -// UpdateBuiltInMCPServer downloads and activates the latest runtime for a -// bundled MCP. It is intentionally explicit because newer MCP releases can -// change tool schemas and prompt-cache shape. -func (a *App) UpdateBuiltInMCPServer(name string) (BuiltInMCPUpdateResult, error) { - name = strings.TrimSpace(name) - if name != "codegraph" { - return BuiltInMCPUpdateResult{}, fmt.Errorf("%s is not an updatable built-in MCP server", name) - } - tab := a.activeTab() - if tab == nil || tab.Ctrl == nil { - return BuiltInMCPUpdateResult{}, fmt.Errorf("no active session") - } - cfg, err := config.LoadForRoot(tab.WorkspaceRoot) - if err != nil { - return BuiltInMCPUpdateResult{}, err - } - client, err := netclient.NewHTTPClient(cfg.NetworkProxySpec(), netclient.TransportOptions{}) - if err != nil { - return BuiltInMCPUpdateResult{}, err - } - updated, err := updateBuiltInCodegraph(a.bootContext(), client, nil) - if err != nil { - return BuiltInMCPUpdateResult{}, err - } - result := BuiltInMCPUpdateResult{Name: name, Version: updated.Version, Path: updated.Path} - a.recordBuiltInMCPUpdateStatus(BuiltInMCPUpdateStatus{ - Name: name, - Mode: "manual", - Current: codegraph.ActiveVersion(), - Latest: updated.Version, - Phase: "activated", - Path: updated.Path, - }) - - a.mu.RLock() - _, sessionDisabled := tab.disabledMCP[name] - a.mu.RUnlock() - if cfg.Codegraph.Enabled && !sessionDisabled { - if mcpConnected(tab.Ctrl, name) { - tab.Ctrl.DisconnectMCPServer(name) - } - if h := tab.Ctrl.Host(); h != nil { - h.ClearFailure(name) - } - if _, err := tab.Ctrl.ConnectCodegraphMCPServerForRoot(cfg, tab.WorkspaceRoot); err != nil { - recordCodegraphFailure(tab.Ctrl, cfg.Codegraph, err) - } - } - return result, nil -} - // ClearMCPServerAuthentication removes local auth-like config for one MCP and // clears the current session's cached connection failure. It does not remove the // server itself or try to sign the user out of the third-party browser session. @@ -3947,7 +2904,7 @@ func (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) ( } } if name == "codegraph" { - return tab.Ctrl.ConnectCodegraphMCPServerForRoot(cfg, tab.WorkspaceRoot) + return tab.Ctrl.ConnectCodegraphMCPServer(cfg) } if p, ok := builtinmcp.Entry(name); ok { return tab.Ctrl.ConnectMCPServer(p) @@ -4062,7 +3019,7 @@ func (a *App) setCodegraphEnabled(enabled bool) error { a.mu.Lock() delete(tab.disabledMCP, "codegraph") a.mu.Unlock() - if _, err := tab.Ctrl.ConnectCodegraphMCPServerForRoot(cfg, tab.WorkspaceRoot); err != nil { + if _, err := tab.Ctrl.ConnectCodegraphMCPServer(cfg); err != nil { recordCodegraphFailure(tab.Ctrl, cfg.Codegraph, err) return nil } @@ -4104,7 +3061,7 @@ func (a *App) setCodegraphTier(_ string) error { delete(tab.disabledMCP, "codegraph") a.mu.Unlock() if !mcpConnected(tab.Ctrl, "codegraph") { - if _, err := tab.Ctrl.ConnectCodegraphMCPServerForRoot(cfg, tab.WorkspaceRoot); err != nil { + if _, err := tab.Ctrl.ConnectCodegraphMCPServer(cfg); err != nil { recordCodegraphFailure(tab.Ctrl, cfg.Codegraph, err) return nil } @@ -4457,14 +3414,6 @@ func modelProviderAccessAllowed(access map[string]bool, name string) bool { return access[strings.TrimSpace(name)] } -func controllerHasActiveRuntimeWork(ctrl *control.Controller) bool { - return ctrl != nil && (ctrl.Running() || ctrl.PendingPrompt() || len(ctrl.Jobs()) > 0) -} - -func rebuildControllerActiveWorkError(setting string) error { - return fmt.Errorf("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s", setting) -} - // SetModel switches the active model and carries the current conversation into the // new model's session, so the chat continues seamlessly and subsequent turns use // the new model. No-op if name is already active or the controller is down. @@ -4483,8 +3432,8 @@ func (a *App) SetModelForTab(tabID, name string) error { if name == tab.model { return nil } - if controllerHasActiveRuntimeWork(tab.Ctrl) { - return rebuildControllerActiveWorkError("model") + if tab.Ctrl != nil && tab.Ctrl.Running() { + return fmt.Errorf("finish or cancel the current turn before changing model") } cfg, err := config.LoadForRoot(tab.WorkspaceRoot) if err != nil { @@ -4593,8 +3542,8 @@ func (a *App) SetEffortForTab(tabID, level string) error { return fmt.Errorf("tab %q not found", tabID) } ctrl := tab.Ctrl - if controllerHasActiveRuntimeWork(ctrl) { - return rebuildControllerActiveWorkError("effort") + if ctrl != nil && ctrl.Running() { + return fmt.Errorf("finish or cancel the current turn before changing effort") } entry, err := a.currentProviderEntryForTab(tabID) if err != nil { @@ -4664,8 +3613,8 @@ func (a *App) SetTokenModeForTab(tabID, mode string) error { return nil } ctrl := tab.Ctrl - if controllerHasActiveRuntimeWork(ctrl) { - return rebuildControllerActiveWorkError("token mode") + if ctrl != nil && ctrl.Running() { + return fmt.Errorf("finish or cancel the current turn before changing token mode") } var carried []provider.Message diff --git a/desktop/app_test.go b/desktop/app_test.go index 9ff4255ee..fd2fe5047 100644 --- a/desktop/app_test.go +++ b/desktop/app_test.go @@ -5,19 +5,15 @@ import ( "encoding/json" "errors" "fmt" - "io" - "net/http" "os" "path/filepath" "reflect" - "strconv" "strings" "sync/atomic" "testing" "time" "reasonix/internal/agent" - "reasonix/internal/codegraph" "reasonix/internal/config" "reasonix/internal/control" "reasonix/internal/event" @@ -303,7 +299,6 @@ func TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) { if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(` [desktop] language = "zh" -layout_style = "workbench" theme = "light" theme_style = "glacier" close_behavior = "quit" @@ -317,9 +312,6 @@ status_bar_items = ["cost", "balance"] if err := userCfg.SetDesktopLanguage("en"); err != nil { t.Fatalf("set desktop language: %v", err) } - if err := userCfg.SetDesktopLayoutStyle("classic"); err != nil { - t.Fatalf("set desktop layout style: %v", err) - } if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil { t.Fatalf("set desktop appearance: %v", err) } @@ -343,8 +335,8 @@ status_bar_items = ["cost", "balance"] } got := NewApp().Settings() - if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "classic" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.CloseBehavior != "background" || got.StatusBarStyle != "text" { - t.Fatalf("desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle) + if got.DesktopLanguage != "en" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.CloseBehavior != "background" || got.StatusBarStyle != "text" { + t.Fatalf("desktop settings = lang:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs", got.DesktopLanguage, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle) } if want := []string{"model", "balance", "cache"}; !reflect.DeepEqual(got.StatusBarItems, want) { t.Fatalf("desktop status bar items = %v, want user-level %v", got.StatusBarItems, want) @@ -360,7 +352,6 @@ default_model = "legacy-provider/legacy-model" [desktop] language = "zh" -layout_style = "workbench" theme = "light" theme_style = "glacier" close_behavior = "quit" @@ -381,7 +372,7 @@ status_bar_items = ["model", "cache", "balance"] if got.ConfigPath != config.UserConfigPath() { t.Fatalf("Settings configPath = %q, want user config %q", got.ConfigPath, config.UserConfigPath()) } - if got.DefaultModel != "legacy-provider/legacy-model" || got.DesktopLanguage != "zh" || got.DesktopLayoutStyle != "workbench" || got.DesktopTheme != "light" || got.DesktopThemeStyle != "glacier" || got.CloseBehavior != "quit" || got.StatusBarStyle != "text" { + if got.DefaultModel != "legacy-provider/legacy-model" || got.DesktopLanguage != "zh" || got.DesktopTheme != "light" || got.DesktopThemeStyle != "glacier" || got.CloseBehavior != "quit" || got.StatusBarStyle != "text" { t.Fatalf("Settings did not seed from legacy project config: %+v", got) } if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(got.StatusBarItems, want) { @@ -394,8 +385,8 @@ status_bar_items = ["model", "cache", "balance"] t.Fatalf("SetDesktopLanguage: %v", err) } userCfg := config.LoadForEdit(config.UserConfigPath()) - if userCfg.DesktopLanguage() != "en" || userCfg.DesktopLayoutStyle() != "workbench" || userCfg.DesktopTheme() != "light" || userCfg.DesktopThemeStyle() != "glacier" || userCfg.DesktopCloseBehavior() != "quit" || userCfg.DesktopStatusBarStyle() != "text" { - t.Fatalf("saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle()) + if userCfg.DesktopLanguage() != "en" || userCfg.DesktopTheme() != "light" || userCfg.DesktopThemeStyle() != "glacier" || userCfg.DesktopCloseBehavior() != "quit" || userCfg.DesktopStatusBarStyle() != "text" { + t.Fatalf("saved user config did not preserve seeded desktop prefs: lang:%q theme:%q style:%q close:%q status:%q", userCfg.DesktopLanguage(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle()) } if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) { t.Fatalf("saved user config did not preserve seeded status bar items: got %v want %v", userCfg.DesktopStatusBarItems(), want) @@ -744,7 +735,6 @@ func TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) { "deepseek/deepseek-v4-flash", "deepseek/deepseek-v4-pro", "mimo-token-plan/mimo-v2.5-pro", - "mimo-token-plan/mimo-v2.5", } { if !refs[want] { t.Fatalf("Models() refs = %+v, missing %s", models, want) @@ -758,8 +748,8 @@ func TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) { t.Fatalf("Models() refs = %+v, should not include hidden provider %s", models, hidden) } } - if len(models) != 4 { - t.Fatalf("Models() len = %d, want 4: %+v", len(models), models) + if len(models) != 3 { + t.Fatalf("Models() len = %d, want 3: %+v", len(models), models) } } @@ -776,17 +766,11 @@ func TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) { models := NewApp().Models() refs := modelRefsFromView(models) - for _, want := range []string{ - "mimo-api/mimo-v2.5-pro", - "mimo-api/mimo-v2.5", - "mimo-api/mimo-v2-omni", - } { - if !refs[want] { - t.Fatalf("Models() refs = %+v, missing %s", models, want) - } + if !refs["mimo-api/mimo-v2.5-pro"] { + t.Fatalf("Models() refs = %+v, missing mimo-api/mimo-v2.5-pro", models) } - if len(models) != 3 { - t.Fatalf("Models() len = %d, want 3: %+v", len(models), models) + if len(models) != 1 { + t.Fatalf("Models() len = %d, want 1: %+v", len(models), models) } } @@ -959,44 +943,6 @@ func TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) { ctrl.Close() } -func TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) { - isolateDesktopUserDirs(t) - t.Setenv("REASONIX_TEST_KEY", "sk-test") - - cfg := config.Default() - cfg.DefaultModel = "prov-a/model-a1" - cfg.Providers = []config.ProviderEntry{ - {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"}, - {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"}, - } - if err := cfg.SaveTo(config.UserConfigPath()); err != nil { - t.Fatalf("save config: %v", err) - } - - dir := config.SessionDir() - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatalf("mkdir session dir: %v", err) - } - path := filepath.Join(dir, "provider-job.jsonl") - jm := jobs.NewManager(event.Discard) - ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm}) - defer ctrl.Close() - app := NewApp() - app.setTestCtrl(ctrl, "prov-a/model-a1") - jm.StartForSession(agent.BranchID(path), "bash", "provider job", func(ctx context.Context, _ io.Writer) (string, error) { - <-ctx.Done() - return "", ctx.Err() - }) - - err := app.DeleteProvider("prov-a") - if err == nil || !strings.Contains(err.Error(), "active work") { - t.Fatalf("DeleteProvider with background job error = %v, want active-work guard", err) - } - if _, ok := config.LoadForEdit(config.UserConfigPath()).Provider("prov-a"); !ok { - t.Fatal("provider should remain after rejected deletion") - } -} - func TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) { isolateDesktopUserDirs(t) @@ -1004,9 +950,6 @@ func TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) { if err := userCfg.SetDesktopLanguage("en"); err != nil { t.Fatalf("set desktop language: %v", err) } - if err := userCfg.SetDesktopLayoutStyle("workbench"); err != nil { - t.Fatalf("set desktop layout style: %v", err) - } if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil { t.Fatalf("set desktop appearance: %v", err) } @@ -1019,8 +962,8 @@ func TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) { } got := config.LoadForEdit(config.UserConfigPath()) - if got.DesktopLanguage() != "en" || got.DesktopLayoutStyle() != "workbench" || got.DesktopTheme() != "dark" || got.DesktopThemeStyle() != "graphite" { - t.Fatalf("desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle()) + if got.DesktopLanguage() != "en" || got.DesktopTheme() != "dark" || got.DesktopThemeStyle() != "graphite" { + t.Fatalf("desktop prefs after migration = lang:%q theme:%q style:%q, want existing config preserved", got.DesktopLanguage(), got.DesktopTheme(), got.DesktopThemeStyle()) } } @@ -1160,111 +1103,6 @@ func TestSetTokenModeRejectsRunningTurn(t *testing.T) { waitNotRunning(t, app.activeCtrl()) } -func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) { - isolateDesktopUserDirs(t) - - dir := config.SessionDir() - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatalf("mkdir session dir: %v", err) - } - path := filepath.Join(dir, "jobs.jsonl") - jm := jobs.NewManager(event.Discard) - ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm}) - defer ctrl.Close() - app := NewApp() - app.setTestCtrl(ctrl, "") - - release := make(chan struct{}) - jm.StartForSession(agent.BranchID(path), "bash", "long job", func(ctx context.Context, _ io.Writer) (string, error) { - select { - case <-ctx.Done(): - return "", ctx.Err() - case <-release: - return "", nil - } - }) - defer close(release) - - err := app.SetTokenMode("economy") - if err == nil || !strings.Contains(err.Error(), "stop background jobs") { - t.Fatalf("SetTokenMode with background job error = %v, want background-job guard", err) - } -} - -func TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) { - isolateDesktopUserDirs(t) - - dir := config.SessionDir() - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatalf("mkdir session dir: %v", err) - } - path := filepath.Join(dir, "settings-job.jsonl") - jm := jobs.NewManager(event.Discard) - ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm}) - defer ctrl.Close() - app := NewApp() - app.ctx = context.Background() - app.setTestCtrl(ctrl, "deepseek-flash/deepseek-v4-flash") - - jm.StartForSession(agent.BranchID(path), "bash", "settings job", func(ctx context.Context, _ io.Writer) (string, error) { - <-ctx.Done() - return "", ctx.Err() - }) - - err := app.SetSandbox("enforce", true, "", nil, "") - if err == nil || !strings.Contains(err.Error(), "stop background jobs") { - t.Fatalf("SetSandbox with background job error = %v, want background-job guard", err) - } -} - -func TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) { - isolateDesktopUserDirs(t) - - dir := config.SessionDir() - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatalf("mkdir session dir: %v", err) - } - path := filepath.Join(dir, "clear-running.jsonl") - if err := os.WriteFile(path, []byte(`{"role":"user","content":"old"}`+"\n"), 0o644); err != nil { - t.Fatalf("write session: %v", err) - } - runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})} - oldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: "test"}) - app := NewApp() - app.projectTreeChangedHook = func() {} - app.setTestCtrl(oldCtrl, "deepseek-flash/deepseek-v4-flash") - app.tabs["test"].TopicID = "topic_clear" - app.tabs["test"].TopicTitle = "Clear topic" - defer func() { - if c := app.activeCtrl(); c != nil { - c.Close() - } - }() - - oldCtrl.Submit("work") - <-runner.started - if err := app.ClearSession(); err != nil { - t.Fatalf("ClearSession: %v", err) - } - waitNotRunning(t, oldCtrl) - tab := app.activeTab() - if tab == nil || tab.Ctrl == nil { - t.Fatalf("active tab/controller missing after clear") - } - if tab.Ctrl == oldCtrl { - t.Fatalf("clear should replace the active controller after cancelling old work") - } - if tab.TopicID != "topic_clear" || tab.TopicTitle != "Clear topic" { - t.Fatalf("clear changed topic identity: %+v", tab) - } - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("old cleared session artifacts should be removed, stat err = %v", err) - } - if got := tab.currentSessionPath(); got == "" || got == path { - t.Fatalf("new session path = %q, want fresh path", got) - } -} - func TestSearchFileRefsFindsNestedBasename(t *testing.T) { orig, _ := os.Getwd() defer os.Chdir(orig) @@ -1412,7 +1250,7 @@ func TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) { } } -func TestDeleteSessionCancelsActiveRuntime(t *testing.T) { +func TestDeleteSessionRejectsActiveRelativePath(t *testing.T) { isolateDesktopUserDirs(t) dir := config.SessionDir() @@ -1425,80 +1263,22 @@ func TestDeleteSessionCancelsActiveRuntime(t *testing.T) { } app := NewApp() - activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test"}) - keepPath := filepath.Join(dir, "keep.jsonl") - if err := os.WriteFile(keepPath, []byte(`{"role":"user","content":"keep"}`+"\n"), 0o644); err != nil { - t.Fatalf("write keep session: %v", err) - } - keepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: "keep"}) - defer keepCtrl.Close() - app.setTestCtrl(activeCtrl, "") - app.tabs["keep"] = &WorkspaceTab{ID: "keep", Scope: "global", Ctrl: keepCtrl, Ready: true} - app.tabOrder = []string{"test", "keep"} - - if err := app.DeleteSession(filepath.Base(path)); err != nil { - t.Fatalf("DeleteSession(active basename): %v", err) - } - if _, ok := app.tabs["test"]; ok { - t.Fatalf("deleted active session runtime should be removed") - } - if got := app.activeTabID; got != "keep" { - t.Fatalf("active tab after delete = %q, want keep", got) - } - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("active session should be moved out of active history, stat err = %v", err) - } - trashPath := filepath.Join(dir, sessionTrashDir, "active.jsonl", "active.jsonl") - if _, err := os.Stat(trashPath); err != nil { - t.Fatalf("active session should be moved to trash: %v", err) - } -} - -func TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) { - isolateDesktopUserDirs(t) - - dir := config.SessionDir() - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatalf("mkdir session dir: %v", err) - } - path := filepath.Join(dir, "active-conflict.jsonl") - if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil { - t.Fatalf("write session: %v", err) - } - if err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil { - t.Fatalf("create trash conflict: %v", err) - } - - runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})} - ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: "test"}) - app := NewApp() - app.setTestCtrl(ctrl, "") - defer ctrl.Close() - ctrl.Submit("work") - <-runner.started + app.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test"}), "") + defer func() { + if c := app.activeCtrl(); c != nil { + c.Close() + } + }() - err := app.DeleteSession(filepath.Base(path)) - if err == nil || !strings.Contains(err.Error(), "already exists in trash") { - t.Fatalf("DeleteSession conflict error = %v, want trash conflict", err) - } - if app.activeCtrl() != ctrl { - t.Fatalf("active runtime should remain bound after preflight failure") - } - if !ctrl.Running() { - t.Fatalf("running turn should not be cancelled on preflight failure") - } - if _, ok := app.tabs["test"]; !ok { - t.Fatalf("tab should remain after preflight failure") + if err := app.DeleteSession(filepath.Base(path)); err != errActiveSession { + t.Fatalf("DeleteSession(active basename) error = %v, want errActiveSession", err) } if _, err := os.Stat(path); err != nil { - t.Fatalf("active session file should remain: %v", err) + t.Fatalf("active session should remain: %v", err) } - - close(runner.release) - waitNotRunning(t, ctrl) } -func TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) { +func TestDeleteSessionRejectsInactiveOpenTab(t *testing.T) { isolateDesktopUserDirs(t) dir := config.SessionDir() @@ -1528,18 +1308,11 @@ func TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) { activeTabID: "active", } - if err := app.DeleteSession(filepath.Base(inactivePath)); err != nil { - t.Fatalf("DeleteSession(inactive open basename): %v", err) - } - if _, ok := app.tabs["inactive"]; ok { - t.Fatalf("deleted inactive session runtime should be removed") + if err := app.DeleteSession(filepath.Base(inactivePath)); err != errActiveSession { + t.Fatalf("DeleteSession(inactive open basename) error = %v, want errActiveSession", err) } - if _, err := os.Stat(inactivePath); !os.IsNotExist(err) { - t.Fatalf("inactive open session should be moved out of active history, stat err = %v", err) - } - trashPath := filepath.Join(dir, sessionTrashDir, "inactive.jsonl", "inactive.jsonl") - if _, err := os.Stat(trashPath); err != nil { - t.Fatalf("inactive open session should be moved to trash: %v", err) + if _, err := os.Stat(inactivePath); err != nil { + t.Fatalf("inactive open session should remain: %v", err) } sessions := app.ListSessions() @@ -1552,13 +1325,16 @@ func TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) { if !current[filepath.Base(activePath)] { t.Fatalf("ListSessions should mark active session current, got %#v", current) } + if current[filepath.Base(inactivePath)] { + t.Fatalf("ListSessions should not mark inactive open session current, got %#v", current) + } if current[filepath.Base(otherPath)] { t.Fatalf("ListSessions marked unopened session current, got %#v", current) } - if !open[filepath.Base(activePath)] { + if !open[filepath.Base(activePath)] || !open[filepath.Base(inactivePath)] { t.Fatalf("ListSessions should mark active and inactive open sessions open, got %#v", open) } - if open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] { + if open[filepath.Base(otherPath)] { t.Fatalf("ListSessions marked unopened session open, got %#v", open) } } @@ -1910,9 +1686,6 @@ func TestCapabilitiesShowsDefaultCodegraphDisabled(t *testing.T) { if s.Tier != "background" { t.Fatalf("codegraph tier = %q, want background; server = %+v", s.Tier, s) } - if s.Command != "codegraph" || !reflect.DeepEqual(s.Args, []string{"serve", "--mcp"}) { - t.Fatalf("codegraph command = %q %+v, want codegraph serve --mcp", s.Command, s.Args) - } return } } @@ -2636,226 +2409,6 @@ tier = "lazy" t.Fatalf("codegraph missing from Capabilities: %+v", view.Servers) } -func TestUpdateBuiltInMCPServerUpdatesCodegraphRuntime(t *testing.T) { - isolateDesktopUserDirs(t) - dir := robustTempDir(t) - t.Chdir(dir) - if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(` -[codegraph] -enabled = false -`), 0o644); err != nil { - t.Fatal(err) - } - - orig := updateBuiltInCodegraph - defer func() { updateBuiltInCodegraph = orig }() - called := false - updateBuiltInCodegraph = func(ctx context.Context, client *http.Client, log func(string)) (codegraph.UpdateResult, error) { - called = true - if ctx == nil { - t.Fatal("context is nil") - } - if client == nil { - t.Fatal("http client is nil") - } - return codegraph.UpdateResult{Version: "v9.9.9", Path: filepath.Join(dir, "cache", "codegraph")}, nil - } - - app := NewApp() - app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "") - defer app.activeCtrl().Close() - - got, err := app.UpdateBuiltInMCPServer("codegraph") - if err != nil { - t.Fatalf("UpdateBuiltInMCPServer(codegraph): %v", err) - } - if !called { - t.Fatal("updater was not called") - } - if got.Name != "codegraph" || got.Version != "v9.9.9" || got.Path == "" { - t.Fatalf("UpdateBuiltInMCPServer result = %+v", got) - } -} - -func TestUpdateBuiltInMCPServerRejectsOtherServers(t *testing.T) { - isolateDesktopUserDirs(t) - - app := NewApp() - app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "") - defer app.activeCtrl().Close() - - if _, err := app.UpdateBuiltInMCPServer("time"); err == nil { - t.Fatal("UpdateBuiltInMCPServer(time) succeeded; want error") - } -} - -func TestBuiltInMCPBackgroundNotifyDoesNotDownload(t *testing.T) { - isolateDesktopUserDirs(t) - t.Setenv("REASONIX_CACHE_DIR", robustTempDir(t)) - - origCheck := checkCodegraphLatest - origDownload := downloadLatestCodegraph - origUpdate := updateBuiltInCodegraph - defer func() { - checkCodegraphLatest = origCheck - downloadLatestCodegraph = origDownload - updateBuiltInCodegraph = origUpdate - }() - - checkCodegraphLatest = func(ctx context.Context, client *http.Client) (string, error) { - return "v99.99.99", nil - } - downloadLatestCodegraph = func(ctx context.Context, client *http.Client, log func(string)) (codegraph.UpdateResult, error) { - t.Fatal("notify mode should not download") - return codegraph.UpdateResult{}, nil - } - updateBuiltInCodegraph = func(ctx context.Context, client *http.Client, log func(string)) (codegraph.UpdateResult, error) { - t.Fatal("notify mode should not activate") - return codegraph.UpdateResult{}, nil - } - - cfg := config.Default() - cfg.BuiltInMCPUpdates.Mode = config.BuiltInMCPUpdateModeNotify - statuses, err := NewApp().runBuiltInMCPUpdateCheck(cfg) - if err != nil { - t.Fatalf("runBuiltInMCPUpdateCheck: %v", err) - } - if len(statuses) != 1 || statuses[0].Phase != "available" || statuses[0].Latest != "v99.99.99" { - t.Fatalf("statuses = %+v, want available latest v99.99.99", statuses) - } -} - -func TestBuiltInMCPUpdateStatusesReturnArray(t *testing.T) { - app := NewApp() - empty := app.BuiltInMCPUpdateStatuses() - if empty == nil { - t.Fatal("empty BuiltInMCPUpdateStatuses returned nil; Wails should encode []") - } - app.recordBuiltInMCPUpdateStatus(BuiltInMCPUpdateStatus{ - Name: "codegraph", - Mode: "notify", - Current: "v0.9.7", - Latest: "v9.9.9", - Phase: "available", - }) - statuses := app.BuiltInMCPUpdateStatuses() - if len(statuses) != 1 || statuses[0].Name != "codegraph" || statuses[0].Phase != "available" { - t.Fatalf("BuiltInMCPUpdateStatuses = %+v, want codegraph available", statuses) - } -} - -func TestBuiltInMCPBackgroundDownloadDoesNotActivate(t *testing.T) { - isolateDesktopUserDirs(t) - t.Setenv("REASONIX_CACHE_DIR", robustTempDir(t)) - - origCheck := checkCodegraphLatest - origDownload := downloadLatestCodegraph - origUpdate := updateBuiltInCodegraph - defer func() { - checkCodegraphLatest = origCheck - downloadLatestCodegraph = origDownload - updateBuiltInCodegraph = origUpdate - }() - - checkCodegraphLatest = func(ctx context.Context, client *http.Client) (string, error) { - if _, ok := ctx.Deadline(); !ok { - t.Fatal("manifest check context has no deadline") - } - return "v99.99.99", nil - } - downloaded := false - downloadLatestCodegraph = func(ctx context.Context, client *http.Client, log func(string)) (codegraph.UpdateResult, error) { - downloaded = true - if deadline, ok := ctx.Deadline(); ok && time.Until(deadline) <= httpTimeout+time.Second { - t.Fatalf("download context inherited manifest timeout; deadline=%v", deadline) - } - return codegraph.UpdateResult{Version: "v99.99.99", Path: filepath.Join(t.TempDir(), "codegraph")}, nil - } - updateBuiltInCodegraph = func(ctx context.Context, client *http.Client, log func(string)) (codegraph.UpdateResult, error) { - t.Fatal("download mode should not activate") - return codegraph.UpdateResult{}, nil - } - - cfg := config.Default() - cfg.BuiltInMCPUpdates.Mode = config.BuiltInMCPUpdateModeDownload - statuses, err := NewApp().runBuiltInMCPUpdateCheck(cfg) - if err != nil { - t.Fatalf("runBuiltInMCPUpdateCheck: %v", err) - } - if !downloaded { - t.Fatal("download mode did not call downloader") - } - if len(statuses) != 1 || statuses[0].Phase != "downloaded" || statuses[0].Path == "" { - t.Fatalf("statuses = %+v, want downloaded with path", statuses) - } -} - -func TestBuiltInMCPBackgroundAutoNextSessionActivates(t *testing.T) { - isolateDesktopUserDirs(t) - t.Setenv("REASONIX_CACHE_DIR", robustTempDir(t)) - - origCheck := checkCodegraphLatest - origDownload := downloadLatestCodegraph - origUpdate := updateBuiltInCodegraph - defer func() { - checkCodegraphLatest = origCheck - downloadLatestCodegraph = origDownload - updateBuiltInCodegraph = origUpdate - }() - - checkCodegraphLatest = func(ctx context.Context, client *http.Client) (string, error) { - if _, ok := ctx.Deadline(); !ok { - t.Fatal("manifest check context has no deadline") - } - return "v99.99.99", nil - } - downloadLatestCodegraph = func(ctx context.Context, client *http.Client, log func(string)) (codegraph.UpdateResult, error) { - t.Fatal("auto_next_session mode should activate through the updater") - return codegraph.UpdateResult{}, nil - } - activated := false - updateBuiltInCodegraph = func(ctx context.Context, client *http.Client, log func(string)) (codegraph.UpdateResult, error) { - activated = true - if deadline, ok := ctx.Deadline(); ok && time.Until(deadline) <= httpTimeout+time.Second { - t.Fatalf("activation context inherited manifest timeout; deadline=%v", deadline) - } - return codegraph.UpdateResult{Version: "v99.99.99", Path: filepath.Join(t.TempDir(), "codegraph")}, nil - } - - cfg := config.Default() - cfg.BuiltInMCPUpdates.Mode = config.BuiltInMCPUpdateModeAutoNextSession - statuses, err := NewApp().runBuiltInMCPUpdateCheck(cfg) - if err != nil { - t.Fatalf("runBuiltInMCPUpdateCheck: %v", err) - } - if !activated { - t.Fatal("auto_next_session mode did not activate") - } - if len(statuses) != 1 || statuses[0].Phase != "activated" || statuses[0].Path == "" { - t.Fatalf("statuses = %+v, want activated with path", statuses) - } -} - -func TestBuiltInMCPUpdateIntervalStamp(t *testing.T) { - isolateDesktopUserDirs(t) - origNow := builtInMCPUpdateNow - defer func() { builtInMCPUpdateNow = origNow }() - - now := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC) - builtInMCPUpdateNow = func() time.Time { return now } - if !shouldRunBuiltInMCPUpdateCheck(time.Hour) { - t.Fatal("first update check should run without a stamp") - } - markBuiltInMCPUpdateChecked() - if shouldRunBuiltInMCPUpdateCheck(time.Hour) { - t.Fatal("update check should be suppressed inside interval") - } - builtInMCPUpdateNow = func() time.Time { return now.Add(2 * time.Hour) } - if !shouldRunBuiltInMCPUpdateCheck(time.Hour) { - t.Fatal("update check should run after interval") - } -} - func TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) { isolateDesktopUserDirs(t) dir := robustTempDir(t) @@ -3016,684 +2569,3 @@ func TestSessionActionsWithoutControllerReturnError(t *testing.T) { t.Errorf("error should carry the tab's startup failure, got %v", err) } } - -// --- Prompt history scanning tests ------------------------------------------ - -func identityPromptDisplay(text string) string { return text } - -// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format -// {"kind":"user.message","text":"..."} is correctly extracted. -func TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "session.jsonl") - if err := os.WriteFile(path, []byte(`{"kind":"user.message","text":"hello world"} -{"kind":"user.message","text":"second prompt"} -{"kind":"model.final","content":"response"} -`), 0o644); err != nil { - t.Fatal(err) - } - info, err := os.Stat(path) - if err != nil { - t.Fatal(err) - } - entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay) - if err != nil { - t.Fatal(err) - } - if len(entries) != 2 { - t.Fatalf("expected 2 entries, got %d", len(entries)) - } - if entries[0].Text != "hello world" { - t.Errorf("expected 'hello world', got %q", entries[0].Text) - } - if entries[1].Text != "second prompt" { - t.Errorf("expected 'second prompt', got %q", entries[1].Text) - } - if entries[0].Turn != 0 || entries[1].Turn != 1 { - t.Errorf("expected turns 0,1; got %d,%d", entries[0].Turn, entries[1].Turn) - } - if entries[0].SessionPath != path { - t.Errorf("expected session path %q, got %q", path, entries[0].SessionPath) - } -} - -// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event -// format {"type":"user.message","text":"..."} is correctly extracted. -func TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "session.jsonl") - if err := os.WriteFile(path, []byte(`{"type":"user.message","text":"v0 prompt"} -{"type":"model.final","content":"response"} -`), 0o644); err != nil { - t.Fatal(err) - } - info, err := os.Stat(path) - if err != nil { - t.Fatal(err) - } - entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay) - if err != nil { - t.Fatal(err) - } - if len(entries) != 1 { - t.Fatalf("expected 1 entry, got %d", len(entries)) - } - if entries[0].Text != "v0 prompt" { - t.Errorf("expected 'v0 prompt', got %q", entries[0].Text) - } -} - -// TestCollectPromptHistoryEntriesProviderMessage verifies that the current -// provider.Message format {"role":"user","content":"..."} is correctly extracted. -func TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "session.jsonl") - if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello from provider"} -{"role":"assistant","content":"response"} -{"role":"user","content":"another prompt"} -`), 0o644); err != nil { - t.Fatal(err) - } - info, err := os.Stat(path) - if err != nil { - t.Fatal(err) - } - entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay) - if err != nil { - t.Fatal(err) - } - if len(entries) != 2 { - t.Fatalf("expected 2 entries, got %d", len(entries)) - } - if entries[0].Text != "hello from provider" { - t.Errorf("expected 'hello from provider', got %q", entries[0].Text) - } - if entries[1].Text != "another prompt" { - t.Errorf("expected 'another prompt', got %q", entries[1].Text) - } -} - -// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the -// same file are extracted. -func TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "session.jsonl") - if err := os.WriteFile(path, []byte(`{"kind":"user.message","text":"legacy prompt"} -{"role":"user","content":"modern prompt"} -`), 0o644); err != nil { - t.Fatal(err) - } - info, err := os.Stat(path) - if err != nil { - t.Fatal(err) - } - entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay) - if err != nil { - t.Fatal(err) - } - if len(entries) != 2 { - t.Fatalf("expected 2 entries, got %d", len(entries)) - } - if entries[0].Text != "legacy prompt" { - t.Errorf("expected 'legacy prompt', got %q", entries[0].Text) - } - if entries[1].Text != "modern prompt" { - t.Errorf("expected 'modern prompt', got %q", entries[1].Text) - } -} - -func TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "session.jsonl") - rfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC) - if err := os.WriteFile(path, []byte(`{"kind":"user.message","text":"legacy timed","time":1800000000123} -{"role":"user","content":"modern timed","createdAt":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`} -`), 0o644); err != nil { - t.Fatal(err) - } - info, err := os.Stat(path) - if err != nil { - t.Fatal(err) - } - entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay) - if err != nil { - t.Fatal(err) - } - if len(entries) != 2 { - t.Fatalf("expected 2 entries, got %d", len(entries)) - } - if entries[0].At != 1800000000123 { - t.Errorf("numeric event time = %d, want 1800000000123", entries[0].At) - } - if entries[1].At != rfcTime.UnixMilli() { - t.Errorf("RFC3339 event time = %d, want %d", entries[1].At, rfcTime.UnixMilli()) - } -} - -// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses -// the user-visible prompt text, not the controller-expanded model input. -func TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "session.jsonl") - expanded := "\nSaved memory\n\n\nvisible prompt" - if err := os.WriteFile(path, []byte(`{"role":"user","content":`+strconv.Quote(expanded)+`}`+"\n"), 0o644); err != nil { - t.Fatal(err) - } - if err := recordSessionDisplay(dir, path, expanded, "visible prompt"); err != nil { - t.Fatal(err) - } - info, err := os.Stat(path) - if err != nil { - t.Fatal(err) - } - entries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path)) - if err != nil { - t.Fatal(err) - } - if len(entries) != 1 { - t.Fatalf("expected 1 entry, got %d", len(entries)) - } - if entries[0].Text != "visible prompt" { - t.Errorf("expected visible prompt, got %q", entries[0].Text) - } -} - -func TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "session.jsonl") - if err := os.WriteFile(path, []byte(`{"role":"user","content":"Plan approved — plan mode is off"} -{"role":"user","content":"real prompt"} -`), 0o644); err != nil { - t.Fatal(err) - } - info, err := os.Stat(path) - if err != nil { - t.Fatal(err) - } - entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay) - if err != nil { - t.Fatal(err) - } - if len(entries) != 1 { - t.Fatalf("expected 1 entry, got %d", len(entries)) - } - if entries[0].Text != "real prompt" { - t.Errorf("expected real prompt, got %q", entries[0].Text) - } -} - -// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only -// assistant/tool messages returns no entries. -func TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "session.jsonl") - if err := os.WriteFile(path, []byte(`{"kind":"model.final","content":"response"} -{"kind":"tool.result","output":"done"} -`), 0o644); err != nil { - t.Fatal(err) - } - info, err := os.Stat(path) - if err != nil { - t.Fatal(err) - } - entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay) - if err != nil { - t.Fatal(err) - } - if len(entries) != 0 { - t.Errorf("expected 0 entries, got %d", len(entries)) - } -} - -// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file -// returns no entries without error. -func TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "empty.jsonl") - if err := os.WriteFile(path, nil, 0o644); err != nil { - t.Fatal(err) - } - info, err := os.Stat(path) - if err != nil { - t.Fatal(err) - } - entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay) - if err != nil { - t.Fatal(err) - } - if len(entries) != 0 { - t.Errorf("expected 0 entries, got %d", len(entries)) - } -} - -// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans -// multiple JSONL files and returns prompts newest-first. -func TestScanPromptHistoryFromDir(t *testing.T) { - app := &App{tabs: map[string]*WorkspaceTab{"t1": {ID: "t1", Ctrl: nil, WorkspaceRoot: ""}}} - _ = app - - dir := t.TempDir() - // Write two session files with different mtimes (sleep to ensure ordering). - if err := os.WriteFile(filepath.Join(dir, "a.jsonl"), []byte(`{"role":"user","content":"older prompt"} -`), 0o644); err != nil { - t.Fatal(err) - } - time.Sleep(10 * time.Millisecond) - if err := os.WriteFile(filepath.Join(dir, "b.jsonl"), []byte(`{"role":"user","content":"newer prompt"} -`), 0o644); err != nil { - t.Fatal(err) - } - - entries, err := app.scanPromptHistoryFromDir(dir) - if err != nil { - t.Fatal(err) - } - if len(entries) != 2 { - t.Fatalf("expected 2 entries, got %d", len(entries)) - } - // Newest-first: "newer prompt" should be first. - if entries[0].Text != "newer prompt" { - t.Errorf("expected 'newer prompt' first, got %q", entries[0].Text) - } - if entries[1].Text != "older prompt" { - t.Errorf("expected 'older prompt' second, got %q", entries[1].Text) - } -} - -func TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) { - app := &App{} - dir := t.TempDir() - base := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC) - early := filepath.Join(dir, "early.jsonl") - late := filepath.Join(dir, "late.jsonl") - - if err := os.WriteFile(early, []byte(fmt.Sprintf(`{"role":"user","content":"early first","time":%d} -{"role":"assistant","content":"ok"} -{"role":"user","content":"early second","time":%d} -`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(late, []byte(fmt.Sprintf(`{"role":"user","content":"late newest","time":%d} -`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil { - t.Fatal(err) - } - // Invert file mtimes: session activity should keep each session grouped - // before event timestamps are considered within that session. - if err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil { - t.Fatal(err) - } - if err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil { - t.Fatal(err) - } - - entries, err := app.scanPromptHistoryFromDir(dir) - if err != nil { - t.Fatal(err) - } - if len(entries) != 3 { - t.Fatalf("expected 3 entries, got %d", len(entries)) - } - want := []string{"early second", "early first", "late newest"} - for i, w := range want { - if entries[i].Text != w { - t.Fatalf("entries[%d] = %q, want %q; all=%+v", i, entries[i].Text, w, entries) - } - } -} - -func TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) { - app := &App{} - dir := t.TempDir() - base := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC) - early := filepath.Join(dir, "early.jsonl") - late := filepath.Join(dir, "late.jsonl") - - if err := os.WriteFile(early, []byte(`{"role":"user","content":"early first"} -{"role":"assistant","content":"ok"} -{"role":"user","content":"early second"} -`), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(late, []byte(`{"role":"user","content":"late newest"} -`), 0o644); err != nil { - t.Fatal(err) - } - if err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{ - CreatedAt: base, - UpdatedAt: base.Add(time.Minute), - }); err != nil { - t.Fatal(err) - } - if err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{ - CreatedAt: base.Add(time.Minute), - UpdatedAt: base.Add(2 * time.Minute), - }); err != nil { - t.Fatal(err) - } - // Invert file mtimes: branch UpdatedAt should be the activity clock. - if err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil { - t.Fatal(err) - } - if err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil { - t.Fatal(err) - } - - entries, err := app.scanPromptHistoryFromDir(dir) - if err != nil { - t.Fatal(err) - } - if len(entries) != 3 { - t.Fatalf("expected 3 entries, got %d", len(entries)) - } - want := []string{"late newest", "early second", "early first"} - for i, w := range want { - if entries[i].Text != w { - t.Fatalf("entries[%d] = %q, want %q; all=%+v", i, entries[i].Text, w, entries) - } - } -} - -func TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) { - app := &App{} - dir := t.TempDir() - base := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC) - empty := filepath.Join(dir, "empty.jsonl") - real := filepath.Join(dir, "real.jsonl") - - if err := os.WriteFile(empty, nil, 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(real, []byte(`{"role":"user","content":"real prompt"} -`), 0o644); err != nil { - t.Fatal(err) - } - if err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{ - CreatedAt: base, - UpdatedAt: base.Add(time.Hour), - }); err != nil { - t.Fatal(err) - } - if err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{ - CreatedAt: base, - UpdatedAt: base, - }); err != nil { - t.Fatal(err) - } - - entries, err := app.scanPromptHistoryFromDir(dir) - if err != nil { - t.Fatal(err) - } - if len(entries) != 1 || entries[0].Text != "real prompt" { - t.Fatalf("entries = %+v, want only real prompt after skipping empty session", entries) - } -} - -func TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) { - dir := t.TempDir() - current := filepath.Join(dir, "current.jsonl") - other := filepath.Join(dir, "other.jsonl") - if err := os.WriteFile(current, []byte(`{"role":"user","content":"current first"} -{"role":"assistant","content":"ok"} -{"role":"user","content":"current second"} -`), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(other, []byte(`{"role":"user","content":"other newest"} -`), 0o644); err != nil { - t.Fatal(err) - } - now := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC) - if err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{ - CreatedAt: now, - UpdatedAt: now, - }); err != nil { - t.Fatal(err) - } - if err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{ - CreatedAt: now.Add(time.Minute), - UpdatedAt: now.Add(time.Minute), - }); err != nil { - t.Fatal(err) - } - - app := NewApp() - ctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: "test"}) - defer ctrl.Close() - app.setTestCtrl(ctrl, "") - - result, err := app.ScanPromptHistory("") - if err != nil { - t.Fatal(err) - } - if len(result.Entries) != 3 { - t.Fatalf("expected current-session entries followed by cross-session fallback, got %d: %+v", len(result.Entries), result.Entries) - } - want := []string{"current second", "current first", "other newest"} - for i, w := range want { - if result.Entries[i].Text != w { - t.Fatalf("entries[%d] = %q, want %q; all=%+v", i, result.Entries[i].Text, w, result.Entries) - } - } -} - -func TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) { - dir := t.TempDir() - current := filepath.Join(dir, "current.jsonl") - other := filepath.Join(dir, "other.jsonl") - var lines []byte - for i := range 55 { - lines = append(lines, []byte(fmt.Sprintf(`{"role":"user","content":"current %d"} -`, i))...) - } - if err := os.WriteFile(current, lines, 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(other, []byte(`{"role":"user","content":"other newest"} -`), 0o644); err != nil { - t.Fatal(err) - } - now := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC) - if err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{ - CreatedAt: now, - UpdatedAt: now, - }); err != nil { - t.Fatal(err) - } - if err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{ - CreatedAt: now.Add(time.Minute), - UpdatedAt: now.Add(time.Minute), - }); err != nil { - t.Fatal(err) - } - - app := NewApp() - ctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: "test"}) - defer ctrl.Close() - app.setTestCtrl(ctrl, "") - - result, err := app.ScanPromptHistory("") - if err != nil { - t.Fatal(err) - } - if len(result.Entries) != promptHistoryPageLimit { - t.Fatalf("expected %d entries, got %d", promptHistoryPageLimit, len(result.Entries)) - } - if result.Entries[0].Text != "current 54" { - t.Fatalf("first entry = %q, want current 54", result.Entries[0].Text) - } - if result.Entries[len(result.Entries)-1].Text != "current 5" { - t.Fatalf("last first-page entry = %q, want current 5", result.Entries[len(result.Entries)-1].Text) - } - if !result.HasOlder || result.OlderCursor == "" { - t.Fatalf("first page should expose an older cursor: %+v", result) - } - for _, entry := range result.Entries { - if entry.Text == "other newest" { - t.Fatalf("cross-session entry appeared before current-session page was exhausted: %+v", result.Entries) - } - } - - nextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor}) - if err != nil { - t.Fatal(err) - } - next, err := app.ScanPromptHistory(string(nextRequest)) - if err != nil { - t.Fatal(err) - } - want := []string{"current 4", "current 3", "current 2", "current 1", "current 0", "other newest"} - if len(next.Entries) != len(want) { - t.Fatalf("second page entries = %+v, want %d entries", next.Entries, len(want)) - } - for i, w := range want { - if next.Entries[i].Text != w { - t.Fatalf("second page entries[%d] = %q, want %q; all=%+v", i, next.Entries[i].Text, w, next.Entries) - } - } -} - -func TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) { - app := &App{} - dir := t.TempDir() - var lines []byte - for i := range 250 { - lines = append(lines, []byte(fmt.Sprintf(`{"role":"user","content":"prompt %d"} -`, i))...) - } - if err := os.WriteFile(filepath.Join(dir, "many.jsonl"), lines, 0o644); err != nil { - t.Fatal(err) - } - entries, err := app.scanPromptHistoryFromDir(dir) - if err != nil { - t.Fatal(err) - } - if len(entries) != 250 { - t.Fatalf("expected 250 entries, got %d", len(entries)) - } - if entries[0].Text != "prompt 249" { - t.Errorf("expected newest 'prompt 249' first, got %q", entries[0].Text) - } -} - -// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil. -func TestScanPromptHistoryFromDirEmpty(t *testing.T) { - app := &App{} - dir := t.TempDir() - entries, err := app.scanPromptHistoryFromDir(dir) - if err != nil { - t.Fatal(err) - } - if len(entries) != 0 { - t.Errorf("expected 0 entries, got %d", len(entries)) - } -} - -// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil -// on cache hit (nonce matches). -func TestScanPromptHistoryCacheHit(t *testing.T) { - app := &App{tabs: map[string]*WorkspaceTab{}} - result, err := app.ScanPromptHistory("") - if err != nil { - t.Fatal(err) - } - nonce := result.Nonce - if nonce == "" { - t.Error("expected a non-empty nonce on first call") - } - - // Second call with the same nonce should be a cache hit (nil entries). - result2, err := app.ScanPromptHistory(nonce) - if err != nil { - t.Fatal(err) - } - if result2.Entries != nil { - t.Error("expected nil entries on cache hit") - } - if result2.Nonce != nonce { - t.Errorf("expected nonce %q unchanged, got %q", nonce, result2.Nonce) - } -} - -func TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) { - dirA := t.TempDir() - dirB := t.TempDir() - pathA := filepath.Join(dirA, "a.jsonl") - pathB := filepath.Join(dirB, "b.jsonl") - if err := os.WriteFile(pathA, []byte(`{"role":"user","content":"workspace A"} -`), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(pathB, []byte(`{"role":"user","content":"workspace B"} -`), 0o644); err != nil { - t.Fatal(err) - } - - app := NewApp() - ctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: "test"}) - ctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: "test"}) - defer ctrlA.Close() - defer ctrlB.Close() - - app.setTestCtrl(ctrlA, "") - first, err := app.ScanPromptHistory("") - if err != nil { - t.Fatal(err) - } - if len(first.Entries) != 1 || first.Entries[0].Text != "workspace A" { - t.Fatalf("first entries = %+v, want workspace A", first.Entries) - } - - app.setTestCtrl(ctrlB, "") - second, err := app.ScanPromptHistory(first.Nonce) - if err != nil { - t.Fatal(err) - } - if second.Entries == nil { - t.Fatal("expected rescan after session dir changes, got cache hit") - } - if len(second.Entries) != 1 || second.Entries[0].Text != "workspace B" { - t.Fatalf("second entries = %+v, want workspace B", second.Entries) - } -} - -func TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) { - dir := t.TempDir() - pathA := filepath.Join(dir, "a.jsonl") - pathB := filepath.Join(dir, "b.jsonl") - if err := os.WriteFile(pathA, []byte(`{"role":"user","content":"session A"} -`), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(pathB, []byte(`{"role":"user","content":"session B"} -`), 0o644); err != nil { - t.Fatal(err) - } - - app := NewApp() - ctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: "test"}) - ctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: "test"}) - defer ctrlA.Close() - defer ctrlB.Close() - - app.setTestCtrl(ctrlA, "") - first, err := app.ScanPromptHistory("") - if err != nil { - t.Fatal(err) - } - if len(first.Entries) != 2 || first.Entries[0].Text != "session A" || first.Entries[1].Text != "session B" { - t.Fatalf("first entries = %+v, want session A followed by session B", first.Entries) - } - - app.setTestCtrl(ctrlB, "") - second, err := app.ScanPromptHistory(first.Nonce) - if err != nil { - t.Fatal(err) - } - if second.Entries == nil { - t.Fatal("expected rescan after session path changes, got cache hit") - } - if len(second.Entries) != 2 || second.Entries[0].Text != "session B" || second.Entries[1].Text != "session A" { - t.Fatalf("second entries = %+v, want session B followed by session A", second.Entries) - } -} diff --git a/desktop/bot_connection_app.go b/desktop/bot_connection_app.go index 5f1ce82f1..86ef7d0dd 100644 --- a/desktop/bot_connection_app.go +++ b/desktop/bot_connection_app.go @@ -29,10 +29,6 @@ type BotConnectionCredentialView struct { type BotConnectionSessionMappingView struct { RemoteID string `json:"remoteId"` SessionID string `json:"sessionId"` - SessionSource string `json:"sessionSource"` - ChatType string `json:"chatType"` - UserID string `json:"userId"` - ThreadID string `json:"threadId"` Scope string `json:"scope"` WorkspaceRoot string `json:"workspaceRoot"` UpdatedAt string `json:"updatedAt"` @@ -77,16 +73,11 @@ type BotInstallPollResult struct { } type BotConnectionDiagnostic struct { - ID string `json:"id"` - Label string `json:"label"` - Status string `json:"status"` - Message string `json:"message"` - MessageID string `json:"messageId"` - Phase string `json:"phase"` - Code string `json:"code"` - ReportKind string `json:"reportKind"` - ReportDetail string `json:"reportDetail"` - OccurredAt string `json:"occurredAt"` + ID string `json:"id"` + Label string `json:"label"` + Status string `json:"status"` + Message string `json:"message"` + MessageID string `json:"messageId"` } type botInstallSession struct { @@ -187,54 +178,32 @@ func (a *App) PollBotConnectionInstall(installID string) (BotInstallPollResult, func (a *App) DiagnoseBotConnection(id string) (BotConnectionDiagnostic, error) { cfg, err := a.loadDesktopBotConfig() if err != nil { - return botConnectionDiagnostic(nil, id, "error", "config", "config_load_failed", err.Error(), true), nil + return BotConnectionDiagnostic{ID: id, Status: "error", Message: err.Error()}, nil } for _, conn := range cfg.Bot.Connections { if conn.ID == id { status := "ok" message := "连接配置已保存。" - phase := "config" - code := "config_ok" - reportable := false if !conn.Enabled { status = "disabled" message = "连接已保存但未启用。" - code = "connection_disabled" } else if conn.Status != "connected" { status = firstNonEmptyBot(conn.Status, "pending") message = firstNonEmptyBot(conn.LastError, "连接还未完成。") - phase = "install" - code = "connection_not_connected" - reportable = status == "error" || strings.TrimSpace(conn.LastError) != "" } else if conn.Credential.AppSecretEnv != "" && strings.TrimSpace(conn.Credential.AppSecretEnv) != "" && !envIsSet(conn.Credential.AppSecretEnv) { status = "warning" message = conn.Credential.AppSecretEnv + " 未设置。" - phase = "credential" - code = "secret_missing" - reportable = true - } else if conn.Credential.TokenEnv != "" && strings.TrimSpace(conn.Credential.TokenEnv) != "" && !botCredentialSecretSet(conn) { - status = "warning" - message = conn.Credential.TokenEnv + " 未设置,且未找到已保存的登录凭据。" - phase = "credential" - code = "secret_missing" - reportable = true - } else if conn.Provider == "weixin" && !botCredentialSecretSet(conn) { - status = "warning" - message = "未找到已保存的微信登录凭据。" - phase = "credential" - code = "secret_missing" - reportable = true } - return botConnectionDiagnostic(&conn, conn.ID, status, phase, code, message, reportable), nil + return BotConnectionDiagnostic{ID: conn.ID, Label: conn.Label, Status: status, Message: message}, nil } } - return botConnectionDiagnostic(nil, id, "missing", "config", "connection_missing", "未找到连接。", true), nil + return BotConnectionDiagnostic{ID: id, Status: "missing", Message: "未找到连接。"}, nil } func (a *App) TestBotConnection(id, target string) (BotConnectionDiagnostic, error) { cfg, err := a.loadDesktopBotConfig() if err != nil { - return botConnectionDiagnostic(nil, id, "error", "config", "config_load_failed", err.Error(), true), nil + return BotConnectionDiagnostic{ID: id, Status: "error", Message: err.Error()}, nil } var conn *config.BotConnectionConfig for i := range cfg.Bot.Connections { @@ -244,14 +213,14 @@ func (a *App) TestBotConnection(id, target string) (BotConnectionDiagnostic, err } } if conn == nil { - return botConnectionDiagnostic(nil, id, "missing", "config", "connection_missing", "未找到连接。", true), nil + return BotConnectionDiagnostic{ID: id, Status: "missing", Message: "未找到连接。"}, nil } target = firstNonEmptyBot(strings.TrimSpace(target), firstSessionRemoteID(conn.SessionMappings)) if conn.Provider != "feishu" && conn.Provider != "weixin" { - return botConnectionDiagnostic(conn, conn.ID, "warning", "send", "test_send_unsupported", "当前渠道暂不支持桌面端主动发送测试消息,可使用诊断检查基础配置。", false), nil + return BotConnectionDiagnostic{ID: conn.ID, Label: conn.Label, Status: "warning", Message: "当前渠道暂不支持桌面端主动发送测试消息,可使用诊断检查基础配置。"}, nil } if target == "" { - return botConnectionDiagnostic(conn, conn.ID, "warning", "send", "test_target_missing", "请输入测试会话 ID 后再发送测试消息。", false), nil + return BotConnectionDiagnostic{ID: conn.ID, Label: conn.Label, Status: "warning", Message: "请输入测试会话 ID 后再发送测试消息。"}, nil } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() @@ -272,163 +241,14 @@ func (a *App) TestBotConnection(id, target string) (BotConnectionDiagnostic, err result, err = weixin.SendText(ctx, weixinCfg, target, "Reasonix bot 测试消息:连接和发送链路可用。") } if err != nil { - return botConnectionDiagnostic(conn, conn.ID, "error", "send", "test_send_failed", err.Error(), true), nil + return BotConnectionDiagnostic{ID: conn.ID, Label: conn.Label, Status: "error", Message: err.Error()}, nil } _ = a.rememberBotConnectionRemote(conn.ID, target) msg := "测试消息已发送。" if result.MessageID != "" { msg += " Message ID: " + result.MessageID } - diag := botConnectionDiagnostic(conn, conn.ID, "ok", "send", "test_send_ok", msg, false) - diag.MessageID = result.MessageID - return diag, nil -} - -func botConnectionDiagnostic(conn *config.BotConnectionConfig, id, status, phase, code, message string, reportable bool) BotConnectionDiagnostic { - id = strings.TrimSpace(id) - label := "" - if conn != nil { - id = firstNonEmptyBot(strings.TrimSpace(conn.ID), id) - label = strings.TrimSpace(conn.Label) - } - occurredAt := time.Now().UTC().Format(time.RFC3339) - diag := BotConnectionDiagnostic{ - ID: id, - Label: label, - Status: strings.TrimSpace(status), - Message: strings.TrimSpace(message), - Phase: strings.TrimSpace(phase), - Code: strings.TrimSpace(code), - OccurredAt: occurredAt, - } - if reportable { - diag.ReportKind = "bot" - diag.ReportDetail = botConnectionReportDetail(conn, id, diag.Status, diag.Phase, diag.Code, diag.Message, occurredAt) - if diag.ReportDetail == "" { - diag.ReportKind = "" - } - } - return diag -} - -func botConnectionReportDetail(conn *config.BotConnectionConfig, fallbackID, status, phase, code, message, occurredAt string) string { - provider := "unknown" - domain := "unknown" - configuredStatus := "" - enabled := false - workspaceScope := "global" - sessionMappings := 0 - appIDSet := false - appSecretEnvConfigured := false - tokenEnvConfigured := false - secretAvailable := false - if conn != nil { - provider = firstNonEmptyBot(strings.TrimSpace(conn.Provider), provider) - domain = firstNonEmptyBot(strings.TrimSpace(conn.Domain), domain) - configuredStatus = strings.TrimSpace(conn.Status) - enabled = conn.Enabled - if strings.TrimSpace(conn.WorkspaceRoot) != "" { - workspaceScope = "project" - } - sessionMappings = len(conn.SessionMappings) - appIDSet = strings.TrimSpace(conn.Credential.AppID) != "" - appSecretEnvConfigured = strings.TrimSpace(conn.Credential.AppSecretEnv) != "" - tokenEnvConfigured = strings.TrimSpace(conn.Credential.TokenEnv) != "" - secretAvailable = botCredentialSecretSet(*conn) - } - summary := botConnectionReportSummary(code, message) - lines := []string{ - "Bot connection diagnostic", - "", - "connection_id: " + safeBotReportValue(fallbackID), - "provider: " + safeBotReportValue(provider), - "domain: " + safeBotReportValue(domain), - "status: " + safeBotReportValue(status), - "phase: " + safeBotReportValue(phase), - "code: " + safeBotReportValue(code), - fmt.Sprintf("enabled: %t", enabled), - "configured_status: " + safeBotReportValue(configuredStatus), - fmt.Sprintf("app_id_set: %t", appIDSet), - fmt.Sprintf("app_secret_env_configured: %t", appSecretEnvConfigured), - fmt.Sprintf("token_env_configured: %t", tokenEnvConfigured), - fmt.Sprintf("secret_available: %t", secretAvailable), - "workspace_scope: " + workspaceScope, - fmt.Sprintf("session_mappings: %d", sessionMappings), - "", - "summary: " + summary, - } - payload := frontendCrashPayload{ - SchemaVersion: 2, - Kind: "bot", - Source: "bot.runtime", - Label: botConnectionReportLabel(provider, domain, phase), - Message: strings.Join(lines, "\n"), - ErrorType: "BotConnectionDiagnostic", - ErrorMessage: summary, - TopFrame: "bot." + safeBotReportSegment(phase), - OccurredAt: occurredAt, - } - detail, err := json.Marshal(payload) - if err != nil { - return "" - } - return string(detail) -} - -func botConnectionReportSummary(code, message string) string { - switch strings.TrimSpace(code) { - case "config_load_failed": - return "desktop bot config could not be loaded: " + scrubSensitiveText(message) - case "connection_missing": - return "bot connection record was not found" - case "connection_not_connected": - return "bot connection is not connected: " + scrubSensitiveText(message) - case "secret_missing": - return "required bot credential is not available" - case "test_send_failed": - return "bot test message failed: " + scrubSensitiveText(message) - default: - if strings.TrimSpace(message) == "" { - return strings.TrimSpace(code) - } - return scrubSensitiveText(message) - } -} - -func botConnectionReportLabel(provider, domain, phase string) string { - parts := []string{"bot", safeBotReportSegment(provider), safeBotReportSegment(domain), safeBotReportSegment(phase)} - return strings.Trim(strings.Join(parts, "."), ".") -} - -func safeBotReportSegment(s string) string { - s = strings.ToLower(strings.TrimSpace(s)) - if s == "" { - return "unknown" - } - var b strings.Builder - for _, r := range s { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' { - b.WriteRune(r) - continue - } - if b.Len() == 0 || strings.HasSuffix(b.String(), ".") { - continue - } - b.WriteByte('.') - } - out := strings.Trim(b.String(), ".") - if out == "" { - return "unknown" - } - return out -} - -func safeBotReportValue(s string) string { - s = safeBotReportSegment(s) - if len(s) > 80 { - return s[:80] - } - return s + return BotConnectionDiagnostic{ID: conn.ID, Label: conn.Label, Status: "ok", Message: msg, MessageID: result.MessageID}, nil } func (a *App) startFeishuConnectionInstall(domain string) (BotInstallStartResult, error) { @@ -834,10 +654,6 @@ func botSessionMappingViews(mappings []config.BotConnectionSessionMapping, conne out = append(out, BotConnectionSessionMappingView{ RemoteID: m.RemoteID, SessionID: m.SessionID, - SessionSource: m.SessionSource, - ChatType: m.ChatType, - UserID: m.UserID, - ThreadID: m.ThreadID, Scope: scope, WorkspaceRoot: botMappingWorkspaceRoot(scope, workspaceRoot), UpdatedAt: m.UpdatedAt, @@ -857,10 +673,6 @@ func botSessionMappingConfigs(mappings []BotConnectionSessionMappingView, connec out = append(out, config.BotConnectionSessionMapping{ RemoteID: strings.TrimSpace(m.RemoteID), SessionID: strings.TrimSpace(m.SessionID), - SessionSource: strings.TrimSpace(m.SessionSource), - ChatType: strings.TrimSpace(m.ChatType), - UserID: strings.TrimSpace(m.UserID), - ThreadID: strings.TrimSpace(m.ThreadID), Scope: scope, WorkspaceRoot: botMappingWorkspaceRoot(scope, workspaceRoot), UpdatedAt: strings.TrimSpace(m.UpdatedAt), diff --git a/desktop/bot_connection_app_test.go b/desktop/bot_connection_app_test.go index 39f59d588..a2d9a7e5e 100644 --- a/desktop/bot_connection_app_test.go +++ b/desktop/bot_connection_app_test.go @@ -409,128 +409,6 @@ func TestFeishuRegistrationQRCodeURLAddsSDKMetadata(t *testing.T) { } } -func TestDiagnoseBotConnectionBuildsReportDetailForMissingSecret(t *testing.T) { - isolateDesktopUserDirs(t) - t.Setenv("FEISHU_BOT_APP_SECRET_PRIVATE", "") - app := NewApp() - if _, err := app.upsertBotConnection(config.BotConnectionConfig{ - ID: "feishu-lark", - Provider: "feishu", - Domain: "lark", - Label: "Lark", - Enabled: true, - Status: "connected", - WorkspaceRoot: "/Users/alice/work/reasonix", - Credential: config.BotConnectionCredential{ - AppID: "cli-private", - AppSecretEnv: "FEISHU_BOT_APP_SECRET_PRIVATE", - }, - SessionMappings: []config.BotConnectionSessionMapping{{ - RemoteID: "ou-private", - SessionID: "session-private", - Scope: "project", - WorkspaceRoot: "/Users/alice/work/reasonix", - }}, - }, nil); err != nil { - t.Fatalf("upsert connection: %v", err) - } - - diag, err := app.DiagnoseBotConnection("feishu-lark") - if err != nil { - t.Fatalf("DiagnoseBotConnection: %v", err) - } - if diag.Status != "warning" || diag.Phase != "credential" || diag.Code != "secret_missing" || diag.ReportKind != "bot" || diag.ReportDetail == "" { - t.Fatalf("diagnostic = %+v, want warning credential report", diag) - } - for _, leaked := range []string{"FEISHU_BOT_APP_SECRET_PRIVATE", "/Users/alice", "ou-private", "session-private"} { - if strings.Contains(diag.ReportDetail, leaked) { - t.Fatalf("diagnostic report leaked %q in %s", leaked, diag.ReportDetail) - } - } - var payload frontendCrashPayload - if err := json.Unmarshal([]byte(diag.ReportDetail), &payload); err != nil { - t.Fatalf("report detail is not structured JSON: %v", err) - } - if payload.Kind != "bot" || payload.Source != "bot.runtime" || payload.Label != "bot.feishu.lark.credential" { - t.Fatalf("payload = %+v, want bot runtime credential label", payload) - } - for _, want := range []string{ - "app_secret_env_configured: true", - "secret_available: false", - "workspace_scope: project", - "session_mappings: 1", - "summary: required bot credential is not available", - } { - if !strings.Contains(payload.Message, want) { - t.Fatalf("payload message = %q, want it to contain %q", payload.Message, want) - } - } - report, err := crashReportFromDetail(diag.ReportKind, diag.ReportDetail) - if err != nil { - t.Fatalf("crashReportFromDetail: %v", err) - } - if report.Kind != "bot" || report.Source != "bot.runtime" || report.ErrorType != "BotConnectionDiagnostic" { - t.Fatalf("report = %+v, want accepted bot report", report) - } -} - -func TestBotConnectionSendFailureReportRedactsEnvNames(t *testing.T) { - conn := config.BotConnectionConfig{ - ID: "feishu-lark", - Provider: "feishu", - Domain: "lark", - Label: "Lark", - Enabled: true, - Status: "connected", - Credential: config.BotConnectionCredential{ - AppSecretEnv: "FEISHU_BOT_APP_SECRET_PRIVATE", - }, - } - diag := botConnectionDiagnostic(&conn, conn.ID, "error", "send", "test_send_failed", "feishu app_id or FEISHU_BOT_APP_SECRET_PRIVATE is not configured", true) - if diag.ReportKind != "bot" || diag.ReportDetail == "" { - t.Fatalf("diagnostic = %+v, want reportable bot diagnostic", diag) - } - if strings.Contains(diag.ReportDetail, "FEISHU_BOT_APP_SECRET_PRIVATE") { - t.Fatalf("diagnostic report leaked env name in %s", diag.ReportDetail) - } - var payload frontendCrashPayload - if err := json.Unmarshal([]byte(diag.ReportDetail), &payload); err != nil { - t.Fatalf("report detail is not structured JSON: %v", err) - } - if !strings.Contains(payload.ErrorMessage, "[redacted-env]") { - t.Fatalf("payload errorMessage = %q, want redacted env marker", payload.ErrorMessage) - } -} - -func TestDiagnoseWeixinConnectionDetectsMissingSavedAccountWithoutTokenEnv(t *testing.T) { - isolateDesktopUserDirs(t) - app := NewApp() - if _, err := app.upsertBotConnection(config.BotConnectionConfig{ - ID: "weixin-weixin", - Provider: "weixin", - Domain: "weixin", - Label: "微信", - Enabled: true, - Status: "connected", - Credential: config.BotConnectionCredential{ - AccountID: "missing-account", - }, - }, nil); err != nil { - t.Fatalf("upsert connection: %v", err) - } - - diag, err := app.DiagnoseBotConnection("weixin-weixin") - if err != nil { - t.Fatalf("DiagnoseBotConnection: %v", err) - } - if diag.Status != "warning" || diag.Phase != "credential" || diag.Code != "secret_missing" || diag.ReportKind != "bot" || diag.ReportDetail == "" { - t.Fatalf("diagnostic = %+v, want missing local credential warning", diag) - } - if strings.Contains(diag.ReportDetail, "missing-account") { - t.Fatalf("diagnostic report leaked account id in %s", diag.ReportDetail) - } -} - func TestRememberBotConnectionRemoteStoresStableScope(t *testing.T) { isolateDesktopUserDirs(t) app := NewApp() diff --git a/desktop/bot_runtime_app.go b/desktop/bot_runtime_app.go index 77ea71664..8f85b54cc 100644 --- a/desktop/bot_runtime_app.go +++ b/desktop/bot_runtime_app.go @@ -116,7 +116,6 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo }, Debounce: time.Duration(cfg.Bot.DebounceMs) * time.Millisecond, OnInbound: botruntime.NewRemoteRememberer(logger), - OnSessionReady: botruntime.NewSessionRememberer(logger), OnToolApprovalModeChange: onToolApprovalModeChange, } bindings := botruntime.AdapterBindings(cfg, plan.Enabled, nil, logger) diff --git a/desktop/build/appicon.png b/desktop/build/appicon.png index 5e92b8a93..b8b57c2d6 100644 Binary files a/desktop/build/appicon.png and b/desktop/build/appicon.png differ diff --git a/desktop/build/appicon.svg b/desktop/build/appicon.svg deleted file mode 100644 index fe2c1aa36..000000000 --- a/desktop/build/appicon.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/desktop/build/windows/icon.ico b/desktop/build/windows/icon.ico index e543775b8..a30ce32bd 100644 Binary files a/desktop/build/windows/icon.ico and b/desktop/build/windows/icon.ico differ diff --git a/desktop/builtin_mcp_updates.go b/desktop/builtin_mcp_updates.go deleted file mode 100644 index ce411c4ee..000000000 --- a/desktop/builtin_mcp_updates.go +++ /dev/null @@ -1,185 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - wruntime "github.com/wailsapp/wails/v2/pkg/runtime" - - "reasonix/internal/codegraph" - "reasonix/internal/config" -) - -var ( - checkCodegraphLatest = codegraph.LatestVersionWithClient - downloadLatestCodegraph = codegraph.DownloadLatestWithClient - builtInMCPUpdateNow = time.Now -) - -type BuiltInMCPUpdateStatus struct { - Name string `json:"name"` - Mode string `json:"mode"` - Current string `json:"current"` - Latest string `json:"latest"` - Phase string `json:"phase"` // current | available | downloaded | activated | skipped | error - Path string `json:"path,omitempty"` - Err string `json:"err,omitempty"` -} - -func (a *App) checkBuiltInMCPUpdates() { - cfg, err := config.Load() - if err != nil { - return - } - mode := cfg.BuiltInMCPUpdates.ResolvedMode() - if mode == config.BuiltInMCPUpdateModeOff { - return - } - if !shouldRunBuiltInMCPUpdateCheck(cfg.BuiltInMCPUpdates.CheckIntervalDuration()) { - return - } - statuses, _ := a.runBuiltInMCPUpdateCheck(cfg) - markBuiltInMCPUpdateChecked() - for _, status := range statuses { - a.recordBuiltInMCPUpdateStatus(status) - } -} - -func (a *App) runBuiltInMCPUpdateCheck(cfg *config.Config) ([]BuiltInMCPUpdateStatus, error) { - if cfg == nil { - cfg = config.Default() - } - mode := cfg.BuiltInMCPUpdates.ResolvedMode() - current := codegraph.ActiveVersion() - if current == "" { - current = codegraph.Version - } - status := BuiltInMCPUpdateStatus{ - Name: "codegraph", - Mode: mode, - Current: current, - Phase: "skipped", - } - if mode == config.BuiltInMCPUpdateModeOff { - return []BuiltInMCPUpdateStatus{status}, nil - } - client, err := httpClient() - if err != nil { - status.Phase = "error" - status.Err = err.Error() - return []BuiltInMCPUpdateStatus{status}, nil - } - manifestCtx, cancel := context.WithTimeout(a.reqCtx(), httpTimeout) - defer cancel() - latest, err := checkCodegraphLatest(manifestCtx, client) - if err != nil { - status.Phase = "error" - status.Err = err.Error() - return []BuiltInMCPUpdateStatus{status}, nil - } - status.Latest = latest - if !codegraph.NewerThanActive(latest) { - status.Phase = "current" - return []BuiltInMCPUpdateStatus{status}, nil - } - switch mode { - case config.BuiltInMCPUpdateModeDownload: - res, err := downloadLatestCodegraph(a.reqCtx(), client, nil) - if err != nil { - status.Phase = "error" - status.Err = err.Error() - return []BuiltInMCPUpdateStatus{status}, nil - } - status.Latest = res.Version - status.Path = res.Path - status.Phase = "downloaded" - case config.BuiltInMCPUpdateModeAutoNextSession: - res, err := updateBuiltInCodegraph(a.reqCtx(), client, nil) - if err != nil { - status.Phase = "error" - status.Err = err.Error() - return []BuiltInMCPUpdateStatus{status}, nil - } - status.Latest = res.Version - status.Path = res.Path - status.Phase = "activated" - default: - status.Phase = "available" - } - return []BuiltInMCPUpdateStatus{status}, nil -} - -func (a *App) emitBuiltInMCPUpdateStatus(status BuiltInMCPUpdateStatus) { - if a.ctx == nil { - return - } - wruntime.EventsEmit(a.ctx, "builtin-mcp:update", status) -} - -func (a *App) recordBuiltInMCPUpdateStatus(status BuiltInMCPUpdateStatus) { - if strings.TrimSpace(status.Name) == "" { - return - } - a.builtInMCPUpdatesMu.Lock() - if a.builtInMCPUpdates == nil { - a.builtInMCPUpdates = map[string]BuiltInMCPUpdateStatus{} - } - a.builtInMCPUpdates[status.Name] = status - a.builtInMCPUpdatesMu.Unlock() - a.emitBuiltInMCPUpdateStatus(status) -} - -func (a *App) BuiltInMCPUpdateStatuses() []BuiltInMCPUpdateStatus { - a.builtInMCPUpdatesMu.RLock() - defer a.builtInMCPUpdatesMu.RUnlock() - out := make([]BuiltInMCPUpdateStatus, 0, len(a.builtInMCPUpdates)) - for _, status := range a.builtInMCPUpdates { - out = append(out, status) - } - return out -} - -func shouldRunBuiltInMCPUpdateCheck(interval time.Duration) bool { - if interval <= 0 { - interval = 24 * time.Hour - } - path, err := builtInMCPUpdateStampPath() - if err != nil { - return true - } - data, err := os.ReadFile(path) - if err != nil { - return true - } - last, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(string(data))) - if err != nil { - return true - } - return !builtInMCPUpdateNow().Before(last.Add(interval)) -} - -func markBuiltInMCPUpdateChecked() { - path, err := builtInMCPUpdateStampPath() - if err != nil { - return - } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return - } - _ = os.WriteFile(path, []byte(builtInMCPUpdateNow().Format(time.RFC3339Nano)+"\n"), 0o644) -} - -func builtInMCPUpdateStampPath() (string, error) { - base, err := os.UserCacheDir() - if err != nil { - return "", err - } - if base == "" { - return "", fmt.Errorf("user cache dir is empty") - } - return filepath.Join(base, "reasonix", "builtin-mcp-updates", "last-check"), nil -} diff --git a/desktop/crash_app.go b/desktop/crash_app.go index 2fb1728fe..203003577 100644 --- a/desktop/crash_app.go +++ b/desktop/crash_app.go @@ -11,8 +11,8 @@ import ( "strings" ) -// crash_app.go is the crash/feedback/performance reporting surface. Reports are -// sent only on an explicit user click in the frontend UI — never automatically. +// crash_app.go is the crash/feedback reporting surface. Reports are sent only on +// an explicit user click in the frontend crash overlay — never automatically. var crashEndpoint = "https://crash.reasonix.io/v1/report" @@ -27,7 +27,6 @@ var ( secretKeyValuePattern = regexp.MustCompile(`(?i)\b(api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|authorization|secret|password|passwd|pwd|token)\b\s*[:=]\s*(?:Bearer\s+)?['"]?[^'"\s,;]+['"]?`) bearerTokenPattern = regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9._~+/=-]{16,}`) explicitKeyPattern = regexp.MustCompile(`\b(?:sk|rk)-(?:proj-)?[A-Za-z0-9_-]{16,}\b`) - envIdentifierPattern = regexp.MustCompile(`\b[A-Z][A-Z0-9_]*(?:API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PWD)[A-Z0-9_]*\b`) jwtPattern = regexp.MustCompile(`\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b`) longHexPattern = regexp.MustCompile(`\b[0-9a-fA-F]{32,}\b`) longBase64Pattern = regexp.MustCompile(`[A-Za-z0-9+/]{40,}={0,2}`) @@ -43,7 +42,6 @@ func scrubSensitiveText(s string) string { s = emailPattern.ReplaceAllString(s, "[redacted-email]") s = bearerTokenPattern.ReplaceAllString(s, "Bearer [redacted]") s = secretKeyValuePattern.ReplaceAllString(s, "${1}=[redacted]") - s = envIdentifierPattern.ReplaceAllString(s, "[redacted-env]") s = jwtPattern.ReplaceAllString(s, "[redacted-jwt]") s = explicitKeyPattern.ReplaceAllString(s, "[redacted-key]") s = longHexPattern.ReplaceAllString(s, "[redacted-hex]") @@ -102,7 +100,7 @@ type frontendCrashPayload struct { func normalizeReportKind(kind string) (string, bool) { switch strings.TrimSpace(kind) { - case "crash", "exception", "feedback", "performance", "bot": + case "crash", "exception", "feedback": return strings.TrimSpace(kind), true default: return "", false @@ -167,28 +165,6 @@ func topFrameFromStack(stack string) string { return "" } -func nativeResourceContext() string { - var m runtime.MemStats - runtime.ReadMemStats(&m) - mb := func(n uint64) string { - return fmt.Sprintf("%.1f MB", float64(n)/1024/1024) - } - return strings.Join([]string{ - "go heap alloc: " + mb(m.Alloc), - "go heap sys: " + mb(m.HeapSys), - "go total sys: " + mb(m.Sys), - fmt.Sprintf("goroutines: %d", runtime.NumGoroutine()), - fmt.Sprintf("gc cycles: %d", m.NumGC), - }, "\n") -} - -func appendNativeResourceContext(kind, message string) string { - if kind != "performance" { - return message - } - return sanitizeCrashText(message+"\n\n--- native runtime context ---\n"+nativeResourceContext(), maxCrashDetailBytes) -} - func crashReportFromDetail(kind, detail string) (crashReport, error) { rawKind := kind kind, ok := normalizeReportKind(kind) @@ -229,7 +205,6 @@ func crashReportFromDetail(kind, detail string) (crashReport, error) { if r.Source == "" { r.Source = "frontend" } - r.Message = appendNativeResourceContext(r.Kind, r.Message) return r, nil } @@ -237,7 +212,6 @@ func crashReportFromDetail(kind, detail string) (crashReport, error) { r.Source = "legacy" r.Label = kind r.Message = sanitizeCrashText(detail, maxCrashDetailBytes) - r.Message = appendNativeResourceContext(r.Kind, r.Message) return r, nil } diff --git a/desktop/crash_app_test.go b/desktop/crash_app_test.go index 0b9a956eb..be97a1aeb 100644 --- a/desktop/crash_app_test.go +++ b/desktop/crash_app_test.go @@ -33,14 +33,14 @@ func TestScrubSensitiveText(t *testing.T) { bearer := "abcdefghijklmnopqrstuvwxyz1234567890ABCDE" longHex := "0123456789abcdef0123456789abcdef" jwt := "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.signature" - got := scrubSensitiveText("user dev@example.com Authorization: Bearer " + bearer + " api_key=" + apiKey + " jwt " + jwt + " hash " + longHex + " env FEISHU_BOT_APP_SECRET WEIXIN_BOT_TOKEN short abc1234 path /Users/alice/x") + got := scrubSensitiveText("user dev@example.com Authorization: Bearer " + bearer + " api_key=" + apiKey + " jwt " + jwt + " hash " + longHex + " short abc1234 path /Users/alice/x") - for _, leaked := range []string{"dev@example.com", bearer, apiKey, jwt, longHex, "FEISHU_BOT_APP_SECRET", "WEIXIN_BOT_TOKEN", "alice"} { + for _, leaked := range []string{"dev@example.com", bearer, apiKey, jwt, longHex, "alice"} { if strings.Contains(got, leaked) { t.Fatalf("sensitive text leaked %q in %q", leaked, got) } } - for _, want := range []string{"[redacted-email]", "Authorization=[redacted]", "api_key=[redacted]", "[redacted-jwt]", "[redacted-hex]", "[redacted-env]", "short abc1234", "/Users/_/x"} { + for _, want := range []string{"[redacted-email]", "Authorization=[redacted]", "api_key=[redacted]", "[redacted-jwt]", "[redacted-hex]", "short abc1234", "/Users/_/x"} { if !strings.Contains(got, want) { t.Fatalf("scrubSensitiveText() = %q, want it to contain %q", got, want) } @@ -147,61 +147,3 @@ func TestCrashReportFromStructuredDetail(t *testing.T) { } } } - -func TestCrashReportFromPerformanceDetail(t *testing.T) { - payload := frontendCrashPayload{ - SchemaVersion: 2, - Kind: "performance", - Source: "frontend.performance", - Label: "performance.pressure", - Message: "[performance.pressure]\n\n--- performance context ---\nreason: event loop lag 1300ms", - ErrorType: "PerformancePressure", - ErrorMessage: "UI responsiveness degraded because the app observed long tasks, event-loop lag, or high JS heap pressure.", - TopFrame: "frontend.performance", - } - detail, err := json.Marshal(payload) - if err != nil { - t.Fatal(err) - } - r, err := crashReportFromDetail("performance", string(detail)) - if err != nil { - t.Fatal(err) - } - if r.Kind != "performance" || r.Source != "frontend.performance" || r.Label != "performance.pressure" { - t.Fatalf("performance fields not preserved: %+v", r) - } - if !strings.Contains(r.Message, "--- native runtime context ---") || !strings.Contains(r.Message, "goroutines:") { - t.Fatalf("native runtime context missing from performance report: %q", r.Message) - } -} - -func TestCrashReportFromBotDetail(t *testing.T) { - token := "abcdefghijklmnopqrstuvwxyz1234567890ABCDE" - payload := frontendCrashPayload{ - SchemaVersion: 2, - Kind: "bot", - Source: "bot.runtime", - Label: "bot.feishu.lark.send", - Message: "[bot]\n\nfailed at /Users/alice/project with token=" + token, - ErrorType: "BotConnectionDiagnostic", - ErrorMessage: "send failed with Bearer " + token, - TopFrame: "bot.send", - } - detail, err := json.Marshal(payload) - if err != nil { - t.Fatal(err) - } - r, err := crashReportFromDetail("bot", string(detail)) - if err != nil { - t.Fatal(err) - } - if r.Kind != "bot" || r.Source != "bot.runtime" || r.Label != "bot.feishu.lark.send" { - t.Fatalf("bot fields not preserved: %+v", r) - } - if strings.Contains(r.Message, "alice") || strings.Contains(r.Message, token) || strings.Contains(r.ErrorMessage, token) { - t.Fatalf("bot report was not scrubbed: %+v", r) - } - if strings.Contains(r.Message, "--- native runtime context ---") { - t.Fatalf("bot report should not include performance runtime context: %q", r.Message) - } -} diff --git a/desktop/frontend/package-lock.json b/desktop/frontend/package-lock.json index ec58a4fc7..515af9176 100644 --- a/desktop/frontend/package-lock.json +++ b/desktop/frontend/package-lock.json @@ -316,9 +316,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], @@ -333,9 +333,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], @@ -350,9 +350,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], @@ -367,9 +367,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], @@ -384,9 +384,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], @@ -401,9 +401,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], @@ -418,9 +418,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], @@ -435,9 +435,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], @@ -452,9 +452,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], @@ -469,9 +469,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], @@ -486,9 +486,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], @@ -503,9 +503,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], @@ -520,9 +520,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], @@ -537,9 +537,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], @@ -554,9 +554,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], @@ -571,9 +571,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], @@ -588,9 +588,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], @@ -605,9 +605,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", "cpu": [ "arm64" ], @@ -622,9 +622,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], @@ -639,9 +639,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "cpu": [ "arm64" ], @@ -656,9 +656,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], @@ -673,9 +673,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", "cpu": [ "arm64" ], @@ -690,9 +690,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], @@ -707,9 +707,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], @@ -724,9 +724,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], @@ -741,9 +741,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], @@ -1625,9 +1625,9 @@ } }, "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1638,32 +1638,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, "node_modules/escalade": { @@ -3508,6 +3508,490 @@ "fsevents": "~2.3.3" } }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json index 731c4c72d..e7507447f 100644 --- a/desktop/frontend/package.json +++ b/desktop/frontend/package.json @@ -10,9 +10,9 @@ "check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css", "test:todo-visibility": "node scripts/test-todo-visibility.mjs", "typecheck": "tsc --noEmit", - "test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts", + "test": "tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/render-optimization.test.ts", "test:typecheck": "tsc --noEmit -p tsconfig.test.json", - "test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts" + "test:all": "tsc --noEmit -p tsconfig.test.json && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/render-optimization.test.ts" }, "dependencies": { "@gsap/react": "^2.1.2", @@ -40,7 +40,6 @@ "vite": "^6.0.7" }, "overrides": { - "esbuild": "0.28.1", "mdast-util-gfm-autolink-literal": "2.0.0" } } diff --git a/desktop/frontend/pnpm-lock.yaml b/desktop/frontend/pnpm-lock.yaml index 121d692aa..918f2aea9 100644 --- a/desktop/frontend/pnpm-lock.yaml +++ b/desktop/frontend/pnpm-lock.yaml @@ -5,7 +5,6 @@ settings: excludeLinksFromLockfile: false overrides: - esbuild: 0.28.1 mdast-util-gfm-autolink-literal: 2.0.0 importers: @@ -162,158 +161,314 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -380,66 +535,79 @@ packages: resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.4': resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.4': resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.4': resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.4': resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.4': resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.4': resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.4': resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.4': resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.4': resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.4': resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.4': resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.4': resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.4': resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} @@ -623,8 +791,13 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} engines: {node: '>=18'} hasBin: true @@ -1228,82 +1401,160 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@esbuild/aix-ppc64@0.28.1': + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.28.0': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.28.0': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.28.0': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.28.0': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.28.0': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.28.0': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.28.0': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.28.0': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.28.0': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.28.0': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.28.0': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.28.0': + optional: true + + '@esbuild/linux-mips64el@0.25.12': optional: true - '@esbuild/android-arm64@0.28.1': + '@esbuild/linux-mips64el@0.28.0': optional: true - '@esbuild/android-arm@0.28.1': + '@esbuild/linux-ppc64@0.25.12': optional: true - '@esbuild/android-x64@0.28.1': + '@esbuild/linux-ppc64@0.28.0': optional: true - '@esbuild/darwin-arm64@0.28.1': + '@esbuild/linux-riscv64@0.25.12': optional: true - '@esbuild/darwin-x64@0.28.1': + '@esbuild/linux-riscv64@0.28.0': optional: true - '@esbuild/freebsd-arm64@0.28.1': + '@esbuild/linux-s390x@0.25.12': optional: true - '@esbuild/freebsd-x64@0.28.1': + '@esbuild/linux-s390x@0.28.0': optional: true - '@esbuild/linux-arm64@0.28.1': + '@esbuild/linux-x64@0.25.12': optional: true - '@esbuild/linux-arm@0.28.1': + '@esbuild/linux-x64@0.28.0': optional: true - '@esbuild/linux-ia32@0.28.1': + '@esbuild/netbsd-arm64@0.25.12': optional: true - '@esbuild/linux-loong64@0.28.1': + '@esbuild/netbsd-arm64@0.28.0': optional: true - '@esbuild/linux-mips64el@0.28.1': + '@esbuild/netbsd-x64@0.25.12': optional: true - '@esbuild/linux-ppc64@0.28.1': + '@esbuild/netbsd-x64@0.28.0': optional: true - '@esbuild/linux-riscv64@0.28.1': + '@esbuild/openbsd-arm64@0.25.12': optional: true - '@esbuild/linux-s390x@0.28.1': + '@esbuild/openbsd-arm64@0.28.0': optional: true - '@esbuild/linux-x64@0.28.1': + '@esbuild/openbsd-x64@0.25.12': optional: true - '@esbuild/netbsd-arm64@0.28.1': + '@esbuild/openbsd-x64@0.28.0': optional: true - '@esbuild/netbsd-x64@0.28.1': + '@esbuild/openharmony-arm64@0.25.12': optional: true - '@esbuild/openbsd-arm64@0.28.1': + '@esbuild/openharmony-arm64@0.28.0': optional: true - '@esbuild/openbsd-x64@0.28.1': + '@esbuild/sunos-x64@0.25.12': optional: true - '@esbuild/openharmony-arm64@0.28.1': + '@esbuild/sunos-x64@0.28.0': optional: true - '@esbuild/sunos-x64@0.28.1': + '@esbuild/win32-arm64@0.25.12': optional: true - '@esbuild/win32-arm64@0.28.1': + '@esbuild/win32-arm64@0.28.0': optional: true - '@esbuild/win32-ia32@0.28.1': + '@esbuild/win32-ia32@0.25.12': optional: true - '@esbuild/win32-x64@0.28.1': + '@esbuild/win32-ia32@0.28.0': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.28.0': optional: true '@gsap/react@2.1.2(gsap@3.15.0)(react@19.2.7)': @@ -1551,34 +1802,63 @@ snapshots: entities@6.0.1: {} - esbuild@0.28.1: + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.28.0: optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 escalade@3.2.0: {} @@ -2283,7 +2563,7 @@ snapshots: tsx@4.22.4: dependencies: - esbuild: 0.28.1 + esbuild: 0.28.0 optionalDependencies: fsevents: 2.3.3 @@ -2357,7 +2637,7 @@ snapshots: vite@6.4.2(@types/node@25.9.1)(terser@5.48.0)(tsx@4.22.4): dependencies: - esbuild: 0.28.1 + esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.15 diff --git a/desktop/frontend/pnpm-workspace.yaml b/desktop/frontend/pnpm-workspace.yaml index 4a17d1b80..2ed5bb54b 100644 --- a/desktop/frontend/pnpm-workspace.yaml +++ b/desktop/frontend/pnpm-workspace.yaml @@ -2,5 +2,4 @@ allowBuilds: esbuild: true overrides: - esbuild: 0.28.1 mdast-util-gfm-autolink-literal: 2.0.0 diff --git a/desktop/frontend/scripts/test-todo-visibility.mjs b/desktop/frontend/scripts/test-todo-visibility.mjs index 80ab114c6..cbe436ea9 100644 --- a/desktop/frontend/scripts/test-todo-visibility.mjs +++ b/desktop/frontend/scripts/test-todo-visibility.mjs @@ -16,21 +16,17 @@ const transpiled = ts.transpileModule(source, { }).outputText; const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled).toString("base64")}`; -const { shouldShowTodoPanel, todoDismissalKey } = await import(moduleUrl); +const { shouldShowTodoPanel } = await import(moduleUrl); const completedTodos = [ { content: "Inspect the report", status: "completed" }, { content: "Ship the fix", status: "completed" }, ]; -const activeTodos = [ - { content: "Inspect the report", status: "in_progress" }, - { content: "Ship the fix", status: "pending" }, -]; assert.equal( shouldShowTodoPanel("todo-final", null, completedTodos), - false, - "the final all-completed todo_write must auto-collapse", + true, + "the final all-completed todo_write must remain visible", ); assert.equal( shouldShowTodoPanel("todo-active", null, [{ content: "Run tests", status: "in_progress" }]), @@ -45,28 +41,11 @@ assert.equal( assert.equal(shouldShowTodoPanel(null, null, completedTodos), false, "no canonical todo item means no panel"); assert.equal(shouldShowTodoPanel("todo-empty", null, []), false, "empty todo lists do not render a panel"); -const activeKey = todoDismissalKey(activeTodos); -assert.equal( - activeKey, - todoDismissalKey(activeTodos.map((todo) => ({ ...todo }))), - "the same task list keeps a stable dismissal key across restored event ids", -); -assert.equal( - shouldShowTodoPanel(activeKey, activeKey, activeTodos), - false, - "a user dismissal hides the same restored todo list", -); -assert.notEqual( - activeKey, - todoDismissalKey([{ ...activeTodos[0], status: "completed" }, { ...activeTodos[1], status: "in_progress" }]), - "real progress produces a fresh dismissal key", -); - const iterations = 200_000; const started = performance.now(); for (let i = 0; i < iterations; i += 1) { - if (shouldShowTodoPanel("todo-perf", null, completedTodos)) { - throw new Error("unexpected visible todo panel during performance loop"); + if (!shouldShowTodoPanel("todo-perf", null, completedTodos)) { + throw new Error("unexpected hidden todo panel during performance loop"); } } const elapsed = performance.now() - started; diff --git a/desktop/frontend/src/App.tsx b/desktop/frontend/src/App.tsx index d70dd84cb..a1665ab9e 100644 --- a/desktop/frontend/src/App.tsx +++ b/desktop/frontend/src/App.tsx @@ -30,7 +30,7 @@ import { useToast } from "./lib/toast"; import { asArray } from "./lib/array"; import { clearLegacyLangPref, normalizeLangPref, readLegacyLangPref, useI18n, useT, type Translator } from "./lib/i18n"; import { useController, type Item, type LiveStream } from "./lib/useController"; -import { app, onBuiltInMCPUpdate, onEvent, onProjectTreeChanged } from "./lib/bridge"; +import { app, onEvent, onProjectTreeChanged } from "./lib/bridge"; import { generativeMusic, isGenerativeMusicEnabled } from "./lib/generative-music"; import { playSuccessChime } from "./lib/sound"; import { Transcript } from "./components/Transcript"; @@ -54,10 +54,9 @@ import { AppChrome } from "./components/AppChrome"; import { ProjectTree } from "./components/ProjectTree"; import { CopyButton } from "./components/CopyButton"; import { parseTodos } from "./lib/tools"; -import { shouldShowTodoPanel, todoDismissalKey } from "./lib/todoVisibility"; +import { shouldShowTodoPanel } from "./lib/todoVisibility"; import { type BotConnectionView, - type BotRuntimeStatusView, type BotSettingsView, type CollaborationMode, type ComposerInsertRequest, @@ -122,12 +121,6 @@ const WORKSPACE_RESIZER_WIDTH = 8; function isThemeMode(value: string): value is Theme { return value === "auto" || value === "light" || value === "dark"; } - -type DesktopLayoutStyle = "classic" | "workbench"; - -function normalizeDesktopLayoutStyle(style: string | undefined): DesktopLayoutStyle { - return style === "workbench" ? "workbench" : "classic"; -} const RIGHT_DOCK_TREE_DEFAULT_WIDTH = 300; const RIGHT_DOCK_TREE_MIN_WIDTH = 300; const RIGHT_DOCK_TREE_MAX_WIDTH = 560; @@ -148,11 +141,10 @@ type HistoryViewState = | { kind: "history"; source: "scope"; filter: HistoryScopeFilter; sessions: SessionMeta[] } | { kind: "history"; source: "all"; sessions: SessionMeta[] } | { kind: "trash"; sessions: SessionMeta[] }; -type SidebarImPlatform = "qq" | "feishu" | "lark" | "weixin"; +type SidebarImPlatform = "feishu" | "lark" | "weixin"; type SidebarImStatus = "connected" | "disabled" | "pending" | "error" | "disconnected"; type SidebarImConnection = { id: string; - connectionId: string; platform: SidebarImPlatform; title: string; platformLabel: string; @@ -193,12 +185,19 @@ function sidebarImPlatform(connection: BotConnectionView): SidebarImPlatform { } function sidebarImPlatformLabel(platform: SidebarImPlatform, translate: Translator): string { - if (platform === "qq") return "QQ"; if (platform === "lark") return "Lark"; if (platform === "weixin") return translate("settings.botWeixin"); return translate("settings.botFeishu"); } +function firstBotSessionMapping(connection: BotConnectionView): BotConnectionView["sessionMappings"][number] | null { + return ( + connection.sessionMappings.find((mapping) => mapping.sessionId.trim()) ?? + connection.sessionMappings.find((mapping) => mapping.remoteId.trim()) ?? + null + ); +} + function botMappingScope(mapping: BotConnectionView["sessionMappings"][number] | null | undefined, connectionWorkspaceRoot: string): "global" | "project" { if (mapping?.scope === "project") return "project"; if ((mapping?.workspaceRoot ?? "").trim()) return "project"; @@ -219,15 +218,6 @@ function compactRemoteId(value: string): string { return `${trimmed.slice(0, 12)}…${trimmed.slice(-8)}`; } -function botMappingIdentityLabel(mapping: BotConnectionView["sessionMappings"][number] | null | undefined): string { - const chatType = (mapping?.chatType ?? "").trim(); - const userId = (mapping?.userId ?? "").trim(); - const threadId = (mapping?.threadId ?? "").trim(); - if (threadId) return compactRemoteId(threadId); - if ((chatType === "group" || chatType === "guild") && userId) return compactRemoteId(userId); - return ""; -} - function sidebarImStatus(connection: BotConnectionView, botEnabled: boolean): SidebarImStatus { if (!botEnabled || !connection.enabled) return "disabled"; if (connection.status === "connected") return "connected"; @@ -256,85 +246,18 @@ function uniqueTrimmedValues(values: string[]): string[] { } function sidebarImAllowlistUsers(bot: BotSettingsView, platform: SidebarImPlatform): string[] { - if (platform === "qq") return uniqueTrimmedValues(asArray(bot.allowlist.qqUsers)); if (platform === "weixin") return uniqueTrimmedValues(asArray(bot.allowlist.weixinUsers)); return uniqueTrimmedValues(asArray(bot.allowlist.feishuUsers)); } -function sidebarImQQAdded(qq: BotSettingsView["qq"]): boolean { - return Boolean(qq.enabled || qq.secretSet || qq.appId.trim()); -} - -function sidebarImQQStatus(bot: BotSettingsView, runtimeStatus: BotRuntimeStatusView | null | undefined): SidebarImStatus { - const appId = bot.qq.appId.trim(); - if (!bot.enabled || !bot.qq.enabled) return "disabled"; - if (!appId || !bot.qq.secretSet) return "disconnected"; - if (typeof window !== "undefined" && !window.runtime) return "pending"; - if (!runtimeStatus) return "pending"; - const status = runtimeStatus.status.trim().toLowerCase(); - if (runtimeStatus.running && runtimeStatus.connections > 0 && status === "running") { - return "connected"; - } - if (status === "error" || status === "blocked" || status === "degraded") return "error"; - if (status === "stopped") return "disconnected"; - return "pending"; -} - -async function loadBotRuntimeStatus(): Promise { - if (typeof window !== "undefined" && !window.runtime) return null; - try { - return await app.BotRuntimeStatus(); - } catch (e) { - console.warn("bot runtime status failed", e); - return null; - } -} - -function sidebarImQQConnection(bot: BotSettingsView, translate: Translator, runtimeStatus?: BotRuntimeStatusView | null): SidebarImConnection | null { - if (!sidebarImQQAdded(bot.qq)) return null; - const remoteId = bot.qq.appId.trim(); - const status = sidebarImQQStatus(bot, runtimeStatus); - const statusLabel = sidebarImStatusLabel(status, translate); - const allowlistUsers = sidebarImAllowlistUsers(bot, "qq"); - const subtitleParts = [ - remoteId ? compactRemoteId(remoteId) : "QQ", - statusLabel, - ].filter(Boolean); - return { - id: "__qq_bot__", - connectionId: "__qq_bot__", - platform: "qq", - title: "QQ Bot", - platformLabel: "QQ", - subtitle: subtitleParts.join(" · "), - status, - statusLabel, - remoteId, - sessionId: "", - scope: "global", - workspaceRoot: "", - allowAll: bot.allowlist.allowAll, - allowlistEnabled: bot.allowlist.enabled, - allowlistUsers, - allowlistMatched: remoteId ? allowlistUsers.includes(remoteId) : false, - }; -} - -function sidebarImConnectionsFromBot( - bot: BotSettingsView | null | undefined, - translate: Translator, - runtimeStatus?: BotRuntimeStatusView | null, -): SidebarImConnection[] { - if (!bot) return []; - const qqConnection = sidebarImQQConnection(bot, translate, runtimeStatus); - const connectionItems: SidebarImConnection[] = []; - for (const connection of asArray(bot.connections)) { - if (!isSidebarImConnection(connection)) continue; - const mappings = connection.sessionMappings.filter((mapping) => mapping.sessionId.trim() || mapping.remoteId.trim()); - const rowMappings = mappings.length > 0 ? mappings : [null]; - rowMappings.forEach((mapping, index) => { +function sidebarImConnectionsFromBot(bot: BotSettingsView | null | undefined, translate: Translator): SidebarImConnection[] { + if (!bot?.connections?.length) return []; + return bot.connections + .filter(isSidebarImConnection) + .map((connection) => { const platform = sidebarImPlatform(connection); const platformLabel = sidebarImPlatformLabel(platform, translate); + const mapping = firstBotSessionMapping(connection); const remoteId = mapping?.remoteId.trim() ?? ""; const sessionId = mapping?.sessionId.trim() ?? ""; const scope = botMappingScope(mapping, connection.workspaceRoot); @@ -342,17 +265,13 @@ function sidebarImConnectionsFromBot( const status = sidebarImStatus(connection, bot.enabled); const title = connection.label.trim() || platformLabel; const allowlistUsers = sidebarImAllowlistUsers(bot, platform); - const identityLabel = botMappingIdentityLabel(mapping); - const mappedUserId = mapping?.userId.trim() ?? ""; const subtitleParts = [ remoteId ? compactRemoteId(remoteId) : platformLabel, - identityLabel, connection.model.trim() || "", sidebarImStatusLabel(status, translate), ].filter(Boolean); - connectionItems.push({ - id: mapping ? `${connection.id}:mapping:${index}` : connection.id, - connectionId: connection.id, + return { + id: connection.id, platform, title, platformLabel, @@ -366,13 +285,9 @@ function sidebarImConnectionsFromBot( allowAll: bot.allowlist.allowAll, allowlistEnabled: bot.allowlist.enabled, allowlistUsers, - allowlistMatched: remoteId - ? allowlistUsers.includes(remoteId) || (mappedUserId ? allowlistUsers.includes(mappedUserId) : false) - : false, - }); + allowlistMatched: remoteId ? allowlistUsers.includes(remoteId) : false, + }; }); - } - return qqConnection ? [qqConnection, ...connectionItems] : connectionItems; } function mappedSessionTarget(sessionId: string): { kind: "path" | "topic"; value: string } | null { @@ -457,7 +372,7 @@ function SidebarImConnectionDetail({ connection, onClose, onOpenSession, onOpenS
{translate("botDetail.subtitle")} @@ -848,7 +763,6 @@ export default function App() { openGlobalTab, closeTab, reorderTabs, - openTopicSession, syncActiveTab, ensureBlankTab, } = useController(); @@ -865,7 +779,6 @@ export default function App() { const [needsOnboarding, setNeedsOnboarding] = useState(null); const [settingsTarget, setSettingsTarget] = useState(null); const [settingsFocus, setSettingsFocus] = useState(null); - const [desktopLayoutStyle, setDesktopLayoutStyle] = useState("classic"); const [startupUpdateChecksEnabled, setStartupUpdateChecksEnabled] = useState(null); const [histView, setHistView] = useState(null); const [paletteOpen, setPaletteOpen] = useState(false); @@ -907,21 +820,6 @@ export default function App() { return unsub; }, []); - useEffect(() => { - return onBuiltInMCPUpdate((status) => { - if (status.name !== "codegraph") return; - if (status.phase === "available") { - showToast(t("caps.updateToastAvailable", { name: "codegraph", latest: status.latest || "" })); - } else if (status.phase === "downloaded") { - showToast(t("caps.updateToastDownloaded", { name: "codegraph", latest: status.latest || "" })); - } else if (status.phase === "activated") { - showToast(t("caps.updateToastActivated", { name: "codegraph", latest: status.latest || "" })); - } else if (status.phase === "error") { - showToast(t("caps.updateToastError", { name: "codegraph" }), "warn"); - } - }); - }, [showToast, t]); - const [workspacePanelResizing, setWorkspacePanelResizing] = useState(false); const [workspacePanelMaximized, setWorkspacePanelMaximized] = useState(false); const [rightDockMode, setRightDockMode] = useState("context"); @@ -952,20 +850,14 @@ export default function App() { // Persist window geometry across launches. useWindowStatePersistence(); - useEffect(() => { - document.documentElement.setAttribute("data-platform", desktopPlatform); - }, [desktopPlatform]); const closeTransientOverlays = useCallback(() => { setTransientOverlayDismissSignal((signal) => signal + 1); }, []); const reloadSidebarImConnections = useCallback(async () => { - const [settings, runtimeStatus] = await Promise.all([ - app.Settings(), - loadBotRuntimeStatus(), - ]); - setSidebarImConnections(sidebarImConnectionsFromBot(settings.bot, t, runtimeStatus)); + const settings = await app.Settings(); + setSidebarImConnections(sidebarImConnectionsFromBot(settings.bot, t)); setImTopicSources(sidebarImTopicSourcesFromBot(settings.bot, t)); }, [t]); @@ -1066,11 +958,10 @@ export default function App() { }, []); const applyDesktopPreferences = useCallback( - (settings: Pick) => { + (settings: Pick) => { const nextTheme = normalizeThemePreference(settings.desktopTheme); const nextStyle = normalizeThemeStyleForTheme(settings.desktopThemeStyle, nextTheme); applyTheme(nextTheme, nextStyle, { persist: false }); - setDesktopLayoutStyle(normalizeDesktopLayoutStyle(settings.desktopLayoutStyle)); setLocalePref(normalizeLangPref(settings.desktopLanguage)); setStartupUpdateChecksEnabled(settings.checkUpdates !== false); setStatusBarStyle(settings.statusBarStyle === "text" ? "text" : "icon"); @@ -1089,14 +980,11 @@ export default function App() { clearLegacyLangPref(); clearLegacyThemePreference(); } - const [settings, runtimeStatus] = await Promise.all([ - app.Settings(), - loadBotRuntimeStatus(), - ]); + const settings = await app.Settings(); if (cancelled) return; applyDesktopPreferences(settings); hydrateDisplayMode(settings.displayMode); - setSidebarImConnections(sidebarImConnectionsFromBot(settings.bot, t, runtimeStatus)); + setSidebarImConnections(sidebarImConnectionsFromBot(settings.bot, t)); setImTopicSources(sidebarImTopicSourcesFromBot(settings.bot, t)); }; void syncDesktopPreferences().catch((e) => { @@ -1389,10 +1277,10 @@ export default function App() { // The live task list pinned above the composer comes from the most recent // successful top-level todo_write result; failed or still-running attempts do - // not advance the canonical panel state. It stays visible while any item is - // incomplete. It can be dismissed by the user (the ✕). A dismissal is keyed to - // the list's stable state, so host-advanced events and history reloads - // do not resurrect the same task list under a different event id. + // not advance the canonical panel state. It stays visible through the final + // all-completed update, and can be dismissed by the user (the ✕). A dismissal + // is keyed to that list's id, so a fresh accepted todo_write brings the panel + // back. const todoEntry = useMemo(() => { for (let i = state.items.length - 1; i >= 0; i--) { const it = state.items[i]; @@ -1405,8 +1293,7 @@ export default function App() { const todoItem = todoEntry?.item ?? null; const todos = useMemo(() => (todoItem ? parseTodos(todoItem.args) : []), [todoItem]); const [dismissedTodo, setDismissedTodo] = useState(null); - const todoKey = useMemo(() => todoDismissalKey(todos), [todos]); - const showTodos = shouldShowTodoPanel(todoKey, dismissedTodo, todos); + const showTodos = shouldShowTodoPanel(todoItem?.id, dismissedTodo, todos); const sessionTitle = topicTitle(activeTab); const sessionHasContent = state.items.length > 0 || Boolean(state.live?.text || state.live?.reasoning); @@ -1964,7 +1851,7 @@ export default function App() { const tabs = await switchTab(id); if (tabs) setTabMetas(tabs); setTabRevealSignal((signal) => signal + 1); - }, [closeTransientOverlays, switchTab]); + }, [closeTransientOverlays, setTabMetas, switchTab]); const handleTabClose = useCallback(async (id: string) => { closeTransientOverlays(); @@ -2078,6 +1965,7 @@ export default function App() { rewind(turn, scope).then(() => { refreshTabMetas(); setProjectRevision((v) => v + 1); + setTabRevealSignal((v) => v + 1); }); return; } @@ -2123,18 +2011,17 @@ export default function App() { setRewindSignal((v) => v + 1); }, [state.items, rewind, refreshTabMetas, setComposerInsertRequest]); - const handleOpenTopic = useCallback(async (scope: string, workspaceRoot: string, topicId: string, sessionPath?: string) => { + const handleOpenTopic = useCallback(async (scope: string, workspaceRoot: string, topicId: string) => { closeTransientOverlays(); setSidebarImDetailConnectionId(""); - if (sessionPath) { - await openTopicSession(scope, workspaceRoot, topicId, sessionPath); - } else if (scope === "global") { + if (scope === "global") { await openGlobalTab(topicId); } else { await openProjectTab(workspaceRoot, topicId); } await refreshTabMetas(); - }, [closeTransientOverlays, openGlobalTab, openProjectTab, openTopicSession, refreshTabMetas]); + setTabRevealSignal((signal) => signal + 1); + }, [closeTransientOverlays, openGlobalTab, openProjectTab, refreshTabMetas]); const openSidebarImConnectionSession = useCallback(async (connection: SidebarImConnection) => { const target = mappedSessionTarget(connection.sessionId); @@ -2154,6 +2041,7 @@ export default function App() { } await refreshTabMetas(); setProjectRevision((value) => value + 1); + setTabRevealSignal((signal) => signal + 1); } catch (err) { console.warn("bot sidebar open failed", err); showToast(t("sidebar.imOpenFailed", { name: connection.title })); @@ -2214,6 +2102,7 @@ export default function App() { setHistView(null); await resumeSession(session.path, targetTab.id); await refreshTabMetas(); + setTabRevealSignal((signal) => signal + 1); } catch (err: any) { setHistView(null); if (scope === "project" && session.workspaceRoot) { @@ -2432,102 +2321,15 @@ export default function App() { const sidebarImToggleLabel = !sidebarImHasConnections ? t("sidebar.im") : t(sidebarImExpanded ? "sidebar.imCollapse" : "sidebar.imExpand"); - const sidebarWorkbench = desktopLayoutStyle === "workbench"; - const sidebarClassName = [ - "sidebar", - sidebarCollapsed ? "sidebar--collapsed" : "", - sidebarWorkbench ? "sidebar--workbench" : "", - ].filter(Boolean).join(" "); - const sidebarImBlock = ( -
- - {sidebarImExpanded && sidebarImConnections.length > 0 && ( -
-
- {t("sidebar.imManage")} -
- - - -
-
-
- {sidebarImConnections.map((connection) => ( -
- -
- ))} -
-
- )} -
- ); return ( -
+
void openPalette()} /> -
+ )} )} diff --git a/desktop/frontend/src/components/CapabilitiesPanel.tsx b/desktop/frontend/src/components/CapabilitiesPanel.tsx index 7a869dd7e..e9b94466b 100644 --- a/desktop/frontend/src/components/CapabilitiesPanel.tsx +++ b/desktop/frontend/src/components/CapabilitiesPanel.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { asArray } from "../lib/array"; -import { app, onBuiltInMCPUpdate, openExternal } from "../lib/bridge"; +import { app, openExternal } from "../lib/bridge"; import { useT } from "../lib/i18n"; -import type { BuiltInMCPUpdateStatus, CapabilitiesView, MCPServerInput, ServerView, SkillRootSkillView, SkillRootView, SkillView } from "../lib/types"; +import type { CapabilitiesView, MCPServerInput, ServerView, SkillRootSkillView, SkillRootView, SkillView } from "../lib/types"; import { InlineConfirmButton } from "./InlineConfirmButton"; import { ResizableDrawer } from "./ResizableDrawer"; import { Tooltip } from "./Tooltip"; @@ -33,32 +33,13 @@ export function CapabilitiesPanel({ const [expandedErrors, setExpandedErrors] = useState>(() => new Set()); const [expandedServers, setExpandedServers] = useState>(() => new Set()); const [expandedServerTools, setExpandedServerTools] = useState>(() => new Set()); - const [updateStatuses, setUpdateStatuses] = useState>({}); const reload = useCallback(async () => { setView(normalizeCapabilitiesView(await app.Capabilities().catch(() => ({ servers: [], skills: [], skillRoots: [] })))); }, []); - const reloadUpdateStatuses = useCallback(async () => { - setUpdateStatuses(normalizeBuiltInMCPUpdateStatuses(await app.BuiltInMCPUpdateStatuses().catch(() => []))); - }, []); useEffect(() => { void reload(); }, [reload]); - useEffect(() => { - let cancelled = false; - void app.BuiltInMCPUpdateStatuses() - .then((statuses) => { - if (!cancelled) setUpdateStatuses(normalizeBuiltInMCPUpdateStatuses(statuses)); - }) - .catch(() => {}); - const unsubscribe = onBuiltInMCPUpdate((status) => { - setUpdateStatuses((prev) => mergeBuiltInMCPUpdateStatus(prev, status)); - }); - return () => { - cancelled = true; - unsubscribe(); - }; - }, []); useEffect(() => { if (tab !== "servers" || !view?.servers.some((s) => s.status === "initializing" || s.status === "deferred")) return; const id = window.setInterval(() => void reload(), 2500); @@ -83,12 +64,6 @@ export function CapabilitiesPanel({ } }; - const upgradeBuiltInMCP = async (name: string) => { - const ok = await mutate(() => app.UpdateBuiltInMCPServer(name)); - await reloadUpdateStatuses(); - return ok; - }; - const summary = useMemo(() => { if (!view) return ""; return t("caps.summary", { @@ -119,10 +94,6 @@ export function CapabilitiesPanel({ active: servers.filter((s) => s.status !== "failed"), }; }, [view]); - const visibleUpdateStatuses = useMemo( - () => Object.values(updateStatuses).filter((status) => isVisibleBuiltInMCPUpdateStatus(status)), - [updateStatuses], - ); const toggleSkill = useCallback((name: string) => { setExpandedSkills((prev) => { @@ -204,13 +175,6 @@ export function CapabilitiesPanel({ {tab === "servers" ? (
- {visibleUpdateStatuses.length > 0 && ( - void upgradeBuiltInMCP(name)} - /> - )}
{!adding && ( - )} -
- ); -} - function sortServersForDisplay(servers: ServerView[]): ServerView[] { return [...servers].sort((a, b) => { const priority = serverDisplayPriority(a) - serverDisplayPriority(b); @@ -725,7 +607,6 @@ function skillRootBadges(root: SkillRootView, t: ReturnType): Array function ServerGroup({ servers, - updates, expanded, expandedTools, busy, @@ -735,7 +616,6 @@ function ServerGroup({ onCancelEdit, onRetry, onReconnect, - onUpgrade, onConfirmClearAuth, onToggle, onUpdate, @@ -743,7 +623,6 @@ function ServerGroup({ onToggleTools, }: { servers: ServerView[]; - updates: Record; expanded: Set; expandedTools: Set; busy: boolean; @@ -753,7 +632,6 @@ function ServerGroup({ onCancelEdit: () => void; onRetry: (name: string) => void; onReconnect: (name: string) => void; - onUpgrade: (name: string) => void; onConfirmClearAuth: (name: string) => void; onToggle: (name: string, on: boolean) => void; onUpdate: (name: string, input: MCPServerInput) => void; @@ -767,7 +645,6 @@ function ServerGroup({ onRetry(s.name)} onReconnect={() => onReconnect(s.name)} - onUpgrade={() => onUpgrade(s.name)} onConfirmClearAuth={() => onConfirmClearAuth(s.name)} onToggle={(on) => onToggle(s.name, on)} onUpdate={(input) => onUpdate(s.name, input)} @@ -791,24 +667,20 @@ function ServerGroup({ function FailedServersNotice({ servers, - updates, expanded, busy, onToggle, onRetry, - onUpgrade, onRetryMany, onConfirmClearAuth, onConfirm, onConfirmMany, }: { servers: ServerView[]; - updates: Record; expanded: Set; busy: boolean; onToggle: (name: string) => void; onRetry: (name: string) => void; - onUpgrade: (name: string) => void; onRetryMany: (names: string[]) => void; onConfirmClearAuth: (name: string) => void; onConfirm: (name: string) => void; @@ -879,18 +751,12 @@ function FailedServersNotice({
{s.name}
{s.authStatus === "required" ? t("caps.authRequiredSummary") : summarizeServerError(error, t)}
- {updates[s.name] &&
{builtInMCPUpdateSummary(updates[s.name], t)}
}
- {canUpgradeBuiltInMCP(s, updates[s.name]) && ( - - )} {canClearAuth(s) && ( void; onRetry: () => void; onReconnect: () => void; - onUpgrade: () => void; onConfirmClearAuth: () => void; onToggle: (on: boolean) => void; onUpdate: (input: MCPServerInput) => void; @@ -988,10 +850,6 @@ function ServerRow({ if (s.authStatus === "possible" && s.status !== "failed") { sub = `${sub} · ${t("caps.authPossibleShort")}`; } - const updateSummary = builtInMCPRowUpdateSummary(s, updateStatus, t); - if (updateSummary) { - sub = `${sub} · ${updateSummary}`; - } const enabled = s.status === "connected" || s.status === "deferred" || s.status === "initializing"; const handlePrimaryAction = () => { if (shouldOpenAuth(s)) { @@ -1019,11 +877,6 @@ function ServerRow({ {s.name} {s.transport} {s.builtIn && {t("caps.builtIn")}} - {isVisibleBuiltInMCPUpdateStatusForServer(s, updateStatus) && ( - - {builtInMCPUpdateBadge(updateStatus, t)} - - )}
{sub}
@@ -1053,13 +906,11 @@ function ServerRow({ {expanded && ( void; onConnectNow: () => void; onReconnect: () => void; - onUpgrade: () => void; onConfirmClearAuth: () => void; toolsExpanded: boolean; editing: boolean; @@ -1111,8 +958,7 @@ function ServerDetails({ const canEditConfig = s.configured && !s.builtIn; const canConnectNow = s.status === "deferred" || s.status === "disabled"; const canReconnect = s.status === "connected"; - const canUpgrade = canUpgradeBuiltInMCP(s, updateStatus); - const canShowTools = s.status === "connected" && ((s.tools ?? 0) > 0 || (tools?.length ?? 0) > 0); + const canShowTools = (s.tools ?? 0) > 0 || (tools?.length ?? 0) > 0; const showClearAuth = canClearAuth(s); const authLabel = serverAuthLabel(s, t); if (editing && canEditConfig) { @@ -1139,12 +985,6 @@ function ServerDetails({ {authLabel}
)} - {s.builtIn && ( -
- {t("caps.updatePolicy")} - {builtInMCPUpdatePolicy(s, updateStatus, t)} -
- )} {command && (
{s.transport === "stdio" ? t("caps.command") : t("caps.url")} @@ -1169,11 +1009,6 @@ function ServerDetails({ {t("caps.reconnect")} )} - {canUpgrade && ( - - )} {canShowTools && ( ); } diff --git a/desktop/frontend/src/components/ErrorBoundary.tsx b/desktop/frontend/src/components/ErrorBoundary.tsx index 2861b61fe..eae0bb717 100644 --- a/desktop/frontend/src/components/ErrorBoundary.tsx +++ b/desktop/frontend/src/components/ErrorBoundary.tsx @@ -1,11 +1,11 @@ import { Component, type ReactNode } from "react"; import { reportCrash } from "../lib/crash"; -export class ErrorBoundary extends Component<{ children: ReactNode }, { crashed: boolean }> { - state = { crashed: false }; +export class ErrorBoundary extends Component<{ children: ReactNode }, { crashed: boolean; error: string }> { + state = { crashed: false, error: "" }; - static getDerivedStateFromError() { - return { crashed: true }; + static getDerivedStateFromError(error: unknown) { + return { crashed: true, error: String(error) }; } componentDidCatch(error: unknown, info: { componentStack?: string | null }) { @@ -13,6 +13,14 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, { crashed: } render() { - return this.state.crashed ? null : this.props.children; + if (this.state.crashed) { + return ( +
+

React Error

+
{this.state.error}
+
+ ); + } + return this.props.children; } } diff --git a/desktop/frontend/src/components/InlineDiff.tsx b/desktop/frontend/src/components/InlineDiff.tsx index 61c76c81d..308a301e9 100644 --- a/desktop/frontend/src/components/InlineDiff.tsx +++ b/desktop/frontend/src/components/InlineDiff.tsx @@ -64,18 +64,12 @@ export function InlineDiff({
-        
-          {visible.map((r, i) => (
-            
-              
-                {r.oldLine ?? ""}
-                {r.newLine ?? ""}
-                {r.type === "add" ? "+" : r.type === "del" ? "−" : " "}
-              
-              {r.text || " "}
-            
-          ))}
-        
+        {visible.map((r, i) => (
+          
+ {r.type === "add" ? "+" : r.type === "del" ? "−" : " "} + {r.text || " "} +
+ ))}
{hidden > 0 && ( - {!isSessionNode && compactTopics && ( - - - - - - - - - )} - {!isSessionNode && ( - - )} -
- ); - return ( -
- {row} - {hasChildren && ( -
-
- {children.map((child) => renderNode(child, depth + 1, section))} -
-
- )} +
); } const scope = node.kind === "global_folder" ? "global" : "project"; const scopeClass = scope === "global" ? " project-tree__folder--global" : " project-tree__folder--project"; - const pinnedClass = node.pinned ? " project-tree__folder--pinned" : ""; const accentStyle = projectAccentStyle(node.projectColor, scope === "global" ? "var(--project-tree-global-accent)" : undefined); const projectRoot = scope === "global" ? "" : node.root ?? ""; const projectDragKey = scope === "global" ? GLOBAL_PROJECT_ORDER_KEY : projectRoot; const projectPath = node.root ?? ""; const colorTargetRoot = scope === "global" ? "" : projectPath; const projectLabel = node.label || (scope === "global" ? "Global" : "Untitled"); - const projectPinned = Boolean(node.pinned); const projectActive = activeScope === scope && (scope === "global" || activeWorkspaceRoot === node.root); - const projectMenuOpen = menuProject?.key === key; - const activeTopicInProject = Boolean(activeTopicId) && activeScope === scope && (scope === "global" || activeWorkspaceRoot === projectRoot); - const draggableProject = section !== "pinned" && projectDragEnabled && depth === 0 && Boolean(projectDragKey) && editingProject?.key !== key; + const draggableProject = projectDragEnabled && depth === 0 && Boolean(projectDragKey) && editingProject?.key !== key; const projectDropPosition = dropProject?.root === projectDragKey ? dropProject.position : null; const handleProjectDragStart = (event: ReactDragEvent) => { if (!draggableProject) return; const target = event.target; - if (target instanceof Element && target.closest(".project-tree__action-slot,.project-tree__folder-action-slot")) { + if (target instanceof Element && target.closest(".project-tree__action-slot")) { event.preventDefault(); return; } @@ -1250,78 +901,6 @@ export function ProjectTree({ ] : []), ]; - const workbenchProjectMenuItems: ContextMenuItem[] = [ - ...(scope === "project" - ? [ - { - key: projectPinned ? "unpin-project" : "pin-project", - icon: , - label: t(projectPinned ? "projectTree.unpinProject" : "projectTree.pinProject"), - onSelect: () => { - void setProjectPinned(projectRoot, !projectPinned); - }, - }, - ] - : []), - { - key: "reveal", - icon: , - label: t(revealLabelKey(platform)), - disabled: !projectPath, - onSelect: () => { - void app.RevealPath(projectPath).catch(() => {}); - closeMenu(); - }, - }, - ...(scope === "project" - ? [ - { - key: "project-history", - icon: , - label: t("projectTree.projectHistory"), - onSelect: () => { - closeMenu(); - void onOpenProjectHistory(scope, projectRoot); - }, - }, - ] - : []), - { - key: "rename", - icon: , - label: t("projectTree.renameProjectWorkbench"), - onSelect: () => startRenameProject(key, projectRoot, projectLabel), - }, - { - key: "archive-active-topic", - icon: , - label: activeTopicId && confirmAction?.topicId === activeTopicId && confirmAction.action === "trash" - ? t("history.confirmMoveToTrash") - : t("projectTree.archiveConversation"), - disabled: !activeTopicInProject || !activeTopicId, - danger: true, - onSelect: () => { - if (!activeTopicId) return; - if (confirmAction?.topicId === activeTopicId && confirmAction.action === "trash") void trashTopic(activeTopicId); - else setConfirmAction({ topicId: activeTopicId, action: "trash" }); - }, - }, - ...(scope === "project" - ? [ - { type: "separator" as const, key: "remove-separator" }, - { - key: "remove", - icon: , - label: confirmRemoveProject === key ? t("projectTree.confirmRemoveProjectShort") : t("projectTree.removeProjectShort"), - danger: true, - onSelect: () => { - if (confirmRemoveProject === key) void removeProject(projectPath); - else setConfirmRemoveProject(key); - }, - }, - ] - : []), - ]; if (editingProject?.key === key) { return ( @@ -1345,7 +924,7 @@ export function ProjectTree({ {hasChildren && (
- {children.map((child) => renderNode(child, depth + 1, section))} + {children.map((child) => renderNode(child, depth + 1))}
)} @@ -1356,7 +935,7 @@ export function ProjectTree({ return (