From e148030f982fb4d1f26f7c85897b22f3fd0856a2 Mon Sep 17 00:00:00 2001
From: Satya Patel <14907857+saddlepaddle@users.noreply.github.com>
Date: Sat, 26 Sep 2026 22:06:14 +0000
Subject: [PATCH 1/3] fix(mobile): open cloud workspaces optimistically,
matching desktop
Mobile gated the whole workspace screen on host-service confirming the
sandbox's workspace row before showing anything, so opening any cloud
workspace flashed the full "Starting workspace" screen even when the
sandbox was already warm, and stayed on it the whole time a stopped
sandbox took to wake.
Desktop never waits for that confirmation: it addresses a cloud
workspace at its ticketed URL as soon as it has one and mounts the
workspace UI immediately, surfacing connection state as a banner rather
than a gate. Port that here:
- useWorkspaceHost: derive `host` from the sandbox access ticket
instead of from host-service's own workspace.list confirmation, so
terminals can be requested from the same address without an extra
round trip.
- WorkspaceScreen: trust the remembered/deep-linked terminal tab as
soon as the host is addressed, even before the terminal list has
answered, and only fall back to strict matching once it has. The
full-screen provisioning state now only covers "not addressed yet"
(still provisioning, or the ticket itself is failing); everything
after that is the terminal's own connecting/reconnecting banner.
---
.../useWorkspaceHost/useWorkspaceHost.ts | 40 +++++++--------
.../[id]/WorkspaceScreen/WorkspaceScreen.tsx | 51 ++++++++++++-------
2 files changed, 52 insertions(+), 39 deletions(-)
diff --git a/apps/mobile/hooks/useWorkspaceHost/useWorkspaceHost.ts b/apps/mobile/hooks/useWorkspaceHost/useWorkspaceHost.ts
index 40c85522c0f..e8244a46c85 100644
--- a/apps/mobile/hooks/useWorkspaceHost/useWorkspaceHost.ts
+++ b/apps/mobile/hooks/useWorkspaceHost/useWorkspaceHost.ts
@@ -73,7 +73,6 @@ export function useWorkspaceHost(
);
const {
target: sandbox,
- isReady: sandboxReady,
isError: sandboxUnreachable,
retry: retrySandbox,
} = useSandboxAccess(cloud);
@@ -115,32 +114,34 @@ export function useWorkspaceHost(
// the workspace; the sandbox's own row is scratch that a rename
// never reaches. Live git state still comes from the sandbox.
const workspace = servedRow ? { ...servedRow, name: cloud.name } : null;
+ // Optimistic, like desktop's open workspace: the sandbox is used at
+ // its ticketed address as soon as one exists, rather than waiting on
+ // host-service to confirm the row first — terminals address the same
+ // URL and surface their own connecting/error state downstream.
+ const host: OrgHost | null = sandbox
+ ? {
+ organizationId: cloud.organizationId,
+ machineId: cloud.id,
+ name: "Cloud",
+ version: null,
+ platform: null,
+ installSource: null,
+ // A sandbox is reachable or it isn't; there is no offline
+ // device behind it to report on.
+ isOnline: true,
+ }
+ : null;
return {
workspace,
- host: workspace
- ? {
- organizationId: cloud.organizationId,
- machineId: cloud.id,
- name: "Cloud",
- version: null,
- platform: null,
- installSource: null,
- // A sandbox is reachable or it isn't; there is no offline
- // device behind it to report on.
- isOnline: true,
- }
- : null,
+ host,
cloud,
sandboxUnreachable:
- !workspace && (sandboxUnreachable || served?.isError === true),
+ !host && (sandboxUnreachable || served?.isError === true),
retrySandbox: () => {
retrySandbox();
void served?.refetch();
},
- isResolving:
- !workspace &&
- cloud.status === "ready" &&
- (!sandboxReady || served?.isLoading === true),
+ isResolving: !host && cloud.status === "ready",
};
}
let workspace: HostWorkspaceRow | null = null;
@@ -169,7 +170,6 @@ export function useWorkspaceHost(
}, [
cloud,
sandbox,
- sandboxReady,
sandboxUnreachable,
retrySandbox,
targets,
diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx
index 9027b96b701..e6041184137 100644
--- a/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx
+++ b/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx
@@ -134,7 +134,8 @@ export function WorkspaceScreen() {
retrySandbox,
isResolving,
} = useWorkspaceHost(id ?? null);
- const { terminalsByWorkspace, isReady } = useHostTerminals(host);
+ const { terminalsByWorkspace, isReady: terminalsReady } =
+ useHostTerminals(host);
const pullRequests = useWorkspacePullRequests(id ?? null);
// Tabs hold the arrangement the user dragged in the sessions sheet, falling
@@ -167,17 +168,29 @@ export function WorkspaceScreen() {
// picking the first row now attaches a stream to the wrong session and
// swaps it out from under the user when the remembered tab lands.
if (!tabsHydrated) return null;
- for (const candidate of [
- pickedTerminalId,
- params.tab,
- rememberedTerminalId,
- ]) {
+ const candidates = [pickedTerminalId, params.tab, rememberedTerminalId];
+ // Before the host has answered even once, rows is empty because
+ // nothing has loaded yet — not because the candidate is gone. Address
+ // it anyway, the way desktop mounts a workspace at its host before the
+ // host confirms: the terminal itself surfaces connecting/denied/error
+ // if the guess was wrong, and this recomputes once the real list lands.
+ if (!terminalsReady) {
+ return candidates.find((candidate) => !!candidate) ?? null;
+ }
+ for (const candidate of candidates) {
if (candidate && rows.some((row) => row.terminalId === candidate)) {
return candidate;
}
}
return rows[0]?.terminalId ?? null;
- }, [tabsHydrated, pickedTerminalId, params.tab, rememberedTerminalId, rows]);
+ }, [
+ tabsHydrated,
+ terminalsReady,
+ pickedTerminalId,
+ params.tab,
+ rememberedTerminalId,
+ rows,
+ ]);
// Remembered here rather than in the tab-strip handler: every route into a
// session ends at this value — the strip, the sessions sheet, a new
@@ -488,9 +501,9 @@ export function WorkspaceScreen() {
const killTerminal = useCallback(
(terminalId: string) => {
- if (!workspace || !hostUrl) return;
+ if (!hostUrl || !id) return;
void getHostServiceClientByUrl(hostUrl)
- .terminal.killSession.mutate({ terminalId, workspaceId: workspace.id })
+ .terminal.killSession.mutate({ terminalId, workspaceId: id })
// A kill that fails leaves the tab exactly where it was, which reads
// as the tap having missed. Cheap to ignore while closing was a
// long-press only; the strip now offers it on every selected tab and
@@ -505,7 +518,7 @@ export function WorkspaceScreen() {
)
.finally(invalidateTerminals);
},
- [workspace, hostUrl, invalidateTerminals, t],
+ [id, hostUrl, invalidateTerminals, t],
);
// The composer reports the intent and stops there: it has no idea that
@@ -575,11 +588,11 @@ export function WorkspaceScreen() {
// cannot take text, and the name belongs to the host, not to the strip.
const renameTerminal = useCallback(
(terminalId: string, title: string) => {
- if (!workspace || !hostUrl) return;
+ if (!hostUrl || !id) return;
void getHostServiceClientByUrl(hostUrl)
.terminal.rename.mutate({
terminalId,
- workspaceId: workspace.id,
+ workspaceId: id,
title,
})
.catch((cause: unknown) =>
@@ -592,7 +605,7 @@ export function WorkspaceScreen() {
)
.finally(invalidateTerminals);
},
- [workspace, hostUrl, invalidateTerminals, t],
+ [id, hostUrl, invalidateTerminals, t],
);
const promptRenameTerminal = useCallback(
@@ -987,13 +1000,13 @@ export function WorkspaceScreen() {
}}
/>
>
- ) : cloud && !workspace ? (
+ ) : cloud && !host ? (
- ) : isResolving || ((!isReady || !tabsHydrated) && host) ? (
+ ) : isResolving || ((!terminalsReady || !tabsHydrated) && host) ? (
@@ -1043,10 +1056,10 @@ export function WorkspaceScreen() {
workspaceId={id}
allowAttachments={activeRow?.agentId != null}
slashCommands={slashCommands}
- // A cloud workspace exists on screen before anything serves
- // it; the strip would offer sessions on a sandbox that is not
- // up yet.
- sessionTabs={cloud && !workspace ? [] : sessionTabs}
+ // A cloud workspace exists on screen before its sandbox is
+ // even addressed; the strip would offer sessions on one that
+ // isn't reachable yet.
+ sessionTabs={cloud && !host ? [] : sessionTabs}
onSessionTabPress={pickTerminal}
onSessionTabClose={confirmCloseTerminal}
onSessionTabRename={promptRenameTerminal}
From d53a872635b33103813c4a8ee9e4967b36edbfe7 Mon Sep 17 00:00:00 2001
From: Satya Patel <14907857+saddlepaddle@users.noreply.github.com>
Date: Sat, 26 Sep 2026 22:06:20 +0000
Subject: [PATCH 2/3] feat(mobile): sort workspaces by desktop's last-active
signal
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Mobile's "Last updated" sort read a workspace's own `updatedAt` (which
moves on renames/metadata writes) maxed against its live terminal
timestamps. Desktop's sidebar instead ranks by `lastActivityAt`, which
the host stamps only on real agent activity, falling back to
`updatedAt` for rows from a host that predates the column — so
housekeeping never jumps a workspace to the top.
Extract that ranking (`getWorkspaceActivityTime`/`toTime`) out of
desktop's sidebar sort into `@superset/shared/workspace-activity`, keep
desktop on the shared implementation, and use it for mobile's "Last
updated" ranking in Home and Search, on top of the existing live
terminal-timestamp boost.
---
.../sortDashboardSidebarProjects.test.ts | 36 ++-----------------
.../sortDashboardSidebarProjects.ts | 31 +++-------------
.../(home)/home/HomeScreen.tsx | 11 +++---
.../(home)/search/SearchScreen.tsx | 7 ++--
packages/shared/package.json | 4 +++
.../shared/src/workspace-activity.test.ts | 35 ++++++++++++++++++
packages/shared/src/workspace-activity.ts | 32 +++++++++++++++++
7 files changed, 90 insertions(+), 66 deletions(-)
create mode 100644 packages/shared/src/workspace-activity.test.ts
create mode 100644 packages/shared/src/workspace-activity.ts
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/sortDashboardSidebarProjects/sortDashboardSidebarProjects.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/sortDashboardSidebarProjects/sortDashboardSidebarProjects.test.ts
index 0cb6297f82d..c17acec7ed6 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/sortDashboardSidebarProjects/sortDashboardSidebarProjects.test.ts
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/sortDashboardSidebarProjects/sortDashboardSidebarProjects.test.ts
@@ -6,7 +6,6 @@ import {
makeWorkspace,
} from "../testProjectFixtures";
import {
- getWorkspaceActivityTime,
sortDashboardSidebarProjectChildren,
sortDashboardSidebarProjects,
} from "./sortDashboardSidebarProjects";
@@ -16,39 +15,8 @@ const at = (iso: string) => new Date(iso).getTime();
const childIds = (children: DashboardSidebarProjectChild[]) =>
children.map((c) => (c.type === "workspace" ? c.workspace.id : c.section.id));
-describe("getWorkspaceActivityTime", () => {
- it("ranks by lastActivityAt alone once the host has stamped it", () => {
- // A rename bumped updatedAt well past the last agent event; the agent
- // event still wins because housekeeping is not activity.
- const workspace = makeWorkspace({
- id: "w",
- name: "w",
- updatedAt: new Date("2026-08-01"),
- lastActivityAt: at("2026-03-01"),
- });
- expect(getWorkspaceActivityTime(workspace)).toBe(at("2026-03-01"));
- });
-
- it("falls back to updatedAt for rows from a host that predates the column", () => {
- const workspace = makeWorkspace({
- id: "w",
- name: "w",
- updatedAt: new Date("2026-05-01"),
- lastActivityAt: null,
- });
- expect(getWorkspaceActivityTime(workspace)).toBe(at("2026-05-01"));
- });
-
- it("treats a NaN lastActivityAt like a missing one", () => {
- const workspace = makeWorkspace({
- id: "w",
- name: "w",
- updatedAt: new Date("2026-05-01"),
- lastActivityAt: Number.NaN,
- });
- expect(getWorkspaceActivityTime(workspace)).toBe(at("2026-05-01"));
- });
-});
+// getWorkspaceActivityTime's own unit tests live with its implementation:
+// packages/shared/src/workspace-activity.test.ts.
describe("sortDashboardSidebarProjects", () => {
const older = makeProject({
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/sortDashboardSidebarProjects/sortDashboardSidebarProjects.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/sortDashboardSidebarProjects/sortDashboardSidebarProjects.ts
index fa9c702814e..74dade095da 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/sortDashboardSidebarProjects/sortDashboardSidebarProjects.ts
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/sortDashboardSidebarProjects/sortDashboardSidebarProjects.ts
@@ -1,3 +1,7 @@
+import {
+ getWorkspaceActivityTime,
+ toTime,
+} from "@superset/shared/workspace-activity";
import type { SidebarProjectSortMode } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema";
import type {
DashboardSidebarProject,
@@ -5,17 +9,7 @@ import type {
DashboardSidebarWorkspace,
} from "../../types";
-// Timestamps are typed as Date but can arrive as ISO strings at runtime
-// (IndexedDB snapshots, persisted query caches). Sorting is cosmetic, so
-// coerce instead of trusting the type — a bad value must never throw
-// mid-render and take the sidebar down with it (that is what got the first
-// version of this feature reverted).
-function toTime(value: Date | string | number | null | undefined): number {
- if (value == null) return Number.NaN;
- if (value instanceof Date) return value.getTime();
- if (typeof value === "number") return value;
- return new Date(value).getTime();
-}
+export { getWorkspaceActivityTime };
// An item with no usable timestamp sinks below everything dated. Mapping
// NaN to -Infinity keeps the comparator a consistent total order instead of
@@ -29,21 +23,6 @@ function newest(times: number[]): number {
return known.length > 0 ? Math.max(...known) : Number.NaN;
}
-/**
- * When a workspace was last active. The host stamps `lastActivityAt` on
- * agent lifecycle events and it alone ranks the row once present; only rows
- * from a host that predates the column fall back to `updatedAt`. Deliberately
- * not `max` of the two: `updatedAt` moves on renames and bulk moves, and
- * housekeeping must not jump a workspace to the top of "Last active".
- */
-export function getWorkspaceActivityTime(
- workspace: DashboardSidebarWorkspace,
-): number {
- const activity = workspace.lastActivityAt;
- if (typeof activity === "number" && !Number.isNaN(activity)) return activity;
- return toTime(workspace.updatedAt);
-}
-
function makeStableComparator- (
byTimestamp: (item: Item) => number,
byName: (item: Item) => string,
diff --git a/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx
index a52a7f54a87..ab82051a2d8 100644
--- a/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx
+++ b/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx
@@ -2,6 +2,7 @@ import { LegendList } from "@legendapp/list/react-native";
import { useLingui } from "@lingui/react/macro";
import { i18n } from "@superset/i18n";
import { FEATURE_FLAGS } from "@superset/shared/constants";
+import { getWorkspaceActivityTime } from "@superset/shared/workspace-activity";
import { useQueryClient } from "@tanstack/react-query";
import { isAfter } from "date-fns";
import * as Haptics from "expo-haptics";
@@ -221,12 +222,14 @@ export function HomeScreen() {
(state) => state.toggleProject,
);
- // Recency ranks a workspace by its latest activity — the newest of its own
- // update and its terminals'.
+ // Recency ranks a workspace by its latest activity — desktop's sidebar
+ // notion of "last active" (the host's lastActivityAt, falling back to
+ // updatedAt) maxed against its terminals', since a session can be live
+ // here before the host's next lifecycle event stamps the column.
const activityTs = useCallback(
(workspace: HostWorkspaceItem) => {
- const workspaceTs = new Date(workspace[sort]).getTime();
- if (sort !== "updatedAt") return workspaceTs;
+ if (sort !== "updatedAt") return new Date(workspace[sort]).getTime();
+ const workspaceTs = getWorkspaceActivityTime(workspace);
const terminalTs = (terminalsByWorkspace.get(workspace.id) ?? []).reduce(
(newest, row) => Math.max(newest, row.ts),
0,
diff --git a/apps/mobile/screens/(authenticated)/(home)/search/SearchScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/search/SearchScreen.tsx
index b3b59d86bfd..2e4efd525f5 100644
--- a/apps/mobile/screens/(authenticated)/(home)/search/SearchScreen.tsx
+++ b/apps/mobile/screens/(authenticated)/(home)/search/SearchScreen.tsx
@@ -1,4 +1,5 @@
import { Plural, useLingui } from "@lingui/react/macro";
+import { getWorkspaceActivityTime } from "@superset/shared/workspace-activity";
import {
type NativeStackNavigationProp,
Stack,
@@ -89,10 +90,12 @@ export function SearchScreen() {
[query, projectNamesById, terminalsByWorkspace],
);
+ // Mirrors home's ranking: desktop's "last active" (lastActivityAt, falling
+ // back to updatedAt) maxed against live terminal activity.
const activityTs = useCallback(
(workspace: HostWorkspaceItem) => {
- const workspaceTs = new Date(workspace[sort]).getTime();
- if (sort !== "updatedAt") return workspaceTs;
+ if (sort !== "updatedAt") return new Date(workspace[sort]).getTime();
+ const workspaceTs = getWorkspaceActivityTime(workspace);
const terminalTs = (terminalsByWorkspace.get(workspace.id) ?? []).reduce(
(newest, row) => Math.max(newest, row.ts),
0,
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 69da27f0aab..4a67e978d9a 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -335,6 +335,10 @@
"./plugins/manifest-schema": {
"types": "./src/plugins/manifest-schema.ts",
"default": "./src/plugins/manifest-schema.ts"
+ },
+ "./workspace-activity": {
+ "types": "./src/workspace-activity.ts",
+ "default": "./src/workspace-activity.ts"
}
},
"scripts": {
diff --git a/packages/shared/src/workspace-activity.test.ts b/packages/shared/src/workspace-activity.test.ts
new file mode 100644
index 00000000000..e9484d48978
--- /dev/null
+++ b/packages/shared/src/workspace-activity.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from "bun:test";
+import { getWorkspaceActivityTime } from "./workspace-activity";
+
+const at = (iso: string) => new Date(iso).getTime();
+
+describe("getWorkspaceActivityTime", () => {
+ it("ranks by lastActivityAt alone once the host has stamped it", () => {
+ // A rename bumped updatedAt well past the last agent event; the agent
+ // event still wins because housekeeping is not activity.
+ expect(
+ getWorkspaceActivityTime({
+ updatedAt: new Date("2026-08-01"),
+ lastActivityAt: at("2026-03-01"),
+ }),
+ ).toBe(at("2026-03-01"));
+ });
+
+ it("falls back to updatedAt for rows from a host that predates the column", () => {
+ expect(
+ getWorkspaceActivityTime({
+ updatedAt: new Date("2026-05-01"),
+ lastActivityAt: null,
+ }),
+ ).toBe(at("2026-05-01"));
+ });
+
+ it("treats a NaN lastActivityAt like a missing one", () => {
+ expect(
+ getWorkspaceActivityTime({
+ updatedAt: new Date("2026-05-01"),
+ lastActivityAt: Number.NaN,
+ }),
+ ).toBe(at("2026-05-01"));
+ });
+});
diff --git a/packages/shared/src/workspace-activity.ts b/packages/shared/src/workspace-activity.ts
new file mode 100644
index 00000000000..b0f9666f324
--- /dev/null
+++ b/packages/shared/src/workspace-activity.ts
@@ -0,0 +1,32 @@
+export interface WorkspaceActivityFields {
+ lastActivityAt: Date | string | number | null | undefined;
+ updatedAt: Date | string | number | null | undefined;
+}
+
+// Timestamps are typed as Date but can arrive as ISO strings at runtime
+// (IndexedDB snapshots, persisted query caches). Sorting is cosmetic, so
+// coerce instead of trusting the type — a bad value must never throw
+// mid-render and take a workspace list down with it.
+export function toTime(
+ value: Date | string | number | null | undefined,
+): number {
+ if (value == null) return Number.NaN;
+ if (value instanceof Date) return value.getTime();
+ if (typeof value === "number") return value;
+ return new Date(value).getTime();
+}
+
+/**
+ * When a workspace was last active. The host stamps `lastActivityAt` on
+ * agent lifecycle events and it alone ranks the row once present; only rows
+ * from a host that predates the column fall back to `updatedAt`. Deliberately
+ * not `max` of the two: `updatedAt` moves on renames and bulk moves, and
+ * "last active" must not jump a workspace to the top from housekeeping.
+ */
+export function getWorkspaceActivityTime(
+ workspace: WorkspaceActivityFields,
+): number {
+ const activity = workspace.lastActivityAt;
+ if (typeof activity === "number" && !Number.isNaN(activity)) return activity;
+ return toTime(workspace.updatedAt);
+}
From 7c475030d9b162ba46131038e8f58db33957b31f Mon Sep 17 00:00:00 2001
From: Satya Patel
Date: Sat, 26 Sep 2026 17:20:17 -0700
Subject: [PATCH 3/3] fix(mobile): address cloud sandboxes without waking, like
desktop
Mobile minted sandbox access only with wake: true, so opening a cloud
workspace still waited on the whole wake (1.7-2.2 s warm, a full resume
cold). Address with wake: false and run the wake beside it, handing its
possibly-moved address back. The wake starts once the address has landed
because the API client batches same-tick calls into one response.
Show desktop's waking copy while the sandbox wakes, refetch terminals and
redial when it lands, keep the remembered tab while the terminal list is
failing, and drop a one-use re-export on desktop.
---
.../sortDashboardSidebarProjects/index.ts | 1 -
.../sortDashboardSidebarProjects.test.ts | 3 -
.../sortDashboardSidebarProjects.ts | 2 -
.../useSandboxAccess/useSandboxAccess.ts | 65 +++++++++++++------
.../useWorkspaceHost/useWorkspaceHost.ts | 13 ++--
apps/mobile/lib/sandbox-access/index.ts | 1 +
.../lib/sandbox-access/sandbox-access.ts | 38 ++++++++---
.../(home)/home/HomeScreen.tsx | 6 +-
.../useHostTerminals/useHostTerminals.ts | 3 +
.../(home)/search/SearchScreen.tsx | 2 -
.../[id]/WorkspaceScreen/WorkspaceScreen.tsx | 53 ++++++++++++---
11 files changed, 131 insertions(+), 56 deletions(-)
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/sortDashboardSidebarProjects/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/sortDashboardSidebarProjects/index.ts
index ebad6e3878d..f7c6ead5755 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/sortDashboardSidebarProjects/index.ts
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/sortDashboardSidebarProjects/index.ts
@@ -1,5 +1,4 @@
export {
- getWorkspaceActivityTime,
sortDashboardSidebarProjectChildren,
sortDashboardSidebarProjects,
} from "./sortDashboardSidebarProjects";
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/sortDashboardSidebarProjects/sortDashboardSidebarProjects.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/sortDashboardSidebarProjects/sortDashboardSidebarProjects.test.ts
index c17acec7ed6..bc8579b9037 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/sortDashboardSidebarProjects/sortDashboardSidebarProjects.test.ts
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/sortDashboardSidebarProjects/sortDashboardSidebarProjects.test.ts
@@ -15,9 +15,6 @@ const at = (iso: string) => new Date(iso).getTime();
const childIds = (children: DashboardSidebarProjectChild[]) =>
children.map((c) => (c.type === "workspace" ? c.workspace.id : c.section.id));
-// getWorkspaceActivityTime's own unit tests live with its implementation:
-// packages/shared/src/workspace-activity.test.ts.
-
describe("sortDashboardSidebarProjects", () => {
const older = makeProject({
id: "p-older",
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/sortDashboardSidebarProjects/sortDashboardSidebarProjects.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/sortDashboardSidebarProjects/sortDashboardSidebarProjects.ts
index 74dade095da..bd584946500 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/sortDashboardSidebarProjects/sortDashboardSidebarProjects.ts
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/sortDashboardSidebarProjects/sortDashboardSidebarProjects.ts
@@ -9,8 +9,6 @@ import type {
DashboardSidebarWorkspace,
} from "../../types";
-export { getWorkspaceActivityTime };
-
// An item with no usable timestamp sinks below everything dated. Mapping
// NaN to -Infinity keeps the comparator a consistent total order instead of
// interleaving unknowns by name.
diff --git a/apps/mobile/hooks/useSandboxAccess/useSandboxAccess.ts b/apps/mobile/hooks/useSandboxAccess/useSandboxAccess.ts
index b351af2a2c3..92596cee833 100644
--- a/apps/mobile/hooks/useSandboxAccess/useSandboxAccess.ts
+++ b/apps/mobile/hooks/useSandboxAccess/useSandboxAccess.ts
@@ -2,17 +2,18 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo } from "react";
import { AppState } from "react-native";
import type { CloudWorkspaceRow } from "@/hooks/useCloudWorkspaces";
-import { wakeSandboxAccess } from "@/lib/sandbox-access";
+import { addressSandboxAccess, wakeSandboxAccess } from "@/lib/sandbox-access";
/**
- * Each mint wakes, which is also what extends the sandbox's session: it stops
- * after hours of not being asked, and the next wake brings it back. Same
- * cadence as the desktop's open workspace.
+ * A wake is also what extends the sandbox's session: it stops after hours of
+ * not being asked, and the next wake brings it back. Same cadence as the
+ * desktop's open workspace.
*/
const KEEPALIVE_MS = 10 * 60 * 1000;
const RETRY_MS = 30_000;
-const SANDBOX_ACCESS_QUERY_KEY = ["cloud", "sandbox-access"] as const;
+const SANDBOX_ADDRESS_QUERY_KEY = ["cloud", "sandbox-address"] as const;
+const SANDBOX_WAKE_QUERY_KEY = ["cloud", "sandbox-wake"] as const;
export interface SandboxTarget {
/** The cloud workspace's id, which is also its host id. */
@@ -23,20 +24,22 @@ export interface SandboxTarget {
export interface SandboxAccessValue {
target: SandboxTarget | null;
- /** False until the workspace has been addressed once. */
- isReady: boolean;
/** True while the last attempt to address it failed; attempts continue. */
isError: boolean;
+ /** True until the sandbox has been woken since this workspace opened. */
+ isWaking: boolean;
retry: () => void;
}
/**
- * Keeps the open cloud workspace addressed and awake.
+ * Keeps the open cloud workspace addressed and awake, the way desktop's
+ * SandboxAccessProvider does: the workspace is used at its last known address
+ * straight away, as if its box were up, and waking runs beside that rather
+ * than in front of it. The wake hands back a fresh address, since a resumed
+ * box can answer on a new domain.
*
- * A sandbox has no host row and no stable URL — it is reachable only through
- * a ticket this brokers. Only the workspace on screen is addressed: every
- * mint wakes its sandbox, and waking the rest of a list would resume and bill
- * sandboxes nobody is looking at.
+ * Only the workspace on screen is woken: waking the rest of a list would
+ * resume and bill sandboxes nobody is looking at.
*
* iOS freezes JS timers in the background, so the interval alone would come
* back to a stopped sandbox after a long sleep; returning to the foreground
@@ -51,15 +54,34 @@ export function useSandboxAccess(
// else, and a provisioning workspace asking for a ticket every few seconds
// would be a retry loop against a guaranteed rejection.
const ready = cloud?.status === "ready" ? cloud : null;
+ const addressKey = [...SANDBOX_ADDRESS_QUERY_KEY, ready?.id ?? null] as const;
- const query = useQuery({
- queryKey: [...SANDBOX_ACCESS_QUERY_KEY, ready?.id ?? null] as const,
+ const address = useQuery({
+ queryKey: addressKey,
enabled: ready !== null,
+ // The wake below re-mints every few minutes and writes its grant here.
+ staleTime: Number.POSITIVE_INFINITY,
+ networkMode: "always" as const,
+ queryFn: async () => {
+ const access = await addressSandboxAccess(
+ (ready as CloudWorkspaceRow).id,
+ );
+ return { url: access.url };
+ },
+ refetchInterval: (current) => (current.state.data ? false : RETRY_MS),
+ });
+
+ const wake = useQuery({
+ queryKey: [...SANDBOX_WAKE_QUERY_KEY, ready?.id ?? null] as const,
+ // The API client batches calls made in the same tick, which would hold
+ // the address back until the wake returns.
+ enabled: ready !== null && address.data !== undefined,
// Every sheet on the workspace mounts this; a mount is not a reason to wake.
staleTime: KEEPALIVE_MS,
networkMode: "always" as const,
queryFn: async () => {
const access = await wakeSandboxAccess((ready as CloudWorkspaceRow).id);
+ queryClient.setQueryData(addressKey, { url: access.url });
return { url: access.url };
},
// No refetchIntervalInBackground: that flag is about browser window
@@ -73,13 +95,13 @@ export function useSandboxAccess(
const subscription = AppState.addEventListener("change", (state) => {
if (state !== "active") return;
void queryClient.invalidateQueries({
- queryKey: SANDBOX_ACCESS_QUERY_KEY,
+ queryKey: SANDBOX_WAKE_QUERY_KEY,
});
});
return () => subscription.remove();
}, [queryClient]);
- const url = query.data?.url ?? null;
+ const url = address.data?.url ?? null;
return useMemo(
() => ({
target:
@@ -90,10 +112,13 @@ export function useSandboxAccess(
url,
}
: null,
- isReady: ready === null || query.isFetched,
- isError: query.isError,
- retry: () => void query.refetch(),
+ isError: address.isError,
+ isWaking: ready !== null && wake.data === undefined,
+ retry: () => {
+ void address.refetch();
+ void wake.refetch();
+ },
}),
- [ready, url, query.isFetched, query.isError, query.refetch],
+ [ready, url, address.isError, address.refetch, wake.data, wake.refetch],
);
}
diff --git a/apps/mobile/hooks/useWorkspaceHost/useWorkspaceHost.ts b/apps/mobile/hooks/useWorkspaceHost/useWorkspaceHost.ts
index e8244a46c85..81da4906d32 100644
--- a/apps/mobile/hooks/useWorkspaceHost/useWorkspaceHost.ts
+++ b/apps/mobile/hooks/useWorkspaceHost/useWorkspaceHost.ts
@@ -47,6 +47,8 @@ export interface WorkspaceHostResult {
cloud: CloudWorkspaceRow | null;
/** Cloud only: the API could not address or wake the sandbox. Attempts continue. */
sandboxUnreachable: boolean;
+ /** Cloud only: addressed, but not yet woken since it was opened. */
+ sandboxWaking: boolean;
retrySandbox: () => void;
/** True while no host has answered yet. */
isResolving: boolean;
@@ -74,6 +76,7 @@ export function useWorkspaceHost(
const {
target: sandbox,
isError: sandboxUnreachable,
+ isWaking: sandboxWaking,
retry: retrySandbox,
} = useSandboxAccess(cloud);
@@ -114,10 +117,6 @@ export function useWorkspaceHost(
// the workspace; the sandbox's own row is scratch that a rename
// never reaches. Live git state still comes from the sandbox.
const workspace = servedRow ? { ...servedRow, name: cloud.name } : null;
- // Optimistic, like desktop's open workspace: the sandbox is used at
- // its ticketed address as soon as one exists, rather than waiting on
- // host-service to confirm the row first — terminals address the same
- // URL and surface their own connecting/error state downstream.
const host: OrgHost | null = sandbox
? {
organizationId: cloud.organizationId,
@@ -135,8 +134,8 @@ export function useWorkspaceHost(
workspace,
host,
cloud,
- sandboxUnreachable:
- !host && (sandboxUnreachable || served?.isError === true),
+ sandboxUnreachable: !host && sandboxUnreachable,
+ sandboxWaking,
retrySandbox: () => {
retrySandbox();
void served?.refetch();
@@ -164,6 +163,7 @@ export function useWorkspaceHost(
host,
cloud: null,
sandboxUnreachable: false,
+ sandboxWaking: false,
retrySandbox,
isResolving,
};
@@ -171,6 +171,7 @@ export function useWorkspaceHost(
cloud,
sandbox,
sandboxUnreachable,
+ sandboxWaking,
retrySandbox,
targets,
queries,
diff --git a/apps/mobile/lib/sandbox-access/index.ts b/apps/mobile/lib/sandbox-access/index.ts
index 35b62952e3d..f96710a13a2 100644
--- a/apps/mobile/lib/sandbox-access/index.ts
+++ b/apps/mobile/lib/sandbox-access/index.ts
@@ -1,4 +1,5 @@
export {
+ addressSandboxAccess,
clearSandboxAccess,
ensureSandboxAccess,
getSandboxAccess,
diff --git a/apps/mobile/lib/sandbox-access/sandbox-access.ts b/apps/mobile/lib/sandbox-access/sandbox-access.ts
index bab28b8e788..699625b6356 100644
--- a/apps/mobile/lib/sandbox-access/sandbox-access.ts
+++ b/apps/mobile/lib/sandbox-access/sandbox-access.ts
@@ -29,6 +29,7 @@ const inflight = new Map>();
* quietly resurrect credentials for a workspace this client gave up.
*/
const epochByWorkspaceId = new Map();
+const wakesByWorkspaceId = new Map();
export function getSandboxAccess(workspaceId: string): SandboxAccess | null {
return accessByWorkspaceId.get(workspaceId) ?? null;
@@ -59,20 +60,35 @@ export function ensureSandboxAccess(
return wakeSandboxAccess(workspaceId);
}
+/**
+ * A grant for the sandbox at its last known address, without waking it. Fast
+ * enough to open the workspace on: a running sandbox answers there right
+ * away, and a stopped one fails its requests until `wakeSandboxAccess` lands.
+ */
+export function addressSandboxAccess(
+ workspaceId: string,
+): Promise {
+ return mint(workspaceId, false);
+}
+
/**
* A new grant whether or not the current one is fresh. The ticket outlives
* the sandbox's session by hours, so this — not expiry — is what keeps the
* open workspace's session going and resumes it once it has stopped.
*/
export function wakeSandboxAccess(workspaceId: string): Promise {
- const pending = inflight.get(workspaceId);
+ return mint(workspaceId, true);
+}
+
+function mint(workspaceId: string, wake: boolean): Promise {
+ const key = `${workspaceId}:${wake}`;
+ const pending = inflight.get(key);
if (pending) return pending;
const epoch = epochByWorkspaceId.get(workspaceId) ?? 0;
- // A phone only ever asks for a workspace it is looking at, so every mint
- // wakes: a stopped sandbox resumes and a running one stays up.
- const mint = apiClient.cloudWorkspace.access
- .mutate({ id: workspaceId, wake: true })
+ const wakes = wakesByWorkspaceId.get(workspaceId) ?? 0;
+ const request = apiClient.cloudWorkspace.access
+ .mutate({ id: workspaceId, wake })
.then((granted) => {
const access: SandboxAccess = {
url: granted.url,
@@ -84,6 +100,12 @@ export function wakeSandboxAccess(workspaceId: string): Promise {
// legitimate) but don't re-register credentials nothing owns.
return access;
}
+ // A resumed sandbox can answer on a new domain; an address read
+ // before that wake landed points at the old one.
+ if (!wake && (wakesByWorkspaceId.get(workspaceId) ?? 0) !== wakes) {
+ return accessByWorkspaceId.get(workspaceId) ?? access;
+ }
+ if (wake) wakesByWorkspaceId.set(workspaceId, wakes + 1);
// A recreated sandbox can move URLs; the old address must stop
// resolving a token or a cached client keeps authenticating with it.
const previous = accessByWorkspaceId.get(workspaceId);
@@ -95,10 +117,10 @@ export function wakeSandboxAccess(workspaceId: string): Promise {
return access;
})
.finally(() => {
- inflight.delete(workspaceId);
+ inflight.delete(key);
});
- inflight.set(workspaceId, mint);
- return mint;
+ inflight.set(key, request);
+ return request;
}
/** Forget a workspace that is gone; its URL stops carrying a token. */
diff --git a/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx
index ab82051a2d8..69afdf95f11 100644
--- a/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx
+++ b/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx
@@ -222,10 +222,8 @@ export function HomeScreen() {
(state) => state.toggleProject,
);
- // Recency ranks a workspace by its latest activity — desktop's sidebar
- // notion of "last active" (the host's lastActivityAt, falling back to
- // updatedAt) maxed against its terminals', since a session can be live
- // here before the host's next lifecycle event stamps the column.
+ // Recency ranks a workspace by its latest activity — the newest of its
+ // own and its terminals'.
const activityTs = useCallback(
(workspace: HostWorkspaceItem) => {
if (sort !== "updatedAt") return new Date(workspace[sort]).getTime();
diff --git a/apps/mobile/screens/(authenticated)/(home)/home/hooks/useHostTerminals/useHostTerminals.ts b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useHostTerminals/useHostTerminals.ts
index b0f2dddfc05..671cb83bd20 100644
--- a/apps/mobile/screens/(authenticated)/(home)/home/hooks/useHostTerminals/useHostTerminals.ts
+++ b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useHostTerminals/useHostTerminals.ts
@@ -88,6 +88,8 @@ export interface UseHostTerminalsResult {
* states only — existing rows always render (cache-first rule).
*/
isReady: boolean;
+ /** True while the last answer from some host was a failure. */
+ isError: boolean;
}
/**
@@ -191,6 +193,7 @@ export function useHostsTerminals(
terminalsByWorkspace,
attentionByWorkspace,
isReady: queries.every((query) => query.isSuccess || query.isError),
+ isError: queries.some((query) => query.isError),
};
}, [queries, terminalSeenAt]);
}
diff --git a/apps/mobile/screens/(authenticated)/(home)/search/SearchScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/search/SearchScreen.tsx
index 2e4efd525f5..586fce28fb8 100644
--- a/apps/mobile/screens/(authenticated)/(home)/search/SearchScreen.tsx
+++ b/apps/mobile/screens/(authenticated)/(home)/search/SearchScreen.tsx
@@ -90,8 +90,6 @@ export function SearchScreen() {
[query, projectNamesById, terminalsByWorkspace],
);
- // Mirrors home's ranking: desktop's "last active" (lastActivityAt, falling
- // back to updatedAt) maxed against live terminal activity.
const activityTs = useCallback(
(workspace: HostWorkspaceItem) => {
if (sort !== "updatedAt") return new Date(workspace[sort]).getTime();
diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx
index e6041184137..8ec97c46163 100644
--- a/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx
+++ b/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx
@@ -131,11 +131,15 @@ export function WorkspaceScreen() {
host,
cloud,
sandboxUnreachable,
+ sandboxWaking,
retrySandbox,
isResolving,
} = useWorkspaceHost(id ?? null);
- const { terminalsByWorkspace, isReady: terminalsReady } =
- useHostTerminals(host);
+ const {
+ terminalsByWorkspace,
+ isReady: terminalsReady,
+ isError: terminalsFailed,
+ } = useHostTerminals(host);
const pullRequests = useWorkspacePullRequests(id ?? null);
// Tabs hold the arrangement the user dragged in the sessions sheet, falling
@@ -169,12 +173,8 @@ export function WorkspaceScreen() {
// swaps it out from under the user when the remembered tab lands.
if (!tabsHydrated) return null;
const candidates = [pickedTerminalId, params.tab, rememberedTerminalId];
- // Before the host has answered even once, rows is empty because
- // nothing has loaded yet — not because the candidate is gone. Address
- // it anyway, the way desktop mounts a workspace at its host before the
- // host confirms: the terminal itself surfaces connecting/denied/error
- // if the guess was wrong, and this recomputes once the real list lands.
- if (!terminalsReady) {
+ // An unanswered or failed list says nothing about whether it is gone.
+ if (!terminalsReady || terminalsFailed) {
return candidates.find((candidate) => !!candidate) ?? null;
}
for (const candidate of candidates) {
@@ -186,6 +186,7 @@ export function WorkspaceScreen() {
}, [
tabsHydrated,
terminalsReady,
+ terminalsFailed,
pickedTerminalId,
params.tab,
rememberedTerminalId,
@@ -720,8 +721,31 @@ export function WorkspaceScreen() {
});
}, [connectionState, id, activeTerminalId]);
+ const wakingMessage = t({
+ message:
+ "This cloud workspace is waking up, which can take up to 30 seconds after it has been idle.",
+ });
const bannerDescriptor = STATE_BANNERS[connectionState];
- const banner = bannerDescriptor ? i18n._(bannerDescriptor) : undefined;
+ const banner =
+ sandboxWaking &&
+ (connectionState === "connecting" || connectionState === "reconnecting")
+ ? wakingMessage
+ : bannerDescriptor
+ ? i18n._(bannerDescriptor)
+ : undefined;
+
+ // A stopped sandbox refused everything asked of it before the wake landed.
+ const wasWaking = useRef(sandboxWaking);
+ useEffect(() => {
+ if (wasWaking.current && !sandboxWaking) {
+ invalidateTerminals();
+ void queryClient.invalidateQueries({
+ queryKey: ["host-service", "workspaces", "list"],
+ });
+ terminalRef.current?.retry();
+ }
+ wasWaking.current = sandboxWaking;
+ }, [sandboxWaking, invalidateTerminals, queryClient]);
const showComposer =
activeTerminalId !== null &&
host !== null &&
@@ -1006,9 +1030,18 @@ export function WorkspaceScreen() {
unreachable={sandboxUnreachable}
onRetry={retrySandbox}
/>
- ) : isResolving || ((!terminalsReady || !tabsHydrated) && host) ? (
+ ) : isResolving ||
+ ((!terminalsReady ||
+ !tabsHydrated ||
+ (cloud && terminalsFailed && rows.length === 0)) &&
+ host) ? (
+ {sandboxWaking ? (
+
+ {wakingMessage}
+
+ ) : null}
) : !host ? (