Skip to content
Open
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
6 changes: 4 additions & 2 deletions apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useAtomValue } from "@effect/atom-react";
import { useAtomMount, useAtomValue } from "@effect/atom-react";
import * as Schema from "effect/Schema";
import {
useEffect,
Expand Down Expand Up @@ -39,7 +39,7 @@ import {
} from "../panelAnimations";
import { BackgroundThreadAnnouncer } from "./BackgroundThreadAnnouncer";
import { HistorySwipeNavigation } from "./HistorySwipeNavigation";
import ThreadSidebar from "./Sidebar";
import ThreadSidebar, { disconnectedEnvironmentIdsAtom } from "./Sidebar";
import { SidebarBrandWidthProbe } from "./sidebar/SidebarChrome";
import { isAppModalPage } from "./sidebar/mainAppLocation";
import type { SidebarRouteReveal } from "./sidebar/routeReveal";
Expand Down Expand Up @@ -402,6 +402,8 @@ function SidebarControl() {
}

export function AppSidebarLayout({ children }: { children: ReactNode }) {
// The narrow sidebar unmounts while closed; connection history must keep updating.
useAtomMount(disconnectedEnvironmentIdsAtom);
const sidebarArea = workspaceAreaProps("sidebar", 0, "Threads");
const [sidebarRouteReveal] = useState<SidebarRouteReveal>(() => ({
observedKey: null,
Expand Down
136 changes: 122 additions & 14 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
import { presentThreadShell } from "@t3tools/client-runtime/state/models";
import { isThreadWorking as isSidebarThreadWorking } from "@t3tools/client-runtime/state/thread-inbox";
import * as DateTime from "effect/DateTime";
import { deriveActiveWorkStartedAt } from "../session-logic.ts";
import { describe, expect, it } from "vite-plus/test";
import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/reactivity";
import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
import {
shouldCreateNewThreadInCurrentProject,
deleteSelectedThreadEntries,
Expand All @@ -13,6 +14,7 @@ import {
resolveSidebarRowThreadId,
belongsInSidebarWorkingShelf,
isSidebarEnvironmentDisconnected,
createSidebarDisconnectedEnvironmentIdsAtom,
orderItemsByPreferredIds,
pinOrderKeyBetween,
resolveSidebarSweepKeys,
Expand Down Expand Up @@ -1005,20 +1007,126 @@ describe("Working shelf (beta)", () => {
});

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);
["connected", false],
["connecting", false],
["reconnecting", true],
["offline", true],
["error", true],
["unsupported", true],
["available", true],
] as const)("lists threads outside the shelf on a %s environment: %s", (phase, disconnected) => {
expect(isSidebarEnvironmentDisconnected(phase, true, false)).toBe(disconnected);
});

it.each([
{
name: "foreground refresh",
states: [
["connected", true],
["connected", false],
["connected", true],
],
expected: [false, false, false],
},
);
{
name: "reconnect before synchronization",
states: [
["connected", true],
["reconnecting", false],
["connected", false],
["connected", true],
],
expected: [false, true, true, false],
},
{
name: "connecting again after going offline",
states: [
["connected", true],
["offline", false],
["connecting", false],
["connected", false],
["connected", true],
],
expected: [false, true, true, true, false],
},
{
name: "explicit connection from available",
states: [
["available", false],
["connecting", false],
["connected", false],
["connected", true],
],
expected: [true, true, true, false],
},
{
name: "cold start",
states: [
["connecting", false],
["connected", false],
["connected", true],
],
expected: [false, false, false],
},
] satisfies ReadonlyArray<{
name: string;
states: ReadonlyArray<readonly [EnvironmentConnectionPhase, boolean]>;
expected: ReadonlyArray<boolean>;
}>)("preserves shelf placement through $name", ({ states, expected }) => {
let disconnected = false;
expect(
states.map(([phase, shellIsLive]) => {
disconnected = isSidebarEnvironmentDisconnected(phase, shellIsLive, disconnected);
return disconnected;
}),
).toEqual(expected);
});

it("tracks reconnects before the sidebar first reads and while its reader is unmounted", () => {
const registry = AtomRegistry.make();
try {
const environments = Atom.make<
ReadonlyArray<{
environmentId: EnvironmentId;
connectionState: EnvironmentConnectionPhase;
}>
>([{ environmentId: localEnvironmentId, connectionState: "connected" }]);
const live = Atom.make<ReadonlySet<EnvironmentId>>(new Set([localEnvironmentId]));
const disconnected = createSidebarDisconnectedEnvironmentIdsAtom(environments, live);
registry.mount(disconnected);

registry.set(environments, [
{ environmentId: localEnvironmentId, connectionState: "reconnecting" },
]);
registry.set(live, new Set());
registry.set(environments, [
{ environmentId: localEnvironmentId, connectionState: "connected" },
]);
const unmountSidebar = registry.mount(disconnected);
expect([...registry.get(disconnected)]).toEqual([localEnvironmentId]);
unmountSidebar();

registry.set(live, new Set([localEnvironmentId]));
const synchronized = registry.get(disconnected);
expect([...synchronized]).toEqual([]);
registry.set(live, new Set());
expect(registry.get(disconnected)).toBe(synchronized);
registry.set(live, new Set([localEnvironmentId]));
expect(registry.get(disconnected)).toBe(synchronized);
registry.set(environments, [
{ environmentId: localEnvironmentId, connectionState: "offline" },
]);
registry.set(live, new Set());
registry.set(environments, [
{ environmentId: localEnvironmentId, connectionState: "connected" },
]);
expect([...registry.get(disconnected)]).toEqual([localEnvironmentId]);
registry.set(live, new Set([localEnvironmentId]));
expect([...registry.get(disconnected)]).toEqual([]);
} finally {
registry.dispose();
}
});

it("lists a running thread outside the shelf while its environment is disconnected", () => {
const thread = { ...idle, runtime, environmentId: localEnvironmentId };
Expand Down
40 changes: 37 additions & 3 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ import {
type ThreadId,
} from "@t3tools/contracts";
import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
import type { AsyncResult } from "effect/reactivity";
import { Atom, type AsyncResult } from "effect/reactivity";
import * as Option from "effect/Option";
import {
effectiveSnoozed,
type ThreadSnoozeShell,
Expand Down Expand Up @@ -185,12 +186,45 @@ export function resolveSidebarThreadSection(input: {
return "active";
}

// Keep cached placement on the first connection attempt, matching mobile's threadRouteIsHydrating.
// A healthy refresh keeps its shelf; a reconnect waits for authoritative shell data.
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 createSidebarDisconnectedEnvironmentIdsAtom(
environmentsAtom: Atom.Atom<
ReadonlyArray<{
readonly environmentId: EnvironmentId;
readonly connectionState: EnvironmentConnectionPhase;
}>
>,
liveEnvironmentIdsAtom: Atom.Atom<ReadonlySet<EnvironmentId>>,
) {
return Atom.make((get) => {
const previous = Option.getOrNull(get.self<ReadonlySet<EnvironmentId>>());
const liveShellEnvironmentIds = get(liveEnvironmentIdsAtom);
const next = new Set(
get(environmentsAtom)
.filter((environment) =>
isSidebarEnvironmentDisconnected(
environment.connectionState,
liveShellEnvironmentIds.has(environment.environmentId),
previous?.has(environment.environmentId) ?? false,
),
)
.map((environment) => environment.environmentId),
);
return previous !== null &&
next.size === previous.size &&
[...next].every((environmentId) => previous.has(environmentId))
? previous
: next;
}).pipe(Atom.keepAlive, Atom.withLabel("web-sidebar-disconnected-environment-ids"));
}

export function belongsInSidebarWorkingShelf<T extends { readonly environmentId: EnvironmentId }>(
Expand Down
23 changes: 8 additions & 15 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,6 @@ import { useNowMinute } from "../hooks/useNowMinute";
import {
useEnvironmentIdentities,
useConnectedEnvironmentIds,
useEnvironmentConnectionSummaries,
useEnvironmentMachines,
usePrimaryEnvironmentId,
} from "../state/environments";
Expand All @@ -152,7 +151,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 @@ -188,7 +187,7 @@ import {
filterSidebarProjectScopeItems,
firstValidTimestampMs,
belongsInSidebarWorkingShelf,
isSidebarEnvironmentDisconnected,
createSidebarDisconnectedEnvironmentIdsAtom,
isSidebarNestedLinkClick,
isTrailingDoubleClick,
orderItemsByPreferredIds,
Expand Down Expand Up @@ -367,6 +366,11 @@ function scrollSidebarToTop(content: HTMLElement | null) {
content?.closest("[data-slot=scroll-area-viewport]")?.scrollTo({ top: 0 });
}

export const disconnectedEnvironmentIdsAtom = createSidebarDisconnectedEnvironmentIdsAtom(
environmentSummaries.environmentsAtom,
liveShellEnvironmentIdsAtom,
);

function threadShellKey(thread: Pick<SidebarThreadSummary, "environmentId" | "id">): string {
return scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
}
Expand Down Expand Up @@ -442,18 +446,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