| 39 | * derived workspace (id = root = cwd). Real workspaces win on root. |
| 40 | */ |
| 41 | export function mergeWorkspaces(input: MergeWorkspacesInput): AppWorkspace[] { |
| 42 | const { |
| 43 | workspaces, |
| 44 | sessions, |
| 45 | hiddenWorkspaceRoots, |
| 46 | activeRoot, |
| 47 | activeBranch, |
| 48 | sessionsHasMoreByWorkspace, |
| 49 | } = input; |
| 50 | |
| 51 | const hidden = new Set(hiddenWorkspaceRoots); |
| 52 | const byRoot = new Map<string, AppWorkspace>(); |
| 53 | // Real workspaces win on root (unless the user removed them from the sidebar). |
| 54 | // Keep the FIRST entry per root: the daemon orders by last_opened_at desc, so |
| 55 | // the most recently opened (typically the canonical re-add) comes first. This |
| 56 | // must match `workspaceIdForSession` / the sidebar's first-match session |
| 57 | // assignment — if byRoot kept a different id than sessions are counted and |
| 58 | // grouped under, the only rendered workspace would look empty. |
| 59 | for (const w of workspaces) { |
| 60 | if (hidden.has(w.root)) continue; |
| 61 | if (!byRoot.has(w.root)) byRoot.set(w.root, { ...w }); |
| 62 | } |
| 63 | // Derive from sessions for any cwd without a real workspace. |
| 64 | for (const s of sessions) { |
| 65 | const root = s.cwd; |
| 66 | if (!root) continue; |
| 67 | if (hidden.has(root)) continue; // removed from the sidebar — keep it hidden |
| 68 | if (!byRoot.has(root)) { |
| 69 | byRoot.set(root, { |
| 70 | // Use the session's REAL daemon workspace_id (wd_<slug>_<hash>) so |
| 71 | // createSession({ workspaceId }) is accepted; fall back to cwd only |
| 72 | // when the daemon hasn't tagged the session yet. |
| 73 | id: s.workspaceId ?? root, |
| 74 | root, |
| 75 | name: basename(root), |
| 76 | isGitRepo: false, |
| 77 | sessionCount: 0, |
| 78 | }); |
| 79 | } |
| 80 | } |
| 81 | // Compute live session counts. |
| 82 | const counts = new Map<string, number>(); |
| 83 | for (const s of sessions) { |
| 84 | const wid = workspaceIdForSession(workspaces, s); |
| 85 | counts.set(wid, (counts.get(wid) ?? 0) + 1); |
| 86 | } |
| 87 | |
| 88 | // Order: real workspaces in listWorkspaces order, then derived workspaces |
| 89 | // sorted by root path so the order is stable (not tied to session activity). |
| 90 | // Hidden roots must be excluded here too — `byRoot` skips them, so a hidden |
| 91 | // real workspace would otherwise make `byRoot.get(root)` return undefined. |
| 92 | // |
| 93 | // Dedup by root: the registry can legitimately hold two entries for the same |
| 94 | // folder (e.g. a legacy id from an older encodeWorkDirKey plus the current |
| 95 | // one). `byRoot` already collapses them, but a duplicated root in the |
| 96 | // ordering list would render the same workspace twice — and because both |
| 97 | // copies share an id, selecting one would highlight both. |
| 98 | const realRoots = [...new Set(workspaces.filter((w) => !hidden.has(w.root)).map((w) => w.root))]; |