Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6414edc
feat(web): filter sidebar threads by environment
saphid Aug 28, 2026
72c948d
fix(web): contain environment filter labels
saphid Aug 28, 2026
51c618f
fix(web): avoid transient empty environment filters
saphid Aug 29, 2026
163236d
chore: merge latest main into environment filter
saphid Aug 29, 2026
d80e30b
fix(web): clarify environment filter hierarchy
saphid Aug 30, 2026
944468e
fix(web): clarify locked environment state
saphid Aug 30, 2026
7c9c9b2
fix(web): preserve grouped project labels
saphid Aug 30, 2026
248f615
fix(web): explain locked environment filter
saphid Aug 30, 2026
463a0e3
chore: merge latest main into environment filter
saphid Sep 2, 2026
2cf5122
fix(web): keep project filter label consistent
saphid Sep 2, 2026
9272356
fix(web): refresh environment filter against current sidebar
github-actions[bot] Sep 4, 2026
ba96a6c
docs(web): explain sidebar project and environment filters
github-actions[bot] Sep 4, 2026
e2ecc53
fix(web): preserve project scope persistence with environment filters
github-actions[bot] Sep 5, 2026
c92ee80
fix(web): reconcile sidebar tests with upstream main
github-actions[bot] Sep 5, 2026
2955765
Merge remote-tracking branch 'refs/remotes/origin/main' into landing/…
saphid Sep 6, 2026
1d29737
Merge remote-tracking branch 'refs/remotes/origin/main' into landing/…
saphid Sep 6, 2026
90e13da
fix(web): keep environment filtering with current sidebar drag state
saphid Sep 7, 2026
cc6a707
Merge remote-tracking branch 'origin/main' into repair/pr-8530-refresh
saphid Sep 7, 2026
5a95053
Merge remote-tracking branch 'origin/main' into repair/pr-8530-refresh
saphid Sep 7, 2026
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
115 changes: 115 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
deleteSelectedThreadEntries,
filterSidebarProjectScopeItems,
getSidebarThreadIdsToPrewarm,
pruneDisabledEnvironmentIds,
resolveAdjacentThreadId,
reduceSidebarProjectScopeMenuState,
getFallbackThreadIdAfterDelete,
Expand Down Expand Up @@ -45,6 +46,7 @@ import {
sortScopedProjectsForSidebar,
shouldCreateNewThreadInCurrentProject,
THREAD_JUMP_HINT_SHOW_DELAY_MS,
toggleDisabledEnvironmentId,
type SidebarListItem,
type SidebarListMarker,
type SidebarSection,
Expand Down Expand Up @@ -2471,6 +2473,119 @@ describe("sortLogicalProjectsForSidebar", () => {
});
});

describe("toggleDisabledEnvironmentId", () => {
const envA = EnvironmentId.make("env-a");
const envB = EnvironmentId.make("env-b");
const all = [envA, envB];

it("disables an environment", () => {
const next = toggleDisabledEnvironmentId({
disabledIds: new Set(),
environmentId: envA,
enabled: false,
allEnvironmentIds: all,
});
expect([...next]).toEqual([envA]);
});

it("re-enables a disabled environment", () => {
const next = toggleDisabledEnvironmentId({
disabledIds: new Set([envA]),
environmentId: envA,
enabled: true,
allEnvironmentIds: all,
});
expect(next.size).toBe(0);
});

it("refuses to disable the last enabled environment", () => {
const current: ReadonlySet<EnvironmentId> = new Set([envA]);
const next = toggleDisabledEnvironmentId({
disabledIds: current,
environmentId: envB,
enabled: false,
allEnvironmentIds: all,
});
expect(next).toBe(current);
});

it("returns the same instance when disabling an already-disabled environment", () => {
const current: ReadonlySet<EnvironmentId> = new Set([envA]);
const next = toggleDisabledEnvironmentId({
disabledIds: current,
environmentId: envA,
enabled: false,
allEnvironmentIds: all,
});
expect(next).toBe(current);
});

it("returns the same instance when enabling an already-enabled environment", () => {
const current: ReadonlySet<EnvironmentId> = new Set([envB]);
const next = toggleDisabledEnvironmentId({
disabledIds: current,
environmentId: envA,
enabled: true,
allEnvironmentIds: all,
});
expect(next).toBe(current);
});
});

describe("pruneDisabledEnvironmentIds", () => {
const envA = EnvironmentId.make("env-a");
const envB = EnvironmentId.make("env-b");
const envC = EnvironmentId.make("env-c");

it("returns the same instance when nothing is disabled", () => {
const current: ReadonlySet<EnvironmentId> = new Set();
expect(
pruneDisabledEnvironmentIds({
disabledIds: current,
connectedEnvironmentIds: new Set([envA]),
}),
).toBe(current);
});

it("drops disabled environments that left the catalog", () => {
const next = pruneDisabledEnvironmentIds({
disabledIds: new Set([envA, envC]),
connectedEnvironmentIds: new Set([envA, envB]),
});
expect([...next]).toEqual([envA]);
});

it("returns the same instance when every disabled environment is still connected", () => {
const current: ReadonlySet<EnvironmentId> = new Set([envA]);
expect(
pruneDisabledEnvironmentIds({
disabledIds: current,
connectedEnvironmentIds: new Set([envA, envB]),
}),
).toBe(current);
});

it("returns all-enabled for the render where the last enabled environment disconnects", () => {
// envB (the only enabled environment) disconnected; keeping envA disabled
// would hide every thread while the filter button no longer renders.
const current = new Set<EnvironmentId>([envA]);
const next = pruneDisabledEnvironmentIds({
disabledIds: current,
connectedEnvironmentIds: new Set([envA]),
});
expect(next).not.toBe(current);
expect(next.size).toBe(0);
});

it("clears the filter when the catalog is empty", () => {
const next = pruneDisabledEnvironmentIds({
disabledIds: new Set([envA]),
connectedEnvironmentIds: new Set(),
});
expect(next.size).toBe(0);
});
});

describe("resolveSidebarDropVerb", () => {
it("names the state change a cross-section drop performs", () => {
expect(resolveSidebarDropVerb("active", "pinned")).toBe("pin");
Expand Down
48 changes: 47 additions & 1 deletion apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
isAtomCommandInterrupted,
type AtomCommandResult,
} from "@t3tools/client-runtime/state/runtime";
import type { ContextMenuItem } from "@t3tools/contracts";
import type { ContextMenuItem, EnvironmentId } from "@t3tools/contracts";
import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
import type { AsyncResult } from "effect/unstable/reactivity";
import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort";
Expand Down Expand Up @@ -1231,3 +1231,49 @@ export function sortScopedProjectsForSidebar<
left.id.localeCompare(right.id),
);
}

// --- Sidebar environment filter --------------------------------------------
// The filter stores the DISABLED environments so a newly connected
// environment is visible by default and "all enabled" is the empty set. Both
// helpers return the input set instance when nothing changed so React state
// setters can skip the update entirely.

export function toggleDisabledEnvironmentId(input: {
disabledIds: ReadonlySet<EnvironmentId>;
environmentId: EnvironmentId;
enabled: boolean;
allEnvironmentIds: ReadonlyArray<EnvironmentId>;
}): ReadonlySet<EnvironmentId> {
if (input.disabledIds.has(input.environmentId) !== input.enabled) {
// Already in the requested state (repeated or stale checkbox event):
// keep the instance so the caller's state update is a no-op.
return input.disabledIds;
}
const next = new Set(input.disabledIds);
if (input.enabled) {
next.delete(input.environmentId);
return next;
}
next.add(input.environmentId);
// Never allow disabling the last enabled environment: an empty thread list
// with no visible reason reads as data loss. (The menu also disables the
// last enabled item; this guards races and stale events.)
const someEnabled = input.allEnvironmentIds.some((id) => !next.has(id));
return someEnabled ? next : input.disabledIds;
}

export function pruneDisabledEnvironmentIds(input: {
disabledIds: ReadonlySet<EnvironmentId>;
connectedEnvironmentIds: ReadonlySet<EnvironmentId>;
}): ReadonlySet<EnvironmentId> {
const { connectedEnvironmentIds, disabledIds } = input;
if (disabledIds.size === 0) return disabledIds;
const next = new Set([...disabledIds].filter((id) => connectedEnvironmentIds.has(id)));
// A catalog change can strand the filter with nothing enabled (the last
// enabled environment disconnected) — and on single-environment catalogs
// the filter button no longer renders, so nothing could undo it. Reset to
// all-enabled instead of hiding every thread.
const someEnabled = [...connectedEnvironmentIds].some((id) => !next.has(id));
if (!someEnabled) return new Set<EnvironmentId>();
return next.size === disabledIds.size ? disabledIds : next;
}
Loading
Loading