| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | |
| 6 | "reasonix/internal/agent" |
| 7 | "reasonix/internal/control" |
| 8 | "reasonix/internal/session" |
| 9 | ) |
| 10 | |
| 11 | // SessionClearResult is the post-clear session identity the frontend must apply |
| 12 | // atomically so hydrate/mode-switch cannot re-bind to the destroyed transcript. |
| 13 | type SessionClearResult struct { |
| 14 | SessionPath string `json:"sessionPath"` |
| 15 | SessionID string `json:"sessionId,omitempty"` |
| 16 | Session *session.SessionRef `json:"session,omitempty"` |
| 17 | SessionRevision int64 `json:"sessionRevision,omitempty"` |
| 18 | SessionDigest string `json:"sessionDigest,omitempty"` |
| 19 | SessionGeneration uint64 `json:"sessionGeneration"` |
| 20 | } |
| 21 | |
| 22 | func initClearedPins(path string, newCtrl, oldCtrl control.SessionAPI, tab *WorkspaceTab) error { |
| 23 | if err := savePinnedContextState(path, []string{}); err != nil { |
| 24 | newCtrl.Close() |
| 25 | tab.releaseSessionLease() |
| 26 | oldCtrl.CloseAfterDestroy() |
| 27 | return fmt.Errorf("initialize empty pinned context for cleared session: %w", err) |
| 28 | } |
| 29 | return nil |
| 30 | } |
| 31 | |
| 32 | func setFreshControllerPath(ctrl control.SessionAPI, path string) { |
| 33 | if fresh, ok := ctrl.(interface{ SetFreshSessionPath(string) }); ok { |
| 34 | fresh.SetFreshSessionPath(path) |
| 35 | } else { |
| 36 | ctrl.SetSessionPath(path) |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | // ClearSession discards the current conversation and rotates to a fresh unsaved one. |
| 41 | func (a *App) ClearSession() (SessionClearResult, error) { |
| 42 | return a.ClearSessionForTab("") |
| 43 | } |
| 44 | |
| 45 | // ClearSessionForTab clears the requested tab regardless of later focus changes. |
| 46 | // On success it returns the replacement session identity (path/revision/digest |
| 47 | // and a tab-local generation) so the frontend can retire the old transcript |
| 48 | // without waiting for a later MetaForTab round trip. |
| 49 | func (a *App) ClearSessionForTab(tabID string) (SessionClearResult, error) { |
| 50 | tab, ctrl := a.tabAndCtrlByID(tabID) |
| 51 | if a.tabIsReadOnly(tab) { |
| 52 | return SessionClearResult{}, readOnlyChannelErr() |
| 53 | } |
| 54 | if ctrl == nil { |
| 55 | return SessionClearResult{}, a.workspaceNotReadyErr(tab) |
| 56 | } |
| 57 | if err := a.ensureTabControllerWorkspace(tab); err != nil { |
| 58 | return SessionClearResult{}, err |
| 59 | } |
| 60 | ctrl = a.controllerForTab(tab) |
| 61 | if ctrl == nil { |
| 62 | return SessionClearResult{}, a.workspaceNotReadyErr(tab) |
| 63 | } |
| 64 | if controllerHasActiveRuntimeWork(ctrl) { |
| 65 | return a.clearActiveSessionRuntime(tab, ctrl) |
| 66 | } |
| 67 | unlockRuntime := a.lockRuntimeMutation("clear session") |
| 68 | defer unlockRuntime() |
| 69 | tab.turnStartMu.Lock() |
| 70 | defer tab.turnStartMu.Unlock() |
| 71 | ctrl = a.controllerForTab(tab) |
| 72 | if ctrl == nil { |
| 73 | return SessionClearResult{}, a.workspaceNotReadyErr(tab) |
| 74 | } |
| 75 | if controllerHasActiveRuntimeWork(ctrl) { |
| 76 | return SessionClearResult{}, errTopicHasActiveWork |
| 77 | } |
| 78 | if err := ctrl.ClearSession(); err != nil { |
| 79 | a.syncTabSessionIdentity(tab, ctrl) |
| 80 | return SessionClearResult{}, err |
| 81 | } |
| 82 | a.syncTabSessionIdentity(tab, ctrl) |
| 83 | if path := ctrl.SessionPath(); path != "" { |
| 84 | if err := savePinnedContextState(path, []string{}); err != nil { |
| 85 | return SessionClearResult{}, fmt.Errorf("initialize empty pinned context for cleared session: %w", err) |
| 86 | } |
| 87 | } |
| 88 | tab.setPinnedFiles(nil) |
| 89 | if err := a.ensureTabSessionLeaseForRebuild(tab, ctrl.SessionPath(), ""); err != nil { |
| 90 | // Wails bridge return: a raw lease error would carry the session path |
| 91 | // and holder id across to the frontend. |
| 92 | return SessionClearResult{}, userFacingSessionLeaseError("", err) |
| 93 | } |
| 94 | tab.resetTelemetry(ctrl.SessionPath()) |
| 95 | // Mirror the controller: ClearSession cleared the active goal. |
| 96 | a.clearTabGoal(tab) |
| 97 | a.persistTabSessionPath(tab, ctrl.SessionPath()) |
| 98 | a.invalidatePromptHistoryCache() |
| 99 | return a.bumpAndSnapshotSessionClear(tab), nil |
| 100 | } |
| 101 | |
| 102 | func (a *App) bumpAndSnapshotSessionClear(tab *WorkspaceTab) SessionClearResult { |
| 103 | if tab == nil { |
| 104 | return SessionClearResult{} |
| 105 | } |
| 106 | a.mu.Lock() |
| 107 | tab.SessionGeneration++ |
| 108 | gen := tab.SessionGeneration |
| 109 | if tab.sink != nil { |
| 110 | tab.sink.setSessionGeneration(gen) |
| 111 | } |
| 112 | path := tab.currentSessionPath() |
| 113 | sessionID := tab.SessionID |
| 114 | if path == "" && tab.Ctrl != nil { |
| 115 | path = tab.Ctrl.SessionPath() |
| 116 | } |
| 117 | a.mu.Unlock() |
| 118 | var sessionRef *session.SessionRef |
| 119 | if sessionID != "" { |
| 120 | ref := session.SessionRef{HostID: localDesktopHostID, SessionID: sessionID} |
| 121 | sessionRef = &ref |
| 122 | } |
| 123 | var revision int64 |
| 124 | var digest string |
| 125 | if meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok { |
| 126 | revision = meta.Revision |
| 127 | digest = meta.ContentDigest |
| 128 | } |
| 129 | return SessionClearResult{ |
| 130 | SessionPath: path, SessionID: sessionID, Session: sessionRef, |
| 131 | SessionRevision: revision, SessionDigest: digest, SessionGeneration: gen, |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | // clearTabGoal drops the tab's persisted goal copy so rebuilds and restarts |
| 136 | // cannot re-seed a goal the controller has already cleared on session rotation. |
| 137 | func (a *App) clearTabGoal(tab *WorkspaceTab) { |
| 138 | if tab == nil { |
| 139 | return |
| 140 | } |
| 141 | a.mu.Lock() |
| 142 | tab.goal = "" |
| 143 | if current := a.tabs[tab.ID]; current == tab { |
| 144 | a.saveTabsLocked() |
| 145 | } |
| 146 | a.mu.Unlock() |
| 147 | } |
| 148 |