Skip to content
Merged
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
5 changes: 1 addition & 4 deletions apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,7 @@ export function HomeRouteScreen() {
const projects = useProjects();
// Stop rebuilding the streaming thread list while Thread covers Home.
const visible = useHomeRouteVisible();
const threads = useAtomValueWhileVisible(
environmentThreadShells.navigationThreadShellsAtom,
visible,
);
const threads = useAtomValueWhileVisible(environmentThreadShells.quietThreadShellsAtom, visible);
const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState();
const { savedConnectionsById } = useSavedRemoteConnections();
const navigation = useNavigation();
Expand Down
4 changes: 3 additions & 1 deletion apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -755,7 +755,9 @@ export function HomeScreen(props: HomeScreenProps) {
detail={`No threads matching "${props.searchQuery}".`}
variant={Platform.OS === "android" ? "plain" : undefined}
/>
) : props.hideOfflineEnvironments ? (
) : props.hideOfflineEnvironments &&
props.selectedEnvironmentId === null &&
v2ScopedProjectGroup === null ? (
<EmptyState
title="No visible threads"
detail="Turn off Hide offline environments in Settings → Labs → Zack settings, or choose another filter."
Expand Down
8 changes: 5 additions & 3 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";

import { AppText as Text } from "../../components/ui/AppText";
import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
import { useProjects, useNavigationThreadShells } from "../../state/entities";
import { useProjects, useVisibleThreadShells } from "../../state/entities";
import { useThreadSearch } from "../../state/queries";
import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
import { useThreadListNow } from "./use-thread-list-now";
Expand Down Expand Up @@ -117,7 +117,7 @@ function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) {
const insets = useSafeAreaInsets();
const { fabClearance, fabSize } = useAndroidControlSizing();
const projects = useProjects();
const threads = useNavigationThreadShells();
const threads = useVisibleThreadShells();
const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState();
const { savedConnectionsById } = useSavedRemoteConnections();
const openSwipeableRef = useRef<SwipeableMethods | null>(null);
Expand Down Expand Up @@ -569,7 +569,9 @@ function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) {
? threadSearch.isPending
? "Searching thread messages…"
: "No matching threads"
: options.hideOfflineEnvironments
: options.hideOfflineEnvironments &&
options.selectedEnvironmentId === null &&
selectedProjectScope === null
? "No visible threads. Turn off Hide offline environments in Settings → Labs → Zack settings, or choose another filter."
: selectedProjectScope !== null
? `No threads in ${selectedProjectScope.title}`
Expand Down
103 changes: 103 additions & 0 deletions apps/mobile/src/features/threads/useThreadListV2.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,109 @@ it("reuses a large thread list across tab renders and updates when its inputs ch
expect(
seen.at(-1)!.layout.items.every((item) => item.thread.environmentId === otherEnvironmentId),
).toBe(true);
// Explicitly selecting an offline environment keeps its threads listed, so a
// thread on that environment stays discoverable under its own filter.
await act(async () =>
root.render(<Probe input={{ ...hiddenInput, environmentId }} tab="chats" />),
);
expect(seen.at(-1)!.layout.items).toHaveLength(85);
expect(seen.at(-1)!.layout.settledCount).toBe(575);
expect(
seen.at(-1)!.layout.items.every((item) => item.thread.environmentId === environmentId),
).toBe(true);
expect(
seen
.at(-1)!
.items.filter((item) => item.type === "v2-pending")
.map((item) => item.pendingTask.environmentId),
).toEqual([environmentId]);
expect(
vi
.mocked(olderSettledThreads.shelfAtom)
.mock.lastCall?.[0].map(({ scope }) => scope.environmentId),
).toEqual([environmentId]);
// Scoping to a project on the offline environment (without selecting the
// environment itself) also keeps its threads listed.
await act(async () =>
root.render(
<Probe
input={{
...hiddenInput,
projectRefs: [{ environmentId, projectId: ProjectId.make("project-test") }],
}}
tab="chats"
/>,
),
);
expect(seen.at(-1)!.layout.items).toHaveLength(85);
expect(
seen.at(-1)!.layout.items.every((item) => item.thread.environmentId === environmentId),
).toBe(true);
expect(
vi
.mocked(olderSettledThreads.shelfAtom)
.mock.lastCall?.[0].map(({ scope }) => scope.environmentId),
).toContain(environmentId);
await act(async () => root.render(<Probe input={input} tab="chats" />));
expect(seen.at(-1)!.layout.settledCount).toBe(1100);

const parent = presentThreadShell(
environmentId,
makeRawThreadShell({
id: ThreadId.make("parent"),
projectId: ProjectId.make("parent-project"),
}),
);
const task = presentThreadShell(
otherEnvironmentId,
makeRawThreadShell({
id: ThreadId.make("task"),
delegatedFrom: { environmentId, threadId: parent.id, title: parent.title },
}),
);
const subagent = {
...task,
id: ThreadId.make("subagent"),
lineage: {
parentThreadId: task.id,
rootThreadId: task.id,
relationshipToParent: "subagent" as const,
},
};
const archived = { ...task, id: ThreadId.make("archived"), archivedAt: input.now };
const delegationInput = {
...input,
threads: [parent, task, subagent, archived],
hideOfflineEnvironments: true,
};
await act(async () => registry.set(offlineIds, []));
await act(async () => root.render(<Probe input={delegationInput} tab="chats" />));
expect(seen.at(-1)!.layout.items.map((item) => item.thread.id)).toEqual([parent.id]);
await act(async () => registry.set(offlineIds, [environmentId]));
expect(seen.at(-1)!.layout.items.map((item) => item.thread.id)).toEqual([task.id]);
const taskScope = { environmentId: otherEnvironmentId, projectId: task.projectId };
await act(async () =>
root.render(
<Probe
input={{
...delegationInput,
projectRefs: [taskScope, { environmentId, projectId: parent.projectId }],
}}
tab="chats"
/>,
),
);
expect(seen.at(-1)!.layout.items.map((item) => item.thread.id)).toEqual([parent.id]);
await act(async () =>
root.render(
<Probe
input={{
...delegationInput,
projectRefs: [taskScope, { environmentId, projectId: ProjectId.make("other-project") }],
}}
tab="chats"
/>,
),
);
expect(seen.at(-1)!.layout.items.map((item) => item.thread.id)).toEqual([task.id]);
});
60 changes: 48 additions & 12 deletions apps/mobile/src/features/threads/useThreadListV2.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useAtomValue } from "@effect/atom-react";
import type { OlderSettledScope } from "@t3tools/client-runtime/state/threads";
import type { EnvironmentId } from "@t3tools/contracts";
import { isListedThreadLineage, type EnvironmentId } from "@t3tools/contracts";
import { useMemo, useSyncExternalStore } from "react";

import { offlineEnvironmentIdsAtom } from "../../state/presentation";
Expand Down Expand Up @@ -71,17 +71,53 @@ export function useThreadListV2(input: {
}) {
const { workingShelfEnabled } = input;
const offlineEnvironmentIds = useAtomValue(offlineEnvironmentIdsAtom);
const hiddenEnvironmentIds = useMemo(
() => new Set(input.hideOfflineEnvironments ? offlineEnvironmentIds : []),
[input.hideOfflineEnvironments, offlineEnvironmentIds],
);
const threads = useMemo(
() =>
hiddenEnvironmentIds.size === 0
? input.threads
: input.threads.filter((thread) => !hiddenEnvironmentIds.has(thread.environmentId)),
[input.threads, hiddenEnvironmentIds],
);
// An explicitly selected environment, or one whose project is explicitly
// scoped to, stays listed even when it counts as offline; hiding it would
// leave its own filter with nothing to open.
const hiddenEnvironmentIds = useMemo(() => {
if (!input.hideOfflineEnvironments) return new Set<EnvironmentId>();
const scopedEnvironmentIds = new Set(input.projectRefs?.map((ref) => ref.environmentId));
return new Set(
offlineEnvironmentIds.filter(
(id) => id !== input.environmentId && !scopedEnvironmentIds.has(id),
),
);
}, [
input.hideOfflineEnvironments,
offlineEnvironmentIds,
input.environmentId,
input.projectRefs,
]);
const threads = useMemo(() => {
const visible = input.threads.filter(
(thread) =>
thread.archivedAt === null &&
isListedThreadLineage(thread.lineage) &&
!hiddenEnvironmentIds.has(thread.environmentId),
);
const projectKeys =
input.projectRefs === null || input.projectRefs === undefined
? null
: new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`));
const parentKeys = new Set(
visible
.filter(
(thread) =>
(input.environmentId === null || thread.environmentId === input.environmentId) &&
(projectKeys === null ||
projectKeys.has(`${thread.environmentId}:${thread.projectId}`)),
)
.map((thread) => `${thread.environmentId}:${thread.id}`),
);
return visible.filter((thread) => {
const parent = thread.delegatedFrom;
return (
parent === null ||
parent === undefined ||
!parentKeys.has(`${parent.environmentId}:${parent.threadId}`)
);
});
}, [input.threads, input.environmentId, input.projectRefs, hiddenEnvironmentIds]);
const layoutTime = useMemo(
() => threadListV2LayoutTime({ threads, now: input.now, searchQuery: input.searchQuery }),
[threads, input.now, input.searchQuery],
Expand Down
28 changes: 24 additions & 4 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -869,14 +869,17 @@ describe("filterSidebarV2VisibleThreads", () => {
expect(visibleIds(null, null)).toEqual(["local", "remote", "local-other"]);
expect(visibleIds(null, hidden())).toEqual(["local", "local-other"]);
expect(visibleIds(localScope, hidden())).toEqual(["local"]);
expect(visibleIds(remoteScope, hidden())).toEqual([]);
// Explicitly scoping to the offline environment's project keeps its
// threads listed, so they stay discoverable under that scope.
expect(visibleIds(remoteScope, hidden())).toEqual(["remote"]);
// The open thread's row stays listed, but only within the project scope.
expect(visibleIds(null, hidden(remoteKey))).toEqual(["local", "remote", "local-other"]);
expect(visibleIds(localScope, hidden(remoteKey))).toEqual(["local"]);

expect(hidesOffline(null, null)).toBe(false);
expect(hidesOffline(null, hidden())).toBe(true);
expect(hidesOffline(localScope, hidden())).toBe(false);
expect(hidesOffline(remoteScope, hidden())).toBe(false);
expect(hidesOffline(null, hidden(remoteKey))).toBe(false);
});

Expand All @@ -900,11 +903,14 @@ describe("filterSidebarV2VisibleThreads", () => {
const delegated = new Set([
threadKey({ environmentId: task.environmentId, threadId: task.id }),
]);
const visibleIds = (hiddenEnvironmentIds: ReadonlySet<EnvironmentId> | null) =>
const visibleIds = (
hiddenEnvironmentIds: ReadonlySet<EnvironmentId> | null,
scope: ReadonlySet<string> | null = null,
) =>
filterSidebarV2VisibleThreads(
threads,
null,
withoutHiddenDelegationParents(delegated, threads, hiddenEnvironmentIds),
scope,
withoutHiddenDelegationParents(delegated, threads, hiddenEnvironmentIds, scope),
hiddenEnvironmentIds === null
? null
: { environmentIds: hiddenEnvironmentIds, keptThreadKey: null },
Expand All @@ -913,6 +919,20 @@ describe("filterSidebarV2VisibleThreads", () => {
expect(visibleIds(null)).toEqual(["thread-parent"]);
expect(visibleIds(new Set([parentEnvironmentId]))).toEqual(["thread-task"]);
expect(visibleIds(new Set([taskEnvironmentId]))).toEqual(["thread-parent"]);
const hiddenParents = new Set([parentEnvironmentId]);
const bothProjects = new Set([
`${parentEnvironmentId}:${parent.projectId}`,
`${taskEnvironmentId}:${task.projectId}`,
]);
expect(visibleIds(hiddenParents, bothProjects)).toEqual(["thread-parent"]);
expect(
visibleIds(
hiddenParents,
new Set([`${parentEnvironmentId}:other-project`, `${taskEnvironmentId}:${task.projectId}`]),
),
).toEqual(["thread-task"]);
// Widening a route back to all projects hides the offline parent again.
expect(visibleIds(hiddenParents, null)).toEqual(["thread-task"]);
});
});

Expand Down
29 changes: 26 additions & 3 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,10 @@ export function filterSidebarV2VisibleThreads<
thread.archivedAt === null &&
(hidden === null ||
!hidden.environmentIds.has(thread.environmentId) ||
// Explicitly scoping to a project keeps its threads listed even when
// their environment counts as offline.
(scopedProjectKeys !== null &&
scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)) ||
scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) ===
hidden.keptThreadKey) &&
!isShownUnderParent(
Expand All @@ -640,13 +644,32 @@ export function filterSidebarV2VisibleThreads<
/** A task whose parent's environment is hidden lists on its own, as under an archived parent. */
export function withoutHiddenDelegationParents(
delegatedUnderParent: ReadonlySet<string>,
threads: readonly Pick<SidebarThreadSummary, "delegatedFrom" | "environmentId" | "id">[],
threads: readonly Pick<
SidebarThreadSummary,
"delegatedFrom" | "environmentId" | "id" | "projectId"
>[],
hiddenEnvironmentIds: ReadonlySet<EnvironmentId> | null,
scopedProjectKeys: ReadonlySet<string> | null = null,
): ReadonlySet<string> {
if (hiddenEnvironmentIds === null) return delegatedUnderParent;
const scopedParentKeys =
scopedProjectKeys === null
? null
: new Set(
threads
.filter((thread) =>
scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`),
)
.map((thread) =>
threadKey({ environmentId: thread.environmentId, threadId: thread.id }),
),
);
const orphanKeys = threads.flatMap((thread) => {
const parentEnvironmentId = thread.delegatedFrom?.environmentId;
return parentEnvironmentId !== undefined && hiddenEnvironmentIds.has(parentEnvironmentId)
const parent = thread.delegatedFrom;
return parent !== null &&
parent !== undefined &&
hiddenEnvironmentIds.has(parent.environmentId) &&
!scopedParentKeys?.has(threadKey(parent))
? [threadKey({ environmentId: thread.environmentId, threadId: thread.id })]
: [];
});
Expand Down
17 changes: 13 additions & 4 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,12 @@ function useSidebarThreads(input: {
const olderSettledShelf = useOlderSettledShelf({
scopes: olderSettledScopes(
[...serverConfigs.keys()].filter(
(environmentId) => hidden === null || !hidden.environmentIds.has(environmentId),
(environmentId) =>
hidden === null ||
!hidden.environmentIds.has(environmentId) ||
// A scoped project on an offline environment still pages its shelf.
(scopedProjectKeys !== null &&
[...scopedProjectKeys].some((key) => key.startsWith(`${environmentId}:`))),
),
scopedProjectKeys,
),
Expand Down Expand Up @@ -1133,15 +1138,16 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
),
);
const threads = useQuietThreadShells();
const delegatedUnderParent = withoutHiddenDelegationParents(
const scopedDelegatedUnderParent = withoutHiddenDelegationParents(
allDelegatedUnderParent,
threads,
hiddenEnvironmentIds,
chosenScopeProjectKeys,
);
const routeRowThreadRef =
routeThreadRef === null
? null
: resolveSidebarRowThreadRef(routeThreadRef, threads, delegatedUnderParent);
: resolveSidebarRowThreadRef(routeThreadRef, threads, scopedDelegatedUnderParent);
const routeRowThreadKey = routeRowThreadRef === null ? null : scopedThreadKey(routeRowThreadRef);
// Only a hidden environment's open row is kept, so other navigation leaves the partition memoized.
const keptThreadKey =
Expand All @@ -1161,9 +1167,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
routeKey: routeRowThreadKey,
isSearching: isSearchingThreads,
scopedProjectKeys: chosenScopeProjectKeys,
delegatedUnderParent,
delegatedUnderParent: scopedDelegatedUnderParent,
findThread: (key) => threads.find((candidate) => threadShellKey(candidate) === key),
});
const delegatedUnderParent = revealWidensScope
? withoutHiddenDelegationParents(allDelegatedUnderParent, threads, hiddenEnvironmentIds)
: scopedDelegatedUnderParent;
const projectScopeKeys = revealWidensScope ? [] : chosenProjectScopeKeys;
const projectScopeKey = projectScopeKeys.length === 0 ? null : projectScopeKeys.join("\n");
const toggleProjectScopeKey = (key: string) =>
Expand Down
Loading