]*id="worktree-list-option-[^"]*%3A${escapeRegExp(worktreeId)}"[^>]*>`)
+ new RegExp(`
]*id="worktree-list-option-[^"]*%7C${escapeRegExp(worktreeId)}"[^>]*>`)
)?.[0] ?? ''
)
}
diff --git a/src/renderer/src/components/sidebar/worktree-list-groups-host-collision.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups-host-collision.test.ts
new file mode 100644
index 00000000000..4639a51051a
--- /dev/null
+++ b/src/renderer/src/components/sidebar/worktree-list-groups-host-collision.test.ts
@@ -0,0 +1,87 @@
+/**
+ * STA-4343: correct host-qualified routing is useless if the user cannot select
+ * the row it would route to.
+ *
+ * A repo id can be registered once per execution host, so the same repo checked
+ * out at the same path on the local host and on an SSH host publishes the SAME
+ * `repoId::path` workspace id twice. The sidebar collapsed that to one row, which
+ * meant the other host's workspace could never be confirmed — and a delete
+ * confirmed on the surviving row carried the wrong host.
+ */
+import { describe, expect, it } from 'vitest'
+import { buildRows } from './worktree-list/grouping/build-rows'
+import { repo, worktree } from './worktree-list-groups-test-fixtures'
+import { getExecutionHostLabel } from '../../../../shared/execution-host'
+import type { Repo } from '../../../../shared/repo-types'
+import type { Worktree } from '../../../../shared/worktree/types'
+
+const SHARED_WORKTREE_ID = 'repo-shared::/work/orca-feature'
+
+const localRepo: Repo = { ...repo, id: 'repo-shared', path: '/work/orca' }
+const sshRepo: Repo = {
+ ...repo,
+ id: 'repo-shared',
+ path: '/work/orca',
+ connectionId: 'build-box',
+ executionHostId: 'ssh:build-box'
+}
+
+const localWorktree: Worktree = {
+ ...worktree,
+ id: SHARED_WORKTREE_ID,
+ repoId: 'repo-shared',
+ path: '/work/orca-feature',
+ hostId: 'local',
+ displayName: 'orca-feature'
+}
+const sshWorktree: Worktree = { ...localWorktree, hostId: 'ssh:build-box' }
+
+function buildCollidingRows(): ReturnType {
+ // Only one repo can be keyed per id in the repo map; the SSH row still has to
+ // render, so the collision must be resolved from the worktree rows themselves.
+ return buildRows(
+ 'repo',
+ [localWorktree, sshWorktree],
+ new Map([[localRepo.id, localRepo]]),
+ null,
+ new Set()
+ )
+}
+
+describe('sidebar rows for a workspace id owned by two hosts', () => {
+ it('renders one selectable row per host', () => {
+ const itemRows = buildCollidingRows().filter((row) => row.type === 'item')
+
+ expect(itemRows).toHaveLength(2)
+ expect(itemRows.map((row) => row.worktree.hostId)).toEqual(['local', 'ssh:build-box'])
+ })
+
+ it('gives each row its own React key so neither replaces the other', () => {
+ const rowKeys = buildCollidingRows()
+ .filter((row) => row.type === 'item')
+ .map((row) => row.rowKey)
+
+ expect(new Set(rowKeys).size).toBe(rowKeys.length)
+ })
+
+ it('labels each row with the host it lives on', () => {
+ const labels = buildCollidingRows()
+ .filter((row) => row.type === 'item')
+ .map((row) => row.hostContextLabel)
+
+ expect(labels).toEqual([getExecutionHostLabel('local'), getExecutionHostLabel('ssh:build-box')])
+ })
+
+ it('counts the two hosts separately in the group header', () => {
+ const header = buildCollidingRows().find((row) => row.type === 'header')
+
+ expect(header?.count).toBe(2)
+ })
+
+ // The same-host duplicate that this dedup was originally built for collapses a
+ // layer up, in the store index — see worktree-host-collision-index.test.ts.
+})
+
+// Referenced so the SSH repo fixture documents the two-host registration this
+// scenario requires, even though buildRows keys its repo map by id alone.
+export const SSH_REPO_FIXTURE = sshRepo
diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/group-sections.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/group-sections.ts
index 31a0d86c0f6..fc3acb360c7 100644
--- a/src/renderer/src/components/sidebar/worktree-list/grouping/group-sections.ts
+++ b/src/renderer/src/components/sidebar/worktree-list/grouping/group-sections.ts
@@ -156,8 +156,12 @@ export function appendOrderedGroups(
groupBy === 'repo'
? getMixedHostContextLabels(group, repoMap, projectIndex, hostLabelById)
: undefined
+ // Why (STA-4343): repo grouping normally labels by repo, but one repo id can
+ // be registered on two hosts — then every row in the group shares a repo id
+ // and the per-repo label cannot tell them apart. Fall back to the per-row
+ // host labels, which are keyed by host-qualified identity.
const hostContextLabelByWorktreeId =
- groupBy === 'repo' ? undefined : mixedWorktreeHostContextLabels
+ groupBy === 'repo' && hostContextLabelByRepoId ? undefined : mixedWorktreeHostContextLabels
appendWorktreeRows(result, items, repoMap, lineageById, worktreeMap, {
nestLineage,
collapsedGroups,
diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.ts
index 16f5280ad63..6b077e0a07c 100644
--- a/src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.ts
+++ b/src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.ts
@@ -6,6 +6,7 @@ import {
getWorktreeExecutionHostId
} from '../../../../../../shared/execution-host'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
+import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
import type { ProjectGroupingIndex, WorktreeGroupEntry } from './project-grouping'
function getRepoHostLabel(
@@ -45,20 +46,24 @@ export function getMixedHostContextLabels(
return uniqueLabels.size > 1 ? labelsByRepoId : undefined
}
+/** Keyed by host-qualified identity: two hosts sharing an id need two labels. */
export function getMixedWorktreeHostContextLabels(
worktrees: readonly Worktree[],
repoMap: Map,
hostLabelById: ReadonlyMap | undefined,
defaultHostId: ExecutionHostId
): Map | undefined {
- const labelsByWorktreeId = new Map()
+ const labelsByIdentity = new Map()
const uniqueHostIds = new Set()
for (const worktree of worktrees) {
const hostId = getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId), defaultHostId)
uniqueHostIds.add(hostId)
- labelsByWorktreeId.set(worktree.id, hostLabelById?.get(hostId) ?? getExecutionHostLabel(hostId))
+ labelsByIdentity.set(
+ getWorktreeHostIdentity(worktree),
+ hostLabelById?.get(hostId) ?? getExecutionHostLabel(hostId)
+ )
}
- return uniqueHostIds.size > 1 ? labelsByWorktreeId : undefined
+ return uniqueHostIds.size > 1 ? labelsByIdentity : undefined
}
export function getHostWorktreeCounts(
@@ -70,12 +75,14 @@ export function getHostWorktreeCounts(
return undefined
}
const counts = new Map()
- const seenWorktreeIds = new Set()
+ // Dedup by host, not by bare id: the same id on two hosts is two workspaces
+ // and has to be counted under each of them (STA-4343).
+ const seenIdentities = new Set()
for (const worktree of worktrees) {
- if (seenWorktreeIds.has(worktree.id)) {
+ if (seenIdentities.has(getWorktreeHostIdentity(worktree))) {
continue
}
- seenWorktreeIds.add(worktree.id)
+ seenIdentities.add(getWorktreeHostIdentity(worktree))
const hostId = getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId), defaultHostId)
counts.set(hostId, (counts.get(hostId) ?? 0) + 1)
}
@@ -91,12 +98,12 @@ export function getHostWorktreeIds(
return undefined
}
const idsByHost = new Map()
- const seenWorktreeIds = new Set()
+ const seenIdentities = new Set()
for (const worktree of worktrees) {
- if (seenWorktreeIds.has(worktree.id)) {
+ if (seenIdentities.has(getWorktreeHostIdentity(worktree))) {
continue
}
- seenWorktreeIds.add(worktree.id)
+ seenIdentities.add(getWorktreeHostIdentity(worktree))
const hostId = getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId), defaultHostId)
const ids = idsByHost.get(hostId) ?? []
ids.push(worktree.id)
diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/row-builders.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/row-builders.ts
index 3f9617174ce..bada875fda4 100644
--- a/src/renderer/src/components/sidebar/worktree-list/grouping/row-builders.ts
+++ b/src/renderer/src/components/sidebar/worktree-list/grouping/row-builders.ts
@@ -3,6 +3,7 @@ import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-
import type { Worktree } from '../../../../../../shared/worktree/types'
import { getWorktreeExecutionHostId } from '../../../../../../shared/execution-host'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
+import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
import { getLineageRenderInfo } from '../../worktree-lineage-projection'
import { PINNED_GROUP_KEY, PINNED_GROUP_META, getLineageGroupKey } from './group-keys'
import type {
@@ -82,7 +83,7 @@ export function emitPinnedGroup(
for (const [index, worktree] of pinned.entries()) {
result.push(
buildWorktreeRow(worktree, repoMap, {
- rowKey: `${PINNED_GROUP_KEY}:${worktree.id}`,
+ rowKey: `${PINNED_GROUP_KEY}:${getWorktreeHostIdentity(worktree)}`,
sectionKey: PINNED_GROUP_KEY,
depth: 0,
groupDepth: 0,
@@ -190,7 +191,7 @@ export function appendWorktreeRows(
for (const worktree of worktrees) {
result.push(
buildWorktreeRow(worktree, repoMap, {
- rowKey: `${sectionKey}:${worktree.id}`,
+ rowKey: `${sectionKey}:${getWorktreeHostIdentity(worktree)}`,
sectionKey,
depth: 0,
groupDepth,
@@ -199,7 +200,7 @@ export function appendWorktreeRows(
lineageChildCount: 0,
lineageCollapsed: false,
hostContextLabel:
- hostContextLabelByWorktreeId?.get(worktree.id) ??
+ hostContextLabelByWorktreeId?.get(getWorktreeHostIdentity(worktree)) ??
hostContextLabelByRepoId?.get(worktree.repoId)
})
)
@@ -209,13 +210,16 @@ export function appendWorktreeRows(
const visibleIds = new Set(worktrees.map((worktree) => worktree.id))
const childrenByParentId = new Map()
- const childIds = new Set()
+ // Why (STA-4343): lineage itself is keyed by the bare id, so parent/child
+ // lookups stay id-based — but membership must be host-qualified or the second
+ // host's row for a colliding id is silently never emitted.
+ const childIdentities = new Set()
for (const worktree of worktrees) {
const lineage = getLineageRenderInfo(worktree, lineageById, worktreeMap, cyclicLineageIds)
if (lineage.state !== 'valid' || !visibleIds.has(lineage.parent.id)) {
continue
}
- childIds.add(worktree.id)
+ childIdentities.add(getWorktreeHostIdentity(worktree))
const children = childrenByParentId.get(lineage.parent.id) ?? []
children.push(worktree)
childrenByParentId.set(lineage.parent.id, children)
@@ -228,16 +232,16 @@ export function appendWorktreeRows(
lineageTrail: boolean[],
isLastChild: boolean
): void => {
- if (emitted.has(worktree.id)) {
+ if (emitted.has(getWorktreeHostIdentity(worktree))) {
return
}
const children = childrenByParentId.get(worktree.id) ?? []
const lineageGroupKey = getLineageGroupKey(worktree.id)
const lineageCollapsed = collapsedGroups.has(lineageGroupKey)
- emitted.add(worktree.id)
+ emitted.add(getWorktreeHostIdentity(worktree))
result.push(
buildWorktreeRow(worktree, repoMap, {
- rowKey: `${sectionKey}:${worktree.id}`,
+ rowKey: `${sectionKey}:${getWorktreeHostIdentity(worktree)}`,
sectionKey,
depth,
groupDepth,
@@ -246,7 +250,7 @@ export function appendWorktreeRows(
lineageChildCount: children.length,
lineageCollapsed,
hostContextLabel:
- hostContextLabelByWorktreeId?.get(worktree.id) ??
+ hostContextLabelByWorktreeId?.get(getWorktreeHostIdentity(worktree)) ??
hostContextLabelByRepoId?.get(worktree.repoId)
})
)
@@ -263,13 +267,15 @@ export function appendWorktreeRows(
})
}
- const roots = worktrees.filter((worktree) => !childIds.has(worktree.id))
+ const roots = worktrees.filter(
+ (worktree) => !childIdentities.has(getWorktreeHostIdentity(worktree))
+ )
for (const [index, worktree] of roots.entries()) {
emit(worktree, 0, [], index === roots.length - 1)
}
if (roots.length === 0) {
for (const worktree of worktrees) {
- if (!emitted.has(worktree.id)) {
+ if (!emitted.has(getWorktreeHostIdentity(worktree))) {
// Why: malformed cyclic lineage should not hide every participant.
// Render any leftovers as roots rather than recursing forever.
emit(worktree, 0, [], true)
diff --git a/src/renderer/src/store/slices/worktree-by-id-index.ts b/src/renderer/src/store/slices/worktree-by-id-index.ts
index 3607ce0f339..eb118513cce 100644
--- a/src/renderer/src/store/slices/worktree-by-id-index.ts
+++ b/src/renderer/src/store/slices/worktree-by-id-index.ts
@@ -5,8 +5,15 @@ import type { Worktree } from '../../../../shared/worktree/types'
*
* Why: session hydration and terminal reconnect loop over pending worktree ids and
* previously re-flattened + linear-searched the whole map per iteration, which is
- * O(worktrees x ids) for O(worktrees + ids) distinct work. First entry wins, matching
- * the `.find()` it replaces.
+ * O(worktrees x ids) for O(worktrees + ids) distinct work.
+ *
+ * FIRST entry wins, matching the `.find()` it replaces — and matching
+ * `getIndexedWorktreeMap`, which used to disagree (STA-4343). Both flatten the
+ * same `worktreesByRepo`, so on an id owned by two hosts one caller resolved to
+ * the local row while the other resolved to the SSH row. Neither answer is "the"
+ * workspace: an id with two hosts is two workspaces and a bare-id lookup cannot
+ * say which. Callers that must distinguish them use `getIndexedWorktreesById`;
+ * destructive callers carry an explicit host instead.
*/
export function buildWorktreeByIdIndex(
worktreesByRepo: Record
diff --git a/src/renderer/src/store/worktree-host-collision-index.test.ts b/src/renderer/src/store/worktree-host-collision-index.test.ts
new file mode 100644
index 00000000000..5546f34203f
--- /dev/null
+++ b/src/renderer/src/store/worktree-host-collision-index.test.ts
@@ -0,0 +1,93 @@
+/**
+ * STA-4343: the same repo at the same path on two hosts is TWO workspaces.
+ *
+ * `worktreeId` is `repoId::path` with no host component, so a repo registered on
+ * both the local host and an SSH host publishes the SAME id twice. Every id-keyed
+ * projection therefore has to decide what a second row means. Collapsing them
+ * leaves the user one row for two workspaces — and no way to confirm the other
+ * one, which makes correct host-qualified routing unreachable.
+ *
+ * The dedup these indices were built for is a different thing: a race between
+ * createWorktree (which appends) and fetchWorktrees (which replaces) can leave
+ * two IDENTICAL entries in one repo array. That still collapses.
+ */
+import { describe, expect, it } from 'vitest'
+import type { Worktree } from '../../../shared/worktree/types'
+import type { AppState } from './types'
+import {
+ getIndexedAllWorktrees,
+ getIndexedWorktreeMap,
+ getIndexedWorktreesById
+} from './worktree-repo-index'
+import { buildWorktreeByIdIndex } from './slices/worktree-by-id-index'
+
+const SHARED_ID = 'repo-1::/work/orca'
+
+const baseWorktree: Worktree = {
+ id: SHARED_ID,
+ repoId: 'repo-1',
+ path: '/work/orca',
+ branch: 'refs/heads/feature',
+ head: 'abc123',
+ isBare: false,
+ isMainWorktree: false,
+ linkedIssue: null,
+ linkedPR: null,
+ linkedLinearIssue: null,
+ isArchived: false,
+ comment: '',
+ isUnread: false,
+ isPinned: false
+} as Worktree
+
+const localRow: Worktree = { ...baseWorktree, hostId: 'local', displayName: 'local orca' }
+const sshRow: Worktree = { ...baseWorktree, hostId: 'ssh:build-box', displayName: 'ssh orca' }
+
+function byRepo(...worktrees: Worktree[]): AppState['worktreesByRepo'] {
+ return { 'repo-1': worktrees }
+}
+
+describe('id-keyed worktree projections keep distinct hosts distinct', () => {
+ it('lists a colliding id once per host', () => {
+ const all = getIndexedAllWorktrees(byRepo(localRow, sshRow))
+
+ expect(all).toHaveLength(2)
+ expect(all.map((entry) => entry.hostId)).toEqual(['local', 'ssh:build-box'])
+ })
+
+ it('still collapses the createWorktree/fetchWorktrees duplicate on one host', () => {
+ // Same id AND same host: this is the append/replace race the dedup exists for.
+ const stale: Worktree = { ...localRow, head: 'stale' }
+ const fresh: Worktree = { ...localRow, head: 'fresh' }
+
+ const all = getIndexedAllWorktrees(byRepo(stale, fresh))
+
+ expect(all).toHaveLength(1)
+ // Last wins: fetchWorktrees replaces, so the later entry is the current one.
+ expect(all[0]?.head).toBe('fresh')
+ })
+
+ it('treats an unqualified row as its own bucket rather than inventing a host', () => {
+ const unqualified: Worktree = { ...baseWorktree, displayName: 'unqualified orca' }
+
+ const all = getIndexedAllWorktrees(byRepo(unqualified, sshRow))
+
+ expect(all).toHaveLength(2)
+ })
+
+ it('enumerates every host that publishes an id', () => {
+ const rows = getIndexedWorktreesById(byRepo(localRow, sshRow), SHARED_ID)
+
+ expect(rows.map((entry) => entry.hostId)).toEqual(['local', 'ssh:build-box'])
+ })
+
+ it('agrees with the other id index on which row a bare id resolves to', () => {
+ const worktreesByRepo = byRepo(localRow, sshRow)
+
+ // Both indices flatten the same data; disagreeing meant one caller saw the
+ // local row and another the SSH row for the same lookup.
+ expect(getIndexedWorktreeMap(worktreesByRepo).get(SHARED_ID)).toBe(
+ buildWorktreeByIdIndex(worktreesByRepo).get(SHARED_ID)
+ )
+ })
+})
diff --git a/src/renderer/src/store/worktree-repo-index.ts b/src/renderer/src/store/worktree-repo-index.ts
index 670e6b7ceec..a6108c77d1a 100644
--- a/src/renderer/src/store/worktree-repo-index.ts
+++ b/src/renderer/src/store/worktree-repo-index.ts
@@ -1,10 +1,15 @@
import type { Repo } from '../../../shared/repo-types'
import type { Worktree } from '../../../shared/worktree/types'
+import {
+ composeWorktreeHostIdentity,
+ getWorktreeIdFromHostIdentity
+} from '../../../shared/worktree/host-qualified-identity'
import type { AppState } from './types'
type WorktreeSnapshot = {
allWorktrees: Worktree[]
worktreeMap: Map
+ worktreesById: Map
}
// Why: Zustand reruns selectors on every write, so identity projections need
@@ -19,17 +24,43 @@ function getWorktreeSnapshot(worktreesByRepo: AppState['worktreesByRepo']): Work
}
// Why: a race between createWorktree (which appends) and fetchWorktrees
- // (which replaces) can produce duplicate entries within one repo array.
- const worktreeMap = new Map()
+ // (which replaces) can produce duplicate entries within one repo array. That
+ // duplicate is the SAME workspace twice, so it collapses — but the key has to
+ // include the host (STA-4343). `worktreeId` is `repoId::path` with no host
+ // component, so the local host and an SSH host can publish the same id for
+ // two genuinely different workspaces, and collapsing those hides one of them
+ // from the sidebar entirely. Last wins within a host: fetchWorktrees replaces,
+ // so the later entry is the current one.
+ const byHostAndId = new Map()
for (const worktrees of Object.values(worktreesByRepo)) {
for (const worktree of worktrees) {
- worktreeMap.set(worktree.id, worktree)
+ byHostAndId.set(composeWorktreeHostIdentity(worktree.hostId, worktree.id), worktree)
}
}
- const snapshot = {
- allWorktrees: Array.from(worktreeMap.values()),
- worktreeMap
+ const allWorktrees: Worktree[] = []
+ const worktreeMap = new Map()
+ const worktreesById = new Map()
+ // Why the identity round-trip: `worktree.id` is a getter on some snapshots and
+ // retained selectors assert it is read exactly once per row, so recover the id
+ // from the key instead of reading the property again.
+ for (const [identity, worktree] of byHostAndId) {
+ const worktreeId = getWorktreeIdFromHostIdentity(identity)
+ allWorktrees.push(worktree)
+ // FIRST wins across hosts, matching `buildWorktreeByIdIndex` and the `.find()`
+ // both replaced. Freshness is not the question here: the same-host duplicate
+ // was already collapsed above, so what is left is a genuine two-host collision
+ // that no bare-id lookup can resolve.
+ if (!worktreeMap.has(worktreeId)) {
+ worktreeMap.set(worktreeId, worktree)
+ }
+ const rows = worktreesById.get(worktreeId)
+ if (rows) {
+ rows.push(worktree)
+ } else {
+ worktreesById.set(worktreeId, [worktree])
+ }
}
+ const snapshot = { allWorktrees, worktreeMap, worktreesById }
worktreeSnapshotCache.set(worktreesByRepo, snapshot)
return snapshot
}
@@ -38,12 +69,27 @@ export function getIndexedAllWorktrees(worktreesByRepo: AppState['worktreesByRep
return getWorktreeSnapshot(worktreesByRepo).allWorktrees
}
+/**
+ * One row per id — a LEGACY convenience for the many id-only lookups.
+ *
+ * It cannot represent a collision, so it is not evidence of ownership: a
+ * destructive path must take an explicit host (see `WorktreeRemovalTarget`) and
+ * use `getIndexedWorktreesById` when it needs to see every owner.
+ */
export function getIndexedWorktreeMap(
worktreesByRepo: AppState['worktreesByRepo']
): Map {
return getWorktreeSnapshot(worktreesByRepo).worktreeMap
}
+/** Every host's row for one id, in `worktreesByRepo` order. */
+export function getIndexedWorktreesById(
+ worktreesByRepo: AppState['worktreesByRepo'],
+ worktreeId: string
+): Worktree[] {
+ return getWorktreeSnapshot(worktreesByRepo).worktreesById.get(worktreeId) ?? []
+}
+
export function getIndexedWorktreeById(
worktreesByRepo: AppState['worktreesByRepo'],
worktreeId: string
diff --git a/src/shared/worktree/host-qualified-identity.ts b/src/shared/worktree/host-qualified-identity.ts
new file mode 100644
index 00000000000..434bacbae10
--- /dev/null
+++ b/src/shared/worktree/host-qualified-identity.ts
@@ -0,0 +1,44 @@
+import type { ExecutionHostId } from '../execution-host'
+import type { Worktree } from './types'
+
+/**
+ * Separator between the host and the workspace id.
+ *
+ * Printable on purpose: these identities become React keys and DOM attribute
+ * values, and HTML attribute parsing replaces U+0000 with U+FFFD — a NUL
+ * separator would not survive the round trip an anchor comparison depends on.
+ * The host comes first so the variable-length id absorbs any `|` of its own.
+ */
+const HOST_SEPARATOR = '|'
+
+/**
+ * Stable key for one workspace on one host (STA-4343).
+ *
+ * `worktreeId` is `repoId::path` with no host component, so a repo registered on
+ * two execution hosts publishes the same id twice for two different workspaces.
+ * Any map, set or React key that must keep them apart keys on this instead.
+ *
+ * An unqualified row gets its own bucket rather than being folded into a host:
+ * it may well BE one of them, but nothing here can prove which.
+ */
+export function getWorktreeHostIdentity(worktree: Pick): string {
+ return composeWorktreeHostIdentity(worktree.hostId, worktree.id)
+}
+
+export function composeWorktreeHostIdentity(
+ hostId: ExecutionHostId | undefined,
+ worktreeId: string
+): string {
+ return `${hostId ?? ''}${HOST_SEPARATOR}${worktreeId}`
+}
+
+/**
+ * The workspace id back out of an identity.
+ *
+ * Host-first ordering makes this exact: the host cannot contain the separator,
+ * so everything after the first one is the id — which lets an index recover the
+ * id without re-reading it off the row.
+ */
+export function getWorktreeIdFromHostIdentity(identity: string): string {
+ return identity.slice(identity.indexOf(HOST_SEPARATOR) + 1)
+}