feat: restore last workspace on cold start
Persist the user's last active workspace and navigate directly to it on cold start instead of always landing on the open-project screen. - Unified bootstrap into one cross-platform wait step after platform-specific connection setup - Store-level wait with workspace preference: preferred host online resolves immediately, grace period if a different host connects first, bounded 5s timeout for unreachable hosts - index.tsx is fully declarative — reads resolved target from context, no useEffect/promises/refs/timers
This commit is contained in:
@@ -282,6 +282,72 @@ function updateControllerSnapshot(
|
||||
).updateSnapshot(patch);
|
||||
}
|
||||
|
||||
function makeSingleConnectionHost(serverId: string): HostProfile {
|
||||
return makeHost({
|
||||
serverId,
|
||||
connections: [
|
||||
{
|
||||
id: `direct:${serverId}:6767`,
|
||||
type: "directTcp",
|
||||
endpoint: `${serverId}:6767`,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `direct:${serverId}:6767`,
|
||||
});
|
||||
}
|
||||
|
||||
function createOfflineOnlyStore(): HostRuntimeStore {
|
||||
return new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => {
|
||||
throw new Error("startup host tests drive snapshots directly");
|
||||
},
|
||||
connectToDaemon: async () => {
|
||||
throw new Error("offline");
|
||||
},
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function setStoreHosts(store: HostRuntimeStore, hosts: HostProfile[]): void {
|
||||
(
|
||||
store as unknown as {
|
||||
setHostsAndSync: (hosts: HostProfile[]) => void;
|
||||
}
|
||||
).setHostsAndSync(hosts);
|
||||
}
|
||||
|
||||
function markStoreHostOnline(
|
||||
store: HostRuntimeStore,
|
||||
serverId: string,
|
||||
lastOnlineAt: string,
|
||||
): void {
|
||||
const controller = (
|
||||
store as unknown as {
|
||||
controllers: Map<string, HostRuntimeController>;
|
||||
}
|
||||
).controllers.get(serverId);
|
||||
if (!controller) {
|
||||
throw new Error(`Missing controller for ${serverId}`);
|
||||
}
|
||||
(
|
||||
store as unknown as {
|
||||
lastConnectionStatusByServer: Map<string, HostRuntimeSnapshot["connectionStatus"]>;
|
||||
}
|
||||
).lastConnectionStatusByServer.set(serverId, "online");
|
||||
updateControllerSnapshot(controller, {
|
||||
connectionStatus: "online",
|
||||
hasEverLoadedAgentDirectory: true,
|
||||
lastOnlineAt,
|
||||
});
|
||||
(
|
||||
store as unknown as {
|
||||
emit: (serverId: string) => void;
|
||||
}
|
||||
).emit(serverId);
|
||||
}
|
||||
|
||||
function makeProbeMap(
|
||||
entries: Array<
|
||||
[string, HostRuntimeSnapshot["probeByConnectionId"] extends Map<string, infer T> ? T : never]
|
||||
@@ -1158,6 +1224,113 @@ describe("HostRuntimeController", () => {
|
||||
});
|
||||
|
||||
describe("HostRuntimeStore", () => {
|
||||
it("resolves startup host immediately when the preferred host is first online", async () => {
|
||||
const store = createOfflineOnlyStore();
|
||||
setStoreHosts(store, [
|
||||
makeSingleConnectionHost("srv_preferred"),
|
||||
makeSingleConnectionHost("srv_other"),
|
||||
]);
|
||||
|
||||
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
|
||||
markStoreHostOnline(store, "srv_preferred", "2026-04-26T10:00:00.000Z");
|
||||
|
||||
await expect(startup.promise).resolves.toEqual({ serverId: "srv_preferred" });
|
||||
startup.cancel();
|
||||
setStoreHosts(store, []);
|
||||
});
|
||||
|
||||
it("waits through grace for the preferred host after a different host comes online first", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const store = createOfflineOnlyStore();
|
||||
setStoreHosts(store, [
|
||||
makeSingleConnectionHost("srv_preferred"),
|
||||
makeSingleConnectionHost("srv_other"),
|
||||
]);
|
||||
|
||||
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
|
||||
markStoreHostOnline(store, "srv_other", "2026-04-26T10:00:00.000Z");
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
markStoreHostOnline(store, "srv_preferred", "2026-04-26T10:00:01.000Z");
|
||||
|
||||
await expect(startup.promise).resolves.toEqual({ serverId: "srv_preferred" });
|
||||
startup.cancel();
|
||||
setStoreHosts(store, []);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the first online host when preferred does not arrive during grace", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const store = createOfflineOnlyStore();
|
||||
setStoreHosts(store, [
|
||||
makeSingleConnectionHost("srv_preferred"),
|
||||
makeSingleConnectionHost("srv_other"),
|
||||
]);
|
||||
|
||||
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
|
||||
markStoreHostOnline(store, "srv_other", "2026-04-26T10:00:00.000Z");
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
|
||||
await expect(startup.promise).resolves.toEqual({ serverId: "srv_other" });
|
||||
startup.cancel();
|
||||
setStoreHosts(store, []);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves the first online host immediately when no preferred host is set", async () => {
|
||||
const store = createOfflineOnlyStore();
|
||||
setStoreHosts(store, [makeSingleConnectionHost("srv_other")]);
|
||||
|
||||
const startup = store.waitForAnyConnectionOnline({ preferredServerId: null });
|
||||
markStoreHostOnline(store, "srv_other", "2026-04-26T10:00:00.000Z");
|
||||
|
||||
await expect(startup.promise).resolves.toEqual({ serverId: "srv_other" });
|
||||
startup.cancel();
|
||||
setStoreHosts(store, []);
|
||||
});
|
||||
|
||||
it("resolves null when startup has no hosts to wait for", async () => {
|
||||
const store = createOfflineOnlyStore();
|
||||
|
||||
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
|
||||
|
||||
await expect(startup.promise).resolves.toBeNull();
|
||||
startup.cancel();
|
||||
});
|
||||
|
||||
it("resolves null using the connection online timeout when hosts exist but none come online", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const store = createOfflineOnlyStore();
|
||||
setStoreHosts(store, [
|
||||
makeSingleConnectionHost("srv_preferred"),
|
||||
makeSingleConnectionHost("srv_other"),
|
||||
]);
|
||||
|
||||
let resolved = false;
|
||||
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
|
||||
startup.promise.then(() => {
|
||||
resolved = true;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4_999);
|
||||
expect(resolved).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
await expect(startup.promise).resolves.toBeNull();
|
||||
startup.cancel();
|
||||
setStoreHosts(store, []);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("bootstraps agent directory subscription when host transitions online", async () => {
|
||||
const host = makeHost({
|
||||
connections: [
|
||||
|
||||
@@ -70,6 +70,10 @@ export interface HostRuntimeSnapshot {
|
||||
clientGeneration: number;
|
||||
}
|
||||
|
||||
export interface StartupHostResolution {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
type HostRuntimeSnapshotPatch = Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>;
|
||||
|
||||
function setSnapshotPatchField<Key extends keyof HostRuntimeSnapshotPatch>(
|
||||
@@ -1151,6 +1155,8 @@ const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
|
||||
const DEFAULT_LOCALHOST_ENDPOINT = process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767";
|
||||
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
|
||||
const CONNECTION_ONLINE_TIMEOUT_MS = 15_000;
|
||||
const STARTUP_HOST_GRACE_MS = 2_000;
|
||||
const STARTUP_NO_HOST_ONLINE_TIMEOUT_MS = 5_000;
|
||||
const E2E_STORAGE_KEY = "@paseo:e2e";
|
||||
|
||||
export class HostRuntimeStore {
|
||||
@@ -1753,30 +1759,101 @@ export class HostRuntimeStore {
|
||||
return earliestServerId;
|
||||
}
|
||||
|
||||
waitForAnyConnectionOnline(): { promise: Promise<void>; cancel: () => void } {
|
||||
waitForAnyConnectionOnline(input?: { preferredServerId?: string | null; graceMs?: number }): {
|
||||
promise: Promise<StartupHostResolution | null>;
|
||||
cancel: () => void;
|
||||
} {
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let graceTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let noHostOnlineTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let fallbackServerId: string | null = null;
|
||||
let settled = false;
|
||||
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
if (this.getEarliestOnlineHostServerId() !== null) {
|
||||
resolve();
|
||||
return;
|
||||
const normalizedPreferredServerId = input?.preferredServerId?.trim() || null;
|
||||
const normalizedGraceMs = Math.max(0, input?.graceMs ?? STARTUP_HOST_GRACE_MS);
|
||||
|
||||
const cleanup = (): void => {
|
||||
if (graceTimeout) {
|
||||
clearTimeout(graceTimeout);
|
||||
graceTimeout = null;
|
||||
}
|
||||
if (noHostOnlineTimeout) {
|
||||
clearTimeout(noHostOnlineTimeout);
|
||||
noHostOnlineTimeout = null;
|
||||
}
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
};
|
||||
|
||||
unsubscribe = this.subscribeAll(() => {
|
||||
if (this.getEarliestOnlineHostServerId() !== null) {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
resolve();
|
||||
const promise = new Promise<StartupHostResolution | null>((resolve) => {
|
||||
const settle = (result: StartupHostResolution | null): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const getPreferredOnlineServerId = (): string | null => {
|
||||
if (!normalizedPreferredServerId) {
|
||||
return null;
|
||||
}
|
||||
return isHostRuntimeConnected(this.getSnapshot(normalizedPreferredServerId))
|
||||
? normalizedPreferredServerId
|
||||
: null;
|
||||
};
|
||||
|
||||
const evaluate = (): void => {
|
||||
if (this.hosts.length === 0) {
|
||||
settle(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const preferredOnlineServerId = getPreferredOnlineServerId();
|
||||
if (preferredOnlineServerId) {
|
||||
settle({ serverId: preferredOnlineServerId });
|
||||
return;
|
||||
}
|
||||
|
||||
const earliestOnlineServerId = this.getEarliestOnlineHostServerId();
|
||||
if (!earliestOnlineServerId) {
|
||||
if (!noHostOnlineTimeout) {
|
||||
noHostOnlineTimeout = setTimeout(() => {
|
||||
settle(null);
|
||||
}, STARTUP_NO_HOST_ONLINE_TIMEOUT_MS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (noHostOnlineTimeout) {
|
||||
clearTimeout(noHostOnlineTimeout);
|
||||
noHostOnlineTimeout = null;
|
||||
}
|
||||
|
||||
if (!normalizedPreferredServerId) {
|
||||
settle({ serverId: earliestOnlineServerId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fallbackServerId) {
|
||||
fallbackServerId = earliestOnlineServerId;
|
||||
graceTimeout = setTimeout(() => {
|
||||
const latestPreferredOnlineServerId = getPreferredOnlineServerId();
|
||||
settle({
|
||||
serverId: latestPreferredOnlineServerId ?? fallbackServerId ?? earliestOnlineServerId,
|
||||
});
|
||||
}, normalizedGraceMs);
|
||||
}
|
||||
};
|
||||
|
||||
unsubscribe = this.subscribeAll(evaluate);
|
||||
evaluate();
|
||||
});
|
||||
|
||||
return {
|
||||
promise,
|
||||
cancel: () => {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
},
|
||||
cancel: cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user