| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "crypto/rand" |
| 5 | "encoding/hex" |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "strings" |
| 9 | "time" |
| 10 | |
| 11 | "reasonix/internal/config" |
| 12 | "reasonix/internal/control" |
| 13 | ) |
| 14 | |
| 15 | // topic_activation.go implements the two-phase topic activation used by the |
| 16 | // single-conversation-surface layout. |
| 17 | // |
| 18 | // Phase 1 (synchronous, inside StartTopicActivation): the tab is opened or |
| 19 | // reused, becomes the active tab, and tabs are persisted — the visible surface |
| 20 | // switches immediately, exactly as ActivateTopic behaves today. The caller |
| 21 | // gets a ticket with the tab meta right away. |
| 22 | // |
| 23 | // Phase 2 (background completion): the controller build started by the open |
| 24 | // path finishes (the completion waits on the tab's build-done channel), then |
| 25 | // — only if this activation is still the latest request — the other visible |
| 26 | // tabs are pruned (keepOnlyVisibleTab) and a terminal "ready"/"failed" event |
| 27 | // is emitted on the "topic:activation" channel. A superseded activation's |
| 28 | // completion emits nothing further: the "cancelled" event is emitted up front, |
| 29 | // at supersede time, by whichever activation or legacy surface switch replaced |
| 30 | // it. |
| 31 | // |
| 32 | // Generation protocol: activationGen bumps every time a new ticketed |
| 33 | // activation starts or a legacy surface switch (ActivateTopic, |
| 34 | // EnsureBlankSurface, SetActiveTab to another tab) supersedes the pending one. |
| 35 | // The completion re-checks gen+requestID under singleSurfaceMu immediately |
| 36 | // before pruning, so a stale completion can never prune tabs a newer |
| 37 | // activation just created. Lock order note: the completion waits on the |
| 38 | // build-done channel BEFORE taking singleSurfaceMu, and the synchronous phase |
| 39 | // never waits on a build-done channel, so the two cannot deadlock. |
| 40 | |
| 41 | const ( |
| 42 | topicActivationEventChannel = "topic:activation" |
| 43 | |
| 44 | topicActivationPhaseStarting = "starting" |
| 45 | topicActivationPhaseReady = "ready" |
| 46 | topicActivationPhaseFailed = "failed" |
| 47 | topicActivationPhaseCancelled = "cancelled" |
| 48 | ) |
| 49 | |
| 50 | // TopicActivationRequest is the input of StartTopicActivation. Scope is |
| 51 | // "project" (WorkspaceRoot required) or "global". SessionPath, when set, |
| 52 | // selects a concrete saved session like OpenTopicSession; otherwise the topic |
| 53 | // resolves to its latest session. RequestID is optional — the backend |
| 54 | // generates one when empty. |
| 55 | type TopicActivationRequest struct { |
| 56 | Selector *SessionSelector `json:"selector,omitempty"` |
| 57 | Scope string `json:"scope"` |
| 58 | WorkspaceRoot string `json:"workspaceRoot"` |
| 59 | TopicID string `json:"topicId"` |
| 60 | SessionPath string `json:"sessionPath"` |
| 61 | RequestID string `json:"requestId"` |
| 62 | } |
| 63 | |
| 64 | // TopicActivationTicket is returned synchronously by StartTopicActivation. The |
| 65 | // frontend switches its visible surface from Meta immediately and tracks the |
| 66 | // background completion through "topic:activation" events keyed by RequestID. |
| 67 | type TopicActivationTicket struct { |
| 68 | RequestID string `json:"requestId"` |
| 69 | TabID string `json:"tabId"` |
| 70 | Meta TabMeta `json:"meta"` |
| 71 | } |
| 72 | |
| 73 | // TopicActivationEvent is emitted on the "topic:activation" channel. Per |
| 74 | // requestId, "starting" is always emitted (synchronously, before the ticket is |
| 75 | // returned) and is followed by exactly one terminal event: "ready" or "failed" |
| 76 | // for the activation that wins, "cancelled" for one superseded before its |
| 77 | // completion ran. A superseded activation never emits "ready"/"failed". |
| 78 | // Ordering across requestIds follows request order for "starting"/"cancelled" |
| 79 | // (emitted under singleSurfaceMu); terminal events may lag arbitrarily since |
| 80 | // they depend on the controller build. |
| 81 | type TopicActivationEvent struct { |
| 82 | RequestID string `json:"requestId"` |
| 83 | TabID string `json:"tabId"` |
| 84 | Phase string `json:"phase"` // "starting" | "ready" | "failed" | "cancelled" |
| 85 | Error string `json:"error,omitempty"` |
| 86 | } |
| 87 | |
| 88 | func newTopicActivationRequestID() string { |
| 89 | var b [8]byte |
| 90 | if _, err := rand.Read(b[:]); err == nil { |
| 91 | return "act_" + hex.EncodeToString(b[:]) |
| 92 | } |
| 93 | now := time.Now().UTC() |
| 94 | return fmt.Sprintf("act_%s_%09d", now.Format("20060102150405"), now.Nanosecond()) |
| 95 | } |
| 96 | |
| 97 | // emitTopicActivation delivers an activation lifecycle event. The test hook |
| 98 | // (when installed) replaces emission so tests observe events synchronously; |
| 99 | // production goes through the async runtime emitter and never blocks a build. |
| 100 | func (a *App) emitTopicActivation(ev TopicActivationEvent) { |
| 101 | a.mu.RLock() |
| 102 | hook := a.activationEventHook |
| 103 | a.mu.RUnlock() |
| 104 | if hook != nil { |
| 105 | hook(ev) |
| 106 | return |
| 107 | } |
| 108 | a.emitRuntimeEvent(topicActivationEventChannel, ev) |
| 109 | } |
| 110 | |
| 111 | // supersedePendingTopicActivationLocked invalidates the pending ticketed |
| 112 | // activation, if any, and bumps the activation generation so its background |
| 113 | // completion becomes a no-op. Callers must hold a.mu. Returns the superseded |
| 114 | // requestID/tabID so the caller can emit "cancelled" after unlocking ("" |
| 115 | // when nothing was pending or the pending activation already completed). |
| 116 | // |
| 117 | // When cancelBuild is true the pending activation's in-flight tab build is |
| 118 | // cancelled through the standard superseded-build mechanics — unless the new |
| 119 | // activation targets the same tab (exceptTabID), in which case the build is |
| 120 | // still needed. SetActiveTab passes false: a direct tab click does not prune |
| 121 | // the pending tab, so its build may legitimately finish. |
| 122 | func (a *App) supersedePendingTopicActivationLocked(exceptTabID string, cancelBuild bool) (string, string) { |
| 123 | reqID := a.latestActivationRequestID |
| 124 | tabID := a.pendingActivationTabID |
| 125 | a.activationGen++ |
| 126 | a.latestActivationRequestID = "" |
| 127 | a.pendingActivationTabID = "" |
| 128 | if cancelBuild && tabID != "" && tabID != exceptTabID { |
| 129 | if prev := a.tabs[tabID]; prev != nil { |
| 130 | a.supersedeTabBuildLocked(prev) |
| 131 | } |
| 132 | } |
| 133 | return reqID, tabID |
| 134 | } |
| 135 | |
| 136 | func (a *App) supersedePendingTopicActivation(exceptTabID string) (string, string) { |
| 137 | a.mu.Lock() |
| 138 | reqID, tabID := a.supersedePendingTopicActivationLocked(exceptTabID, true) |
| 139 | a.mu.Unlock() |
| 140 | return reqID, tabID |
| 141 | } |
| 142 | |
| 143 | // finishTopicActivation clears the pending marker after a completion ran, but |
| 144 | // only when this activation is still the latest — a newer request's marker |
| 145 | // must survive an older completion's cleanup. |
| 146 | func (a *App) finishTopicActivation(gen uint64, requestID string) { |
| 147 | a.mu.Lock() |
| 148 | if a.activationGen == gen && a.latestActivationRequestID == requestID { |
| 149 | a.latestActivationRequestID = "" |
| 150 | a.pendingActivationTabID = "" |
| 151 | } |
| 152 | a.mu.Unlock() |
| 153 | } |
| 154 | |
| 155 | // StartTopicActivation activates a topic on the single visible conversation |
| 156 | // surface and returns a ticket immediately after the surface switch; the |
| 157 | // controller build, old-session snapshot/lease handling, and visible-tab |
| 158 | // pruning complete in the background and are reported through |
| 159 | // "topic:activation" events. |
| 160 | // |
| 161 | // Starting a new activation cancels the previous pending one (generation bump |
| 162 | // + build cancel through the existing superseded-build path). Legacy surface |
| 163 | // switches (ActivateTopic, EnsureBlankSurface, SetActiveTab) participate in |
| 164 | // the same generation, so interleaved legacy and ticketed calls resolve |
| 165 | // deterministically to the last call. |
| 166 | func (a *App) StartTopicActivation(req TopicActivationRequest) (TopicActivationTicket, error) { |
| 167 | // Claim intent before source adoption can block. A later request must not |
| 168 | // be displaced by this request finishing its I/O last. |
| 169 | intent := a.desktopSessions.navigationSeq.Add(1) |
| 170 | if req.Selector != nil { |
| 171 | target, err := a.resolveSessionMutationTarget(*req.Selector) |
| 172 | if err != nil { |
| 173 | return TopicActivationTicket{}, err |
| 174 | } |
| 175 | req.Scope, req.WorkspaceRoot, req.TopicID, req.SessionPath = target.Scope, target.WorkspaceRoot, target.TopicID, target.SessionPath |
| 176 | if target.SessionRef.SessionID != "" { |
| 177 | req.SessionPath = sessionRoute(target.SessionRef.SessionID) |
| 178 | } |
| 179 | } |
| 180 | a.singleSurfaceMu.Lock() |
| 181 | defer a.singleSurfaceMu.Unlock() |
| 182 | if a.desktopSessions.navigationSeq.Load() != intent { |
| 183 | return TopicActivationTicket{}, errSessionNavigationSuperseded |
| 184 | } |
| 185 | |
| 186 | var meta TabMeta |
| 187 | var err error |
| 188 | if strings.TrimSpace(req.SessionPath) != "" { |
| 189 | meta, err = a.openTopicSessionWithNavigation(req.Scope, req.WorkspaceRoot, req.TopicID, req.SessionPath, intent) |
| 190 | } else if strings.TrimSpace(req.Scope) == "project" { |
| 191 | meta, err = a.openProjectTab(req.WorkspaceRoot, req.TopicID) |
| 192 | } else { |
| 193 | meta, err = a.openGlobalTab(req.TopicID) |
| 194 | } |
| 195 | if err != nil { |
| 196 | // The open failed before anything changed hands: leave a previously |
| 197 | // pending activation untouched, same as ActivateTopic leaves state |
| 198 | // untouched on error. |
| 199 | return TopicActivationTicket{}, err |
| 200 | } |
| 201 | |
| 202 | requestID := strings.TrimSpace(req.RequestID) |
| 203 | if requestID == "" { |
| 204 | requestID = newTopicActivationRequestID() |
| 205 | } |
| 206 | |
| 207 | // The open succeeded and may already have started this tab's build (new |
| 208 | // tab) inside the call above. Register this activation as the latest and |
| 209 | // cancel the previous pending activation's build when it targets a |
| 210 | // different tab — its completion is guarded off by the generation bump |
| 211 | // either way. |
| 212 | a.mu.Lock() |
| 213 | prevReqID, prevTabID := a.supersedePendingTopicActivationLocked(meta.ID, true) |
| 214 | gen := a.activationGen |
| 215 | a.latestActivationRequestID = requestID |
| 216 | a.pendingActivationTabID = meta.ID |
| 217 | a.mu.Unlock() |
| 218 | |
| 219 | if prevReqID != "" { |
| 220 | a.emitTopicActivation(TopicActivationEvent{RequestID: prevReqID, TabID: prevTabID, Phase: topicActivationPhaseCancelled}) |
| 221 | } |
| 222 | a.emitTopicActivation(TopicActivationEvent{RequestID: requestID, TabID: meta.ID, Phase: topicActivationPhaseStarting}) |
| 223 | |
| 224 | a.goSafe("topic-activation-completion", func() { |
| 225 | a.runTopicActivationCompletion(gen, requestID, meta.ID) |
| 226 | }) |
| 227 | |
| 228 | return TopicActivationTicket{RequestID: requestID, TabID: meta.ID, Meta: meta}, nil |
| 229 | } |
| 230 | |
| 231 | // runTopicActivationCompletion is phase 2 of a ticketed activation. It waits |
| 232 | // for the tab's in-flight controller build (if any), then — only when the |
| 233 | // activation is still the latest — prunes the other visible tabs exactly once |
| 234 | // and emits the terminal event. Superseded completions return silently; their |
| 235 | // "cancelled" event was already emitted at supersede time. |
| 236 | func (a *App) runTopicActivationCompletion(gen uint64, requestID, tabID string) { |
| 237 | // Wait for the in-flight build first, WITHOUT holding singleSurfaceMu: |
| 238 | // the synchronous phase of a newer activation needs that mutex and never |
| 239 | // waits on a build, so this ordering cannot deadlock. A nil channel means |
| 240 | // no build is in flight (reuse/fast path, or the synchronous test build |
| 241 | // already finished) and the completion proceeds immediately. |
| 242 | a.mu.RLock() |
| 243 | tab := a.tabs[tabID] |
| 244 | var buildDone chan struct{} |
| 245 | if tab != nil { |
| 246 | buildDone = tab.buildDone |
| 247 | } |
| 248 | a.mu.RUnlock() |
| 249 | if buildDone != nil { |
| 250 | <-buildDone |
| 251 | } |
| 252 | |
| 253 | // A reused/reattached runtime is already usable. Publish ready before |
| 254 | // prune: keepOnlyVisibleTab takes runtimeRebuildMu, which an in-flight |
| 255 | // MCP rebuild on the previous tab can hold for a long time. |
| 256 | emittedReady := a.emitTopicActivationReadyIfCurrent(gen, requestID, tabID) |
| 257 | |
| 258 | // The generation check and the prune serialize against new activations |
| 259 | // through singleSurfaceMu: either this completion runs entirely before the |
| 260 | // next activation's synchronous phase (its tabs are not there to prune), |
| 261 | // or after it (the generation no longer matches and nothing is pruned). |
| 262 | a.singleSurfaceMu.Lock() |
| 263 | defer a.singleSurfaceMu.Unlock() |
| 264 | |
| 265 | a.mu.RLock() |
| 266 | latest := a.activationGen == gen && |
| 267 | a.latestActivationRequestID == requestID && |
| 268 | a.pendingActivationTabID == tabID && |
| 269 | a.tabs[tabID] != nil |
| 270 | a.mu.RUnlock() |
| 271 | if !latest { |
| 272 | return |
| 273 | } |
| 274 | |
| 275 | if _, err := a.keepOnlyVisibleTab(tabID); err != nil { |
| 276 | a.finishTopicActivation(gen, requestID) |
| 277 | if !emittedReady { |
| 278 | a.emitTopicActivation(TopicActivationEvent{ |
| 279 | RequestID: requestID, |
| 280 | TabID: tabID, |
| 281 | Phase: topicActivationPhaseFailed, |
| 282 | // keepOnlyVisibleTab errors can wrap snapshot/path details; the |
| 283 | // event stays generic, the slog entry keeps the specifics. |
| 284 | Error: "failed to switch the visible session", |
| 285 | }) |
| 286 | } |
| 287 | return |
| 288 | } |
| 289 | |
| 290 | if emittedReady { |
| 291 | a.finishTopicActivation(gen, requestID) |
| 292 | a.scheduleTabMetaExtrasRefresh(tabID) |
| 293 | return |
| 294 | } |
| 295 | |
| 296 | // Preserve today's failure semantics: a failed build leaves the tab |
| 297 | // visible with StartupErr and a failed/lease_blocked runtime phase; the |
| 298 | // prune still happened (the surface switched), only the terminal event |
| 299 | // differs. |
| 300 | a.mu.RLock() |
| 301 | tab = a.tabs[tabID] |
| 302 | ready := tab != nil && tab.Ready && tab.Ctrl != nil |
| 303 | startupErr, leaseHeld := "", false |
| 304 | if tab != nil { |
| 305 | startupErr = tab.StartupErr |
| 306 | leaseHeld = tab.StartupErrLeaseHeld |
| 307 | } |
| 308 | a.mu.RUnlock() |
| 309 | |
| 310 | a.finishTopicActivation(gen, requestID) |
| 311 | switch { |
| 312 | case ready: |
| 313 | a.emitTopicActivation(TopicActivationEvent{RequestID: requestID, TabID: tabID, Phase: topicActivationPhaseReady}) |
| 314 | // The activation just made this tab visible: refresh the expensive |
| 315 | // meta fields off-lock and push them to the frontend. |
| 316 | a.scheduleTabMetaExtrasRefresh(tabID) |
| 317 | default: |
| 318 | a.emitTopicActivation(TopicActivationEvent{ |
| 319 | RequestID: requestID, |
| 320 | TabID: tabID, |
| 321 | Phase: topicActivationPhaseFailed, |
| 322 | Error: sanitizedTopicActivationError(startupErr, leaseHeld), |
| 323 | }) |
| 324 | } |
| 325 | } |
| 326 | |
| 327 | func (a *App) emitTopicActivationReadyIfCurrent(gen uint64, requestID, tabID string) bool { |
| 328 | a.mu.RLock() |
| 329 | tab := a.tabs[tabID] |
| 330 | ok := a.activationGen == gen && |
| 331 | a.latestActivationRequestID == requestID && |
| 332 | a.pendingActivationTabID == tabID && |
| 333 | tab != nil && tab.Ready && tab.Ctrl != nil |
| 334 | a.mu.RUnlock() |
| 335 | if !ok { |
| 336 | return false |
| 337 | } |
| 338 | a.emitTopicActivation(TopicActivationEvent{RequestID: requestID, TabID: tabID, Phase: topicActivationPhaseReady}) |
| 339 | return true |
| 340 | } |
| 341 | |
| 342 | // sanitizedTopicActivationError keeps local paths and lease-holder writer IDs |
| 343 | // out of the activation event. The lease-busy message is already sanitized; |
| 344 | // everything else degrades to a generic summary — the full detail remains |
| 345 | // available to the frontend through Meta.StartupErr, same as today. |
| 346 | func sanitizedTopicActivationError(startupErr string, leaseHeld bool) string { |
| 347 | if leaseHeld && strings.TrimSpace(startupErr) != "" { |
| 348 | return startupErr |
| 349 | } |
| 350 | if strings.TrimSpace(startupErr) != "" { |
| 351 | return "session failed to start" |
| 352 | } |
| 353 | return "session is not ready" |
| 354 | } |
| 355 | |
| 356 | // --- MetaForTab fast-path cache -------------------------------------------- |
| 357 | |
| 358 | // tabMetaRefreshEventChannel carries TabMetaRefreshEvent after a background |
| 359 | // refresh of the expensive Meta fields (git branch, image input capability). |
| 360 | const tabMetaRefreshEventChannel = "tab:meta" |
| 361 | |
| 362 | // TabMetaRefreshEvent pushes a freshly recomputed Meta to the frontend after |
| 363 | // the cached expensive fields changed. The frontend should treat it like a |
| 364 | // MetaForTab response for TabID. |
| 365 | type TabMetaRefreshEvent struct { |
| 366 | TabID string `json:"tabId"` |
| 367 | Meta Meta `json:"meta"` |
| 368 | } |
| 369 | |
| 370 | // tabMetaExtras is the per-tab cached snapshot of the MetaForTab fields that |
| 371 | // are too expensive to compute on the request path. It is keyed conservatively |
| 372 | // by the workspace root the values were computed for: a root mismatch serves |
| 373 | // empty values rather than another root's branch/capability. model keys the |
| 374 | // image-input computation so a model switch invalidates it without |
| 375 | // invalidating the (root-scoped) git branch or fallback setting. |
| 376 | type tabMetaExtras struct { |
| 377 | controller control.SessionAPI |
| 378 | modelSettingsPending bool |
| 379 | workspaceRoot string |
| 380 | model string |
| 381 | gitBranch string |
| 382 | imageInputEnabled bool |
| 383 | visionFallbackEnabled bool |
| 384 | fetchedAt time.Time |
| 385 | } |
| 386 | |
| 387 | // tabMetaExtrasFor returns the cached extras valid for (root, model) and |
| 388 | // whether a background refresh should be scheduled. A stale-but-root-matching |
| 389 | // git branch is served while refreshing (same policy the old |
| 390 | // workspaceGitBranchForMeta cache used); a model mismatch hides only the |
| 391 | // image-input flag, and a root mismatch hides both. |
| 392 | func tabMetaExtrasFor(tab *WorkspaceTab, root, model string) (tabMetaExtras, bool) { |
| 393 | var zero tabMetaExtras |
| 394 | if tab == nil || root == "" { |
| 395 | return zero, false |
| 396 | } |
| 397 | extras := tab.metaExtras.Load() |
| 398 | if extras == nil { |
| 399 | return zero, true |
| 400 | } |
| 401 | if extras.workspaceRoot != root { |
| 402 | return zero, true |
| 403 | } |
| 404 | out := *extras |
| 405 | refresh := time.Since(extras.fetchedAt) > workspaceGitBranchCacheTTL |
| 406 | if extras.model != model { |
| 407 | out.imageInputEnabled = false |
| 408 | refresh = true |
| 409 | } |
| 410 | return out, refresh |
| 411 | } |
| 412 | |
| 413 | // scheduleTabMetaExtrasRefresh starts a background refresh unless one is |
| 414 | // already in flight for this tab. Safe to call from request paths. |
| 415 | func (a *App) scheduleTabMetaExtrasRefresh(tabID string) { |
| 416 | a.mu.RLock() |
| 417 | tab := a.tabs[tabID] |
| 418 | a.mu.RUnlock() |
| 419 | if tab == nil || !tab.metaExtrasRefreshing.CompareAndSwap(false, true) { |
| 420 | return |
| 421 | } |
| 422 | a.goSafe("tab-meta-extras-refresh", func() { |
| 423 | a.refreshTabMetaExtras(tab) |
| 424 | }) |
| 425 | } |
| 426 | |
| 427 | // refreshTabMetaExtras recomputes the expensive Meta fields off-lock (a cheap |
| 428 | // `git rev-parse` plus one config load per refresh is fine in the background), |
| 429 | // publishes them into the tab's cache, and pushes the refreshed Meta to the |
| 430 | // frontend on "tab:meta". The tab-identity re-check after the off-lock stretch |
| 431 | // keeps a pruned/replaced tab from receiving another tab's values. |
| 432 | func (a *App) refreshTabMetaExtras(tab *WorkspaceTab) { |
| 433 | if tab == nil { |
| 434 | return |
| 435 | } |
| 436 | defer tab.metaExtrasRefreshing.Store(false) |
| 437 | a.mu.RLock() |
| 438 | if a.tabs[tab.ID] != tab { |
| 439 | a.mu.RUnlock() |
| 440 | return |
| 441 | } |
| 442 | root := tab.WorkspaceRoot |
| 443 | model := tab.model |
| 444 | ctrl := tab.Ctrl |
| 445 | snapshotModel, snapshotRoot := model, root |
| 446 | a.mu.RUnlock() |
| 447 | if root == "" { |
| 448 | root, _ = os.Getwd() |
| 449 | } |
| 450 | |
| 451 | gitBranch := "" |
| 452 | if root != "" { |
| 453 | gitBranch = workspaceGitBranch(root) |
| 454 | } |
| 455 | imageInputEnabled := false |
| 456 | visionFallbackEnabled := false |
| 457 | if cfg, err := a.loadConfigForVision(root); err == nil && cfg != nil { |
| 458 | if model == "" { |
| 459 | model = cfg.DefaultModel |
| 460 | } |
| 461 | if entry, ok := cfg.ResolveModel(model); ok { |
| 462 | imageInputEnabled = config.EffectiveVision(entry) |
| 463 | } |
| 464 | visionFallbackEnabled = strings.TrimSpace(cfg.Agent.VisionModel) != "" |
| 465 | } |
| 466 | if snapshot, ok := ctrl.(interface{ ImageInputSnapshot() (bool, bool, bool) }); ok { |
| 467 | if enabled, fallback, available := snapshot.ImageInputSnapshot(); available { |
| 468 | imageInputEnabled, visionFallbackEnabled = enabled, fallback |
| 469 | } |
| 470 | } |
| 471 | |
| 472 | pending, _ := modelSettingsNeedApply(ctrl) |
| 473 | a.mu.Lock() |
| 474 | if a.tabs[tab.ID] != tab || tab.Ctrl != ctrl || tab.model != snapshotModel || tab.WorkspaceRoot != snapshotRoot { |
| 475 | a.mu.Unlock() |
| 476 | return |
| 477 | } |
| 478 | tab.metaExtras.Store(&tabMetaExtras{ |
| 479 | controller: ctrl, |
| 480 | modelSettingsPending: pending, |
| 481 | workspaceRoot: root, |
| 482 | model: model, |
| 483 | gitBranch: gitBranch, |
| 484 | imageInputEnabled: imageInputEnabled, |
| 485 | visionFallbackEnabled: visionFallbackEnabled, |
| 486 | fetchedAt: time.Now(), |
| 487 | }) |
| 488 | a.mu.Unlock() |
| 489 | |
| 490 | meta := a.MetaForTab(tab.ID) |
| 491 | a.emitRuntimeEvent(tabMetaRefreshEventChannel, TabMetaRefreshEvent{TabID: tab.ID, Meta: meta}) |
| 492 | } |
| 493 |