Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 63 additions & 15 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
sidebarFolderExpansionKeys,
} from "./Sidebar.logic";
import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment";
import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
import {
sortActiveThreadsByOrderKey,
sortPinnedThreadsByOrderKey,
Expand Down Expand Up @@ -969,21 +970,68 @@ describe("Working shelf (beta)", () => {
).toBe(false);
});

it.each([
["connected", true, false],
["connected", false, true],
["connecting", false, false],
["reconnecting", false, true],
["offline", false, true],
["error", false, true],
["unsupported", false, true],
["available", false, true],
] as const)(
"lists threads outside the shelf on a %s environment: %s",
(phase, shellIsLive, disconnected) => {
expect(isSidebarEnvironmentDisconnected(phase, shellIsLive)).toBe(disconnected);
},
);
describe("isSidebarEnvironmentDisconnected", () => {
const follow = (
states: ReadonlyArray<readonly [EnvironmentConnectionPhase, boolean]>,
): boolean[] => {
let disconnected = false;
return states.map(([phase, shellIsLive]) => {
disconnected = isSidebarEnvironmentDisconnected(phase, shellIsLive, disconnected);
return disconnected;
});
};

it.each(["reconnecting", "offline", "error", "unsupported", "available"] as const)(
"lists threads outside the shelf on a %s environment",
(phase) => {
expect(
follow([
["connected", true],
[phase, false],
]),
).toEqual([false, true]);
},
);

it("keeps the shelf through the resync a window refocus starts on a live socket", () => {
expect(
follow([
["connected", true],
["connected", false],
["connected", true],
]),
).toEqual([false, false, false]);
});

it("keeps threads out of the shelf after a reconnect until the shell is live", () => {
expect(
follow([
["connected", true],
["reconnecting", false],
["connected", false],
["connected", true],
]),
).toEqual([false, true, true, false]);
expect(
follow([
["available", false],
["connecting", false],
["connected", false],
["connected", true],
]),
).toEqual([true, true, true, false]);
});

it("keeps cached placement through a cold start", () => {
expect(
follow([
["connecting", false],
["connected", false],
["connected", true],
]),
).toEqual([false, false, false]);
});
});

it("lists a running thread outside the shelf while its environment is disconnected", () => {
const thread = { ...idle, runtime, environmentId: localEnvironmentId };
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,12 +185,16 @@ export function resolveSidebarThreadSection(input: {
return "active";
}

// Keep cached placement on the first connection attempt, matching mobile's threadRouteIsHydrating.
// Between a dropped connection and a live shell, placement holds. A first connection attempt keeps
// the cached shelf, a reconnect keeps threads out until its resync lands, and the resync that
// every window refocus starts on a healthy socket moves nothing.
export function isSidebarEnvironmentDisconnected(
phase: EnvironmentConnectionPhase,
shellIsLive: boolean,
wasDisconnected: boolean,
): boolean {
return phase !== "connecting" && (phase !== "connected" || !shellIsLive);
if (phase === "connected" && shellIsLive) return false;
return phase === "connecting" || phase === "connected" ? wasDisconnected : true;
}

export function belongsInSidebarWorkingShelf<T extends { readonly environmentId: EnvironmentId }>(
Expand Down
40 changes: 26 additions & 14 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,6 @@ import { useNowMinute } from "../hooks/useNowMinute";
import {
useEnvironmentIdentities,
useConnectedEnvironmentIds,
useEnvironmentConnectionSummaries,
useEnvironmentMachines,
usePrimaryEnvironmentId,
} from "../state/environments";
Expand All @@ -151,7 +150,7 @@ import {
useServerConfigs,
useQuietThreadShells,
} from "../state/entities";
import { environmentPresentations } from "../state/presentation";
import { environmentPresentations, environmentSummaries } from "../state/presentation";
import { primaryServerKeybindingsAtom } from "../state/server";
import { environmentProjects } from "../state/projects";
import { threadEnvironment } from "../state/threads";
Expand Down Expand Up @@ -366,6 +365,30 @@ function scrollSidebarToTop(content: HTMLElement | null) {
content?.closest("[data-slot=scroll-area-viewport]")?.scrollTo({ top: 0 });
}

let previousDisconnectedEnvironmentIds: ReadonlySet<EnvironmentId> = new Set();
const disconnectedEnvironmentIdsAtom = Atom.make((get) => {
const liveShellEnvironmentIds = get(liveShellEnvironmentIdsAtom);
const next = new Set(
get(environmentSummaries.environmentsAtom)
.filter((environment) =>
isSidebarEnvironmentDisconnected(
environment.connectionState,
liveShellEnvironmentIds.has(environment.environmentId),
previousDisconnectedEnvironmentIds.has(environment.environmentId),
),
)
.map((environment) => environment.environmentId),
);
if (
next.size === previousDisconnectedEnvironmentIds.size &&
[...next].every((environmentId) => previousDisconnectedEnvironmentIds.has(environmentId))
) {
return previousDisconnectedEnvironmentIds;
}
previousDisconnectedEnvironmentIds = next;
return next;
}).pipe(Atom.withLabel("web-sidebar-disconnected-environment-ids"));

function threadShellKey(thread: Pick<SidebarThreadSummary, "environmentId" | "id">): string {
return scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
}
Expand Down Expand Up @@ -441,18 +464,7 @@ function useSidebarThreads(input: {
// Folders already separate the work, so boring mode has no Working shelf.
const workingShelfEnabled =
useClientSettings((s) => s.sidebarWorkingShelfEnabled) && folderGroups === null;
const environmentConnections = useEnvironmentConnectionSummaries();
const liveShellEnvironmentIds = useAtomValue(liveShellEnvironmentIdsAtom);
const disconnectedEnvironmentIds = new Set(
environmentConnections
.filter((environment) =>
isSidebarEnvironmentDisconnected(
environment.connectionState,
liveShellEnvironmentIds.has(environment.environmentId),
),
)
.map((environment) => environment.environmentId),
);
const disconnectedEnvironmentIds = useAtomValue(disconnectedEnvironmentIdsAtom);
const inboxReturnsVersion = useSyncExternalStore(
inboxReturns.subscribe,
inboxReturns.getSnapshot,
Expand Down
4 changes: 0 additions & 4 deletions apps/web/src/state/environments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,3 @@ export function useEnvironmentMachines() {
export function useConnectedEnvironmentIds() {
return useAtomValue(environmentSummaries.connectedEnvironmentIdsAtom);
}

export function useEnvironmentConnectionSummaries() {
return useAtomValue(environmentSummaries.environmentsAtom);
}
Loading