Sidebar: land the switch-on fixes from the flip branch on main, switch still off (#31466)

* fix(sidebar): land flip-a's switch-on fixes on main, still dark (no flip)

The fixes found while the sidebar ran from main's feed, without turning
that on: ORCA_SIDEBAR_INPUTS_IS_MAIN stays false, and none of flip-a's
stop-gaps come along.

- Main stops counting a closed tab's terminal once it was asked to stop
  and no pane shows it; a failed or refused stop takes the request back,
  and a host out of contact stays unverifiable.
- Main fetches a paired server's rows with the transport's normal bound,
  so a server slower than 5 s still lists.
- A deferred unread raise flips the folder row it was decided for, and a
  read while the owner check is out cancels it.
- Phone: no worktree.ps flash before the feed; a host's last installed
  feed stands in while a fresh read is out; it highlights its own
  last-opened workspace and a fold never scrolls; projects file under
  their project group with the desktop's indent; the collapse spinner
  shows only after 500 ms; "Reopen to update" keeps the rows on screen.
- e2e helpers and journeys for the switch-on suite.

* fix(sidebar): keep an owed relay stop marked, clear a thrown stop, and prune pending unread raises

- A stop-and-wait that throws takes its stop request back, like one that fails.
- A final stop the legacy relay still owes stays marked, since the relay retries it.
- Pending unread raises are tracked only while one waits, so the map no longer grows.
- The paired-server fallback poll test gives its third fetch a reply and asserts live.
This commit is contained in:
Jinwoo Hong
2026-10-21 12:24:27 -04:00
committed by GitHub
parent 50b07c0e8f
commit 92bbb380a6
53 changed files with 2404 additions and 299 deletions
@@ -0,0 +1,34 @@
import { parseExecutionHostId, type ExecutionHostId } from '../../../src/shared/execution-host'
import type { Worktree } from './workspace-list-types'
// Kept free of storage and React Native: the e2e phone-list helper builds sections through this.
/** The workspace this phone last opened on one paired host: the session route's own params. */
export type LastVisitedWorktreeOnHost = {
worktreeId: string
/** A server workspace's `runtime:` host; absent for the desktop's own (local and SSH) rows. */
executionHost?: ExecutionHostId
}
/** The route's server for a row, as the host list routes it: only a `runtime:` host is named. */
export function routeExecutionHostOf(
hostId: string | null | undefined
): ExecutionHostId | undefined {
const parsed = parseExecutionHostId(hostId)
return parsed?.kind === 'runtime' ? parsed.id : undefined
}
/**
* Whether `row` is the workspace this phone last opened on its host: the host list's only highlight.
* The desktop's selection never stands in, so a hidden or never-opened row leaves nothing lit.
*/
export function isLastVisitedWorktreeRow(
row: Pick<Worktree, 'worktreeId' | 'hostId'>,
visited: LastVisitedWorktreeOnHost | null
): boolean {
return (
visited !== null &&
row.worktreeId === visited.worktreeId &&
routeExecutionHostOf(row.hostId) === visited.executionHost
)
}
@@ -45,10 +45,10 @@ export function HostWorkspaceListStates(
return null
}
/** The host serves a list this page cannot build: a page from the host's own build can. */
export function HostWorkspaceListReopen() {
/** A list this page cannot build; shown above the rows it already holds, so it never blanks. */
export function HostWorkspaceListReopen({ aboveRows }: { aboveRows: boolean }) {
return (
<View style={styles.centered}>
<View style={aboveRows ? styles.aboveRows : styles.centered}>
<Text style={styles.emptyText}>Reopen to update</Text>
<Text style={styles.catalogErrorDetail}>
This host lists workspaces in a way this page does not know yet.
@@ -63,6 +63,10 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center'
},
aboveRows: {
alignItems: 'center',
paddingVertical: spacing.md
},
emptyText: {
color: colors.textSecondary,
fontSize: typography.bodySize
@@ -0,0 +1,136 @@
import { describe, expect, it, vi } from 'vitest'
type StorageDouble = { rejection: unknown; values: Map<string, string> }
const storage = vi.hoisted((): StorageDouble => ({ rejection: null, values: new Map() }))
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: async (key: string) => storage.values.get(key) ?? null,
setItem: async (key: string, value: string) => {
if (storage.rejection !== null) {
throw storage.rejection
}
storage.values.set(key, value)
},
removeItem: async (key: string) => {
storage.values.delete(key)
}
}
}))
import { isLastVisitedWorktreeRow } from './host-list-highlight'
import {
readLastVisitedWorktreeOnHost,
subscribeLastVisitedWorktree,
withLastVisitedWorktree,
writeLastVisitedWorktree
} from './last-visited-worktree-by-host'
import {
LAST_VISITED_WORKTREE_STORAGE_KEY,
readLastVisitedWorktreeRecord
} from './last-visited-worktree-repo'
const stored = () => storage.values.get(LAST_VISITED_WORKTREE_STORAGE_KEY) ?? null
describe('readLastVisitedWorktreeOnHost', () => {
it('reads nothing for a host this phone never opened a workspace on', () => {
expect(readLastVisitedWorktreeOnHost(null, 'host-1')).toBeNull()
const raw = withLastVisitedWorktree(null, { hostId: 'host-2', worktreeId: 'repo::/w/a' })
expect(readLastVisitedWorktreeOnHost(raw, 'host-1')).toBeNull()
})
it('remembers each host’s own visit, whichever host was visited last', () => {
let raw = withLastVisitedWorktree(null, { hostId: 'host-1', worktreeId: 'repo::/w/a' })
raw = withLastVisitedWorktree(raw, {
hostId: 'host-2',
worktreeId: 'repo::/w/b',
executionHost: 'runtime:env-1'
})
expect(readLastVisitedWorktreeOnHost(raw, 'host-1')).toEqual({ worktreeId: 'repo::/w/a' })
expect(readLastVisitedWorktreeOnHost(raw, 'host-2')).toEqual({
worktreeId: 'repo::/w/b',
executionHost: 'runtime:env-1'
})
// Home's Resume card still reads the latest visit off the same record.
expect(readLastVisitedWorktreeRecord(raw)).toEqual({
hostId: 'host-2',
worktreeId: 'repo::/w/b'
})
})
it('reads a record stored before per-host visits for its own host, and keeps it on the next write', () => {
const legacy = JSON.stringify({ hostId: 'host-1', worktreeId: 'repo::/w/a' })
expect(readLastVisitedWorktreeOnHost(legacy, 'host-1')).toEqual({ worktreeId: 'repo::/w/a' })
expect(readLastVisitedWorktreeOnHost(legacy, 'host-2')).toBeNull()
const raw = withLastVisitedWorktree(legacy, { hostId: 'host-2', worktreeId: 'repo::/w/b' })
expect(readLastVisitedWorktreeOnHost(raw, 'host-1')).toEqual({ worktreeId: 'repo::/w/a' })
})
it('drops the oldest hosts past the cap rather than the store refusing the value', () => {
let raw: string | null = null
for (let index = 0; index < 20; index++) {
raw = withLastVisitedWorktree(raw, { hostId: `host-${index}`, worktreeId: 'repo::/w/a' })
}
expect(readLastVisitedWorktreeOnHost(raw, 'host-0')).toBeNull()
expect(readLastVisitedWorktreeOnHost(raw, 'host-19')).toEqual({ worktreeId: 'repo::/w/a' })
})
})
describe('isLastVisitedWorktreeRow', () => {
it('matches a desktop row (local or SSH) by id, and a server row by its server too', () => {
const visit = { worktreeId: 'repo::/w/a' }
expect(isLastVisitedWorktreeRow({ worktreeId: 'repo::/w/a', hostId: 'local' }, visit)).toBe(
true
)
expect(isLastVisitedWorktreeRow({ worktreeId: 'repo::/w/a', hostId: 'ssh:gpu' }, visit)).toBe(
true
)
expect(
isLastVisitedWorktreeRow({ worktreeId: 'repo::/w/a', hostId: 'runtime:env-1' }, visit)
).toBe(false)
const serverVisit = { worktreeId: 'repo::/w/a', executionHost: 'runtime:env-1' as const }
expect(
isLastVisitedWorktreeRow({ worktreeId: 'repo::/w/a', hostId: 'runtime:env-1' }, serverVisit)
).toBe(true)
expect(isLastVisitedWorktreeRow({ worktreeId: 'repo::/w/a', hostId: 'local' }, null)).toBe(
false
)
})
})
describe('writeLastVisitedWorktree', () => {
it('tells listeners at once and stores the visit beside other hosts’ visits', async () => {
storage.values.clear()
const told: unknown[] = []
const unsubscribe = subscribeLastVisitedWorktree((hostId, visit) => told.push([hostId, visit]))
void writeLastVisitedWorktree({ hostId: 'host-1', worktreeId: 'repo::/w/a' })
expect(told).toEqual([['host-1', { worktreeId: 'repo::/w/a' }]])
await writeLastVisitedWorktree({
hostId: 'host-2',
worktreeId: 'repo::/w/b',
executionHost: 'runtime:env-1'
})
unsubscribe()
expect(readLastVisitedWorktreeOnHost(stored(), 'host-1')).toEqual({ worktreeId: 'repo::/w/a' })
expect(readLastVisitedWorktreeOnHost(stored(), 'host-2')).toEqual({
worktreeId: 'repo::/w/b',
executionHost: 'runtime:env-1'
})
})
it('handles a store that refuses the write instead of leaving the rejection loose', async () => {
storage.rejection = new Error('quota exceeded')
const seen: unknown[] = []
const listener = (reason: unknown) => seen.push(reason)
process.on('unhandledRejection', listener)
try {
await writeLastVisitedWorktree({ hostId: 'host-1', worktreeId: 'repo::/w/c' })
await new Promise<void>((resolve) => setImmediate(resolve))
} finally {
process.off('unhandledRejection', listener)
storage.rejection = null
}
expect(seen).toEqual([])
})
})
@@ -0,0 +1,148 @@
import type { ExecutionHostId } from '../../../src/shared/execution-host'
import { PAGE_STORAGE_MAX_VALUE_CHARS } from '../mobile-web-shell/page-storage-keys'
import { persistMirrored, readMirroredKeyOrStore } from '../storage/mirrored-storage-keys'
import {
LAST_VISITED_WORKTREE_STORAGE_KEY,
readLastVisitedWorktreeRecord,
type LastVisitedWorktreeRecord
} from './last-visited-worktree-repo'
import { routeExecutionHostOf, type LastVisitedWorktreeOnHost } from './host-list-highlight'
// Why a cap: the page's store refuses a value past its frame cap, and a forgotten host never writes again.
const MAX_REMEMBERED_HOSTS = 16
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function visitOf(worktreeId: string, executionHost: string | undefined): LastVisitedWorktreeOnHost {
const server = routeExecutionHostOf(executionHost)
return server ? { worktreeId, executionHost: server } : { worktreeId }
}
function readEntry(value: unknown): LastVisitedWorktreeOnHost | null {
if (!isRecord(value) || typeof value.worktreeId !== 'string' || value.worktreeId === '') {
return null
}
return visitOf(
value.worktreeId,
typeof value.executionHost === 'string' ? value.executionHost : undefined
)
}
function readByHost(raw: string | null): Map<string, LastVisitedWorktreeOnHost> {
const byHost = new Map<string, LastVisitedWorktreeOnHost>()
if (!raw) {
return byHost
}
try {
const parsed: unknown = JSON.parse(raw)
if (isRecord(parsed) && isRecord(parsed.byHost)) {
for (const [hostId, value] of Object.entries(parsed.byHost)) {
const entry = readEntry(value)
if (entry) {
byHost.set(hostId, entry)
}
}
}
} catch {
// Unreadable history is no history.
}
return byHost
}
/** This phone's last-opened workspace on `hostId`, or null when it has opened none there. */
export function readLastVisitedWorktreeOnHost(
raw: string | null,
hostId: string
): LastVisitedWorktreeOnHost | null {
const entry = readByHost(raw).get(hostId)
if (entry) {
return entry
}
// A record written before `byHost` names only the last host, and only the desktop's own rows.
const record = readLastVisitedWorktreeRecord(raw)
return record?.hostId === hostId ? { worktreeId: record.worktreeId } : null
}
/** The stored value with `record` as the latest visit and its host's entry. */
export function withLastVisitedWorktree(
raw: string | null,
record: LastVisitedWorktreeRecord & { executionHost?: ExecutionHostId }
): string {
const byHost = readByHost(raw)
const previous = readLastVisitedWorktreeRecord(raw)
if (previous && !byHost.has(previous.hostId)) {
byHost.set(previous.hostId, { worktreeId: previous.worktreeId })
}
// Re-inserted so the map's order is oldest visit first, which is what the cap drops.
byHost.delete(record.hostId)
byHost.set(record.hostId, visitOf(record.worktreeId, record.executionHost))
const serialize = () =>
JSON.stringify({
hostId: record.hostId,
worktreeId: record.worktreeId,
byHost: Object.fromEntries(byHost)
})
let value = serialize()
while (
byHost.size > 1 &&
(byHost.size > MAX_REMEMBERED_HOSTS || value.length > PAGE_STORAGE_MAX_VALUE_CHARS)
) {
const oldest = byHost.keys().next().value
if (oldest === undefined) {
break
}
byHost.delete(oldest)
value = serialize()
}
return value
}
type Listener = (hostId: string, visited: LastVisitedWorktreeOnHost) => void
const listeners = new Set<Listener>()
/** Visits noted in this JS context, told at once so an open list moves without a re-read. */
export function subscribeLastVisitedWorktree(listener: Listener): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
/**
* Tells this context's open lists about a visit without storing it. The list's tap uses this: the
* shell's session page is another page that stores the visit itself, and a long-lived list page's
* load-time copy of the key could overwrite newer visits to other hosts.
*/
export function noteLastVisitedWorktree(
record: LastVisitedWorktreeRecord & { executionHost?: ExecutionHostId }
): void {
const visited = visitOf(record.worktreeId, record.executionHost)
for (const listener of listeners) {
listener(record.hostId, visited)
}
}
// Serialized: each write merges into what the last one stored.
let writeChain: Promise<void> = Promise.resolve()
/**
* The one writer of this key (the session route), so the hybrid shell's mirror sees it as it is
* written. The page is handed this key on every `init`, built from that mirror; a write that went
* straight to the store would reach the page one `init` later.
*/
export function writeLastVisitedWorktree(
record: LastVisitedWorktreeRecord & { executionHost?: ExecutionHostId }
): Promise<void> {
noteLastVisitedWorktree(record)
// Through the path that notes on an accepted write (ruling 35); the page's adapter may refuse,
// and nothing is owed a caller that cannot act on one, so a refusal is the adapter's log.
writeChain = writeChain
.then(() => readMirroredKeyOrStore(LAST_VISITED_WORKTREE_STORAGE_KEY).catch(() => null))
.then((raw) =>
persistMirrored(LAST_VISITED_WORKTREE_STORAGE_KEY, withLastVisitedWorktree(raw, record))
)
.catch(() => {})
return writeChain
}
@@ -1,65 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
type StorageDouble = { rejection: unknown; written: string[] }
const storage = vi.hoisted((): StorageDouble => ({ rejection: null, written: [] }))
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
setItem: async (_key: string, value: string) => {
if (storage.rejection !== null) {
throw storage.rejection
}
storage.written.push(value)
},
removeItem: async () => undefined
}
}))
import { describe, expect, it } from 'vitest'
import {
readLastVisitedWorktreeRecord,
readLastVisitedWorktreeRepoId,
writeLastVisitedWorktree
readLastVisitedWorktreeRepoId
} from './last-visited-worktree-repo'
/** Node reports an unhandled rejection at the end of a microtask checkpoint, so one macrotask is
* long enough to see it, and a listener is the only way to observe one from inside a test. */
async function unhandledRejectionsWhile(run: () => void): Promise<unknown[]> {
const seen: unknown[] = []
const listener = (reason: unknown) => seen.push(reason)
process.on('unhandledRejection', listener)
try {
run()
await new Promise<void>((resolve) => setImmediate(resolve))
await new Promise<void>((resolve) => setImmediate(resolve))
} finally {
process.off('unhandledRejection', listener)
}
return seen
}
// Why: the mirror reports this key as written the moment it is noted, so a store write that
// rejects must be handled where it is made. Nothing above it is holding a catch.
describe('writeLastVisitedWorktree', () => {
it('handles a store that refuses the write instead of leaving the rejection loose', async () => {
storage.rejection = new Error('quota exceeded')
const loose = await unhandledRejectionsWhile(() => {
writeLastVisitedWorktree({ hostId: 'host-1', worktreeId: 'repo-2::/tmp/worktree' })
})
storage.rejection = null
expect(loose).toEqual([])
})
it('still persists the record when the store takes it', async () => {
storage.written.length = 0
writeLastVisitedWorktree({ hostId: 'host-1', worktreeId: 'repo-2::/tmp/worktree' })
await new Promise<void>((resolve) => setImmediate(resolve))
expect(storage.written).toEqual([
JSON.stringify({ hostId: 'host-1', worktreeId: 'repo-2::/tmp/worktree' })
])
})
})
describe('last visited worktree repo', () => {
it('extracts the repo id for the current host', () => {
const raw = JSON.stringify({ hostId: 'host-1', worktreeId: 'repo-2::/tmp/worktree' })
@@ -1,4 +1,3 @@
import { persistMirrored } from '../storage/mirrored-storage-keys'
import { getRepoIdFromMobileWorktreeId } from '../session/mobile-session-route-helpers'
export const LAST_VISITED_WORKTREE_STORAGE_KEY = 'orca:last-visited-worktree'
@@ -47,18 +46,3 @@ export function readLastVisitedWorktreeRepoId(raw: string | null, hostId: string
const repoId = getRepoIdFromMobileWorktreeId(record.worktreeId).trim()
return repoId || null
}
/**
* The one writer of this key, so the hybrid shell's mirror sees it as it is written.
*
* The page is handed this key on every `init`, built synchronously from that mirror; a write that
* went straight to the store would reach the page one `init` later, and the New Workspace drawer
* would open on the repo the user left rather than the one they just came from.
*/
export function writeLastVisitedWorktree(record: LastVisitedWorktreeRecord): void {
// Through the path that notes on an accepted write (ruling 35): this module is in the page's
// own closure, so the store behind it may be the bridge's adapter, which refuses a key this
// route was never given. Nothing is owed a caller that cannot act on one, so the refusal is the
// adapter's log rather than a rejection here.
void persistMirrored(LAST_VISITED_WORKTREE_STORAGE_KEY, JSON.stringify(record)).catch(() => {})
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { projectGroupHeaderIndent, projectGroupRowIndent } from './project-group-indent'
const DEPTHS = [0, 1, 2, 6, 9, undefined]
describe('project-group indent', () => {
it('steps headers 10 px a level, up to six levels', () => {
expect(DEPTHS.map(projectGroupHeaderIndent)).toEqual([0, 10, 20, 60, 60, 0])
})
it('steps a hand-made group’s rows, folder workspaces too, a full tree step a level', () => {
for (const workspaceKind of ['git', 'folder-workspace', undefined] as const) {
expect(
DEPTHS.map((projectGroupDepth) =>
projectGroupRowIndent({ workspaceKind, projectGroupDepth })
)
).toEqual([0, 18, 36, 108, 108, 0])
}
})
it('steps a folder-scanned group’s rows by the header step, its folder workspaces one level less', () => {
const indent = (workspaceKind: 'git' | 'folder-workspace', projectGroupDepth: number) =>
projectGroupRowIndent({ workspaceKind, projectGroupDepth, inFolderScanGroup: true })
expect([1, 2, 7].map((depth) => indent('git', depth))).toEqual([10, 20, 60])
expect([1, 2, 7, 9].map((depth) => indent('folder-workspace', depth))).toEqual([0, 10, 60, 60])
})
})
@@ -0,0 +1,32 @@
import type { Worktree } from './workspace-list-types'
// The desktop's project-group tree steps (indentation.ts): headers 10 px a level, workspace cards a
// full 18 px tree step; capped at six levels, as the headers are.
const PROJECT_GROUP_HEADER_INDENT = 10
const TREE_INDENT = 18
const MAX_PROJECT_GROUP_DEPTH = 6
function cappedDepth(depth: number | undefined): number {
return Math.min(Math.max(0, Math.floor(depth ?? 0)), MAX_PROJECT_GROUP_DEPTH)
}
/** A header's indent past the phone's base padding, for its depth in the group tree. */
export function projectGroupHeaderIndent(depth: number | undefined): number {
return cappedDepth(depth) * PROJECT_GROUP_HEADER_INDENT
}
/**
* A row's indent past the phone's base padding: what the desktop adds over an ungrouped card. A
* folder-scanned group's rows take the header step, its folder workspaces one level less.
*/
export function projectGroupRowIndent(
row: Pick<Worktree, 'workspaceKind' | 'projectGroupDepth' | 'inFolderScanGroup'>
): number {
const depth = row.projectGroupDepth ?? 0
if (!row.inFolderScanGroup) {
return cappedDepth(depth) * TREE_INDENT
}
return row.workspaceKind === 'folder-workspace'
? cappedDepth(depth - 1) * PROJECT_GROUP_HEADER_INDENT
: cappedDepth(depth) * PROJECT_GROUP_HEADER_INDENT
}
@@ -0,0 +1,136 @@
import type * as PageStorage from '../mobile-web-shell/bridge/page-async-storage'
import type * as Visits from './last-visited-worktree-by-host'
import { describe, expect, it, vi } from 'vitest'
const KEY = 'orca:last-visited-worktree'
const HOST = 'host-1'
/** The app's own store, which every page's writes reach and every page load copies. */
const appStore = new Map<string, string>()
type PageModules = {
storage: typeof PageStorage
visits: typeof Visits
}
/** One embedded page: its own module graph and its own copy of the store, taken at load. */
async function loadPage(pathname: string): Promise<PageModules> {
vi.resetModules()
// Each page's store is its own page adapter, as in the shell: a load-time copy of the app's.
// doMock, not mock: the mock registry outlives resetModules, which would share one adapter.
vi.doMock('@react-native-async-storage/async-storage', async () => ({
default: (await import('../mobile-web-shell/bridge/page-async-storage')).default
}))
const storage = await import('../mobile-web-shell/bridge/page-async-storage')
const visits = await import('./last-visited-worktree-by-host')
storage.publishPageStorage(
Object.fromEntries(appStore),
(key, value) => {
if (value === null) {
appStore.delete(key)
} else {
appStore.set(key, value)
}
return true
},
HOST,
pathname
)
return { storage, visits }
}
/** The list page: a fresh graph rendering the hook, with its React and renderer from that graph. */
async function mountListPage(): Promise<{
page: PageModules
value: () => unknown
settle: () => Promise<void>
unmount: () => void
}> {
const page = await loadPage(`/h/${HOST}`)
const { createElement } = await import('react')
const { act, create } = await import('react-test-renderer')
const { useLastVisitedWorktreeOnHost } = await import('./use-last-visited-worktree-on-host')
const held: { value: unknown } = { value: undefined }
function ListProbe(): null {
held.value = useLastVisitedWorktreeOnHost(HOST)
return null
}
let unmount = () => {}
await act(async () => {
const renderer = create(createElement(ListProbe))
unmount = () => act(() => renderer.unmount())
})
// Lets this graph's renderer take state set from outside an act scope.
const settle = () => act(async () => {})
return { page, value: () => held.value, settle, unmount }
}
describe('the list page highlight across the shell’s separate pages', () => {
it('highlights the tapped row after the session page opens and Back returns to the list', async () => {
appStore.clear()
const list = await mountListPage()
expect(list.value()).toBeNull()
// Another host's visit lands after the list page loaded, so the list's copy is stale.
appStore.set(
KEY,
JSON.stringify({
hostId: 'host-2',
worktreeId: 'repo::/w/other',
byHost: { 'host-2': { worktreeId: 'repo::/w/other' } }
})
)
// Tap w2: the list notes the visit in its own page.
list.page.visits.noteLastVisitedWorktree({ hostId: HOST, worktreeId: 'repo::/w/2' })
await list.settle()
expect(list.value()).toEqual({ worktreeId: 'repo::/w/2' })
// The shell pushes the session page: a fresh graph and a fresh copy, which stores the visit.
const session = await loadPage(`/h/${HOST}/session/${encodeURIComponent('repo::/w/2')}`)
await session.visits.writeLastVisitedWorktree({ hostId: HOST, worktreeId: 'repo::/w/2' })
// Back: the list page was never reloaded and still highlights the tapped row.
expect(list.value()).toEqual({ worktreeId: 'repo::/w/2' })
// Neither page's write overwrote the newer visit to the other host.
const stored = appStore.get(KEY) ?? null
expect(session.visits.readLastVisitedWorktreeOnHost(stored, 'host-2')).toEqual({
worktreeId: 'repo::/w/other'
})
expect(session.visits.readLastVisitedWorktreeOnHost(stored, HOST)).toEqual({
worktreeId: 'repo::/w/2'
})
list.unmount()
})
it('reads the stored visit when the list page loads again', async () => {
appStore.clear()
const session = await loadPage(`/h/${HOST}/session/w`)
await session.visits.writeLastVisitedWorktree({ hostId: HOST, worktreeId: 'repo::/w/3' })
const list = await mountListPage()
expect(list.value()).toEqual({ worktreeId: 'repo::/w/3' })
list.unmount()
})
it('follows a session page’s write when the list is the app’s own screen beside it', async () => {
appStore.clear()
const list = await mountListPage()
const mirror = await import('../storage/mirrored-storage-keys')
// The app taking a session page's write, which is how a page's visit reaches an app screen.
mirror.writeMirroredStorage(
KEY,
JSON.stringify({
hostId: HOST,
worktreeId: 'repo::/w/4',
byHost: { [HOST]: { worktreeId: 'repo::/w/4' } }
})
)
await list.settle()
expect(list.value()).toEqual({ worktreeId: 'repo::/w/4' })
// An older page's write names another host only; this host's visit stands.
mirror.writeMirroredStorage(KEY, JSON.stringify({ hostId: 'host-2', worktreeId: 'repo::/w/x' }))
await list.settle()
expect(list.value()).toEqual({ worktreeId: 'repo::/w/4' })
list.unmount()
})
})
@@ -0,0 +1,63 @@
import { useEffect, useState } from 'react'
import { readMirroredKeyOrStore, subscribeMirroredKey } from '../storage/mirrored-storage-keys'
import { LAST_VISITED_WORKTREE_STORAGE_KEY } from './last-visited-worktree-repo'
import type { LastVisitedWorktreeOnHost } from './host-list-highlight'
import {
readLastVisitedWorktreeOnHost,
subscribeLastVisitedWorktree
} from './last-visited-worktree-by-host'
/**
* The workspace this phone last opened on `hostId`: stored, then each visit noted in this context
* (a list tap) or written through this context's store (in the app, a session page's write).
* A web page's store is its load-time copy, so a visit another page makes reaches it on reload.
* Null until the stored visit is read, and when this phone has opened nothing there.
*/
export function useLastVisitedWorktreeOnHost(
hostId: string | undefined
): LastVisitedWorktreeOnHost | null {
const [visited, setVisited] = useState<{
hostId: string
value: LastVisitedWorktreeOnHost | null
} | null>(null)
useEffect(() => {
if (!hostId) {
return
}
let stale = false
let noted = false
const take = (value: LastVisitedWorktreeOnHost) => {
noted = true
setVisited({ hostId, value })
}
const unsubscribeNotes = subscribeLastVisitedWorktree((visitedHostId, value) => {
if (visitedHostId === hostId) {
take(value)
}
})
const unsubscribeStore = subscribeMirroredKey(LAST_VISITED_WORKTREE_STORAGE_KEY, (raw) => {
// A write naming no visit here (another host's, in an older shape) leaves this one standing.
const value = readLastVisitedWorktreeOnHost(raw, hostId)
if (value) {
take(value)
}
})
void readMirroredKeyOrStore(LAST_VISITED_WORKTREE_STORAGE_KEY)
.then((raw) => {
// A visit noted while the read was open is newer than anything it returns.
if (!stale && !noted) {
setVisited({ hostId, value: readLastVisitedWorktreeOnHost(raw, hostId) })
}
})
.catch(() => {})
return () => {
stale = true
unsubscribeNotes()
unsubscribeStore()
}
}, [hostId])
// Keyed by host so a switch to another host never shows the last one's visit.
return visited && visited.hostId === hostId ? visited.value : null
}
@@ -55,6 +55,10 @@ export type Worktree = {
parentWorktreeId?: string | null
childWorktreeIds?: string[]
lineageDepth?: number
/** How deep the row sits in the desktop's project-group tree; indents it the same way. */
projectGroupDepth?: number
/** Its project group came from a folder scan, whose rows the desktop steps more compactly. */
inFolderScanGroup?: boolean
lineageChildCount?: number
lineageCollapsed?: boolean
/** The collapse key the shared builder gave this row's children; set only on a feed-built list. */
@@ -100,6 +104,10 @@ export type Section = {
icon?: 'pin'
/** Under Project grouping, the repo whose colour and icon the header shows, as on the desktop. */
repo?: SectionRepo
/** A project group's own header (no repo), drawn with the group icon, as on the desktop. */
isProjectGroup?: boolean
/** How deep the header sits in the desktop's project-group tree; indents it the same way. */
projectGroupDepth?: number
/** The header's own count from a feed-built list, which a collapsed section still shows. */
count?: number
/** Drawn under the header, above its rows, as the desktop places them. */