| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "log/slog" |
| 7 | "strings" |
| 8 | ) |
| 9 | |
| 10 | type desktopRemoteTabEntry struct { |
| 11 | ID string `json:"id"` |
| 12 | HostID string `json:"hostId"` |
| 13 | Workspace string `json:"workspace"` |
| 14 | TopicTitle string `json:"topicTitle,omitempty"` |
| 15 | Model string `json:"model,omitempty"` |
| 16 | SessionName string `json:"sessionName,omitempty"` |
| 17 | SessionPath string `json:"sessionPath,omitempty"` |
| 18 | SessionID string `json:"sessionId,omitempty"` |
| 19 | SessionReset bool `json:"sessionReset,omitempty"` |
| 20 | extra map[string]json.RawMessage |
| 21 | } |
| 22 | |
| 23 | func singleSurfaceTabsFile(f desktopTabsFile) desktopTabsFile { |
| 24 | if len(f.Tabs)+len(f.RemoteTabs) <= 1 { |
| 25 | return f |
| 26 | } |
| 27 | active := strings.TrimSpace(f.ActiveTab) |
| 28 | for _, entry := range f.RemoteTabs { |
| 29 | if entry.ID != active { |
| 30 | continue |
| 31 | } |
| 32 | // The remote surface stays active, but one local entry is kept so local |
| 33 | // commands still have a workspace tab to target. It restores dormant |
| 34 | // (see restoreDormantWorkspaceTab), so it adds no hidden startup work. |
| 35 | if len(f.Tabs) == 0 { |
| 36 | return desktopTabsFile{ |
| 37 | ActiveTab: entry.ID, |
| 38 | RemoteTabs: []desktopRemoteTabEntry{entry}, |
| 39 | RemoteTabOrder: []string{entry.ID}, |
| 40 | TabOrder: []string{entry.ID}, |
| 41 | extra: cloneDesktopJSONFields(f.extra), |
| 42 | } |
| 43 | } |
| 44 | local := f.Tabs[0] |
| 45 | return desktopTabsFile{ |
| 46 | Tabs: []desktopTabEntry{local}, |
| 47 | ActiveTab: entry.ID, |
| 48 | RemoteTabs: []desktopRemoteTabEntry{entry}, |
| 49 | RemoteTabOrder: []string{entry.ID}, |
| 50 | // The remote surface keeps the head of the strip order so any |
| 51 | // first-tab fallback still resolves to the surface the user left on. |
| 52 | TabOrder: []string{entry.ID, local.ID}, |
| 53 | extra: cloneDesktopJSONFields(f.extra), |
| 54 | } |
| 55 | } |
| 56 | for _, entry := range f.Tabs { |
| 57 | if entry.ID == active { |
| 58 | return desktopTabsFile{Tabs: []desktopTabEntry{entry}, ActiveTab: entry.ID, TabOrder: []string{entry.ID}, extra: cloneDesktopJSONFields(f.extra)} |
| 59 | } |
| 60 | } |
| 61 | if len(f.Tabs) > 0 { |
| 62 | return desktopTabsFile{Tabs: []desktopTabEntry{f.Tabs[0]}, ActiveTab: f.Tabs[0].ID, TabOrder: []string{f.Tabs[0].ID}, extra: cloneDesktopJSONFields(f.extra)} |
| 63 | } |
| 64 | chosen := f.RemoteTabs[0] |
| 65 | return desktopTabsFile{ActiveTab: chosen.ID, RemoteTabs: []desktopRemoteTabEntry{chosen}, RemoteTabOrder: []string{chosen.ID}, TabOrder: []string{chosen.ID}, extra: cloneDesktopJSONFields(f.extra)} |
| 66 | } |
| 67 | |
| 68 | // remoteSurfaceIsActiveTab reports whether the persisted active tab is a remote |
| 69 | // entry: the remote shell then owns the visible surface, and restored local tabs |
| 70 | // stay dormant (no runtime) so they add no hidden startup work. |
| 71 | func remoteSurfaceIsActiveTab(f desktopTabsFile) bool { |
| 72 | active := strings.TrimSpace(f.ActiveTab) |
| 73 | if active == "" { |
| 74 | return false |
| 75 | } |
| 76 | for _, entry := range f.RemoteTabs { |
| 77 | if strings.TrimSpace(entry.ID) == active { |
| 78 | return true |
| 79 | } |
| 80 | } |
| 81 | return false |
| 82 | } |
| 83 | |
| 84 | // saveTabsFromRemote snapshots local state before joining it with the remote |
| 85 | // registry. Callers must not hold remoteTabMu. |
| 86 | func (a *App) saveTabsFromRemote() { |
| 87 | a.mu.Lock() |
| 88 | dir, entries, activeID, version := a.saveTabsCollectLocked() |
| 89 | a.mu.Unlock() |
| 90 | a.saveTabsWrite(dir, entries, activeID, version) |
| 91 | } |
| 92 | |
| 93 | // finishRestoredLocalTabs activates and builds the restored local tabs. When the |
| 94 | // persisted active tab is a remote shell they stay dormant instead: the remote |
| 95 | // surface keeps the visible surface and no hidden startup work is added. Their |
| 96 | // controller is built when they are first activated or used. |
| 97 | func (a *App) finishRestoredLocalTabs(f desktopTabsFile, toBuild []*WorkspaceTab) { |
| 98 | if remoteSurfaceIsActiveTab(f) { |
| 99 | return |
| 100 | } |
| 101 | a.mu.Lock() |
| 102 | if _, ok := a.tabs[f.ActiveTab]; ok { |
| 103 | a.activeTabID = f.ActiveTab |
| 104 | } else if ordered := a.orderedTabIDsLocked(); len(ordered) > 0 { |
| 105 | a.activeTabID = ordered[0] |
| 106 | } |
| 107 | a.saveTabsLocked() |
| 108 | a.mu.Unlock() |
| 109 | for _, tab := range toBuild { |
| 110 | a.startTabControllerBuild(tab) |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | // restoreDormantWorkspaceTab publishes one local workspace tab with no session |
| 115 | // and no runtime. A remote-only layout leaves the visible surface to the remote |
| 116 | // shell, but local commands still need a tab to target; this tab is activated |
| 117 | // on first use and never takes the surface from the remote tab. |
| 118 | func (a *App) restoreDormantWorkspaceTab(ctx context.Context) { |
| 119 | admissionRelease, err := a.beginProjectRuntimeAdmission("global", globalTabWorkspaceRoot()) |
| 120 | if err != nil { |
| 121 | slog.Warn("desktop: dormant workspace tab admission failed", "err", err) |
| 122 | return |
| 123 | } |
| 124 | tab := a.createTabEntry("global", globalTabWorkspaceRoot(), "") |
| 125 | tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx} |
| 126 | a.publishRestoredTab(tab, admissionRelease) |
| 127 | } |
| 128 |