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
2 changes: 1 addition & 1 deletion apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ import { useThreadListV2 } from "../threads/useThreadListV2";
import { useThreadListNow } from "../threads/use-thread-list-now";
import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts";
import { usePendingThreadOrder } from "../../state/thread-order";
import { threadListEnvironmentsAtom } from "../../state/server";
import { threadListEnvironmentsAtom } from "../../state/presentation";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import { useQueuedThreadKeys } from "../../state/use-thread-outbox";
import {
Expand Down
118 changes: 98 additions & 20 deletions apps/mobile/src/features/home/useThreadListActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { AsyncResult } from "effect/reactivity";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

const state = vi.hoisted(() => ({
connectedEnvironmentIds: [] as EnvironmentId[],
pendingOrder: null as object | null,
dropBusy: false,
failedAction: null as string | null,
Expand Down Expand Up @@ -60,6 +61,9 @@ vi.mock("../../state/session", () => ({
readEnvironmentScope: (environmentId: string, scope: string) =>
state.scopes.get(environmentId)?.has(scope) === true,
}));
vi.mock("../../state/presentation", () => ({
connectedEnvironmentIdsAtom: "connected-environments",
}));
vi.mock("../../state/server", () => ({
environmentServerConfigsAtom: "server-configs",
}));
Expand All @@ -69,28 +73,30 @@ vi.mock("../../state/atom-registry", () => ({
state.dropBusy = value;
},
get: (atom: string) =>
atom === "thread-drop-busy"
? state.dropBusy
: atom === "thread-shells"
? state.shells
: atom === "queued-thread-keys"
? new Set<string>()
: new Map(
[...state.scopes.keys()].map((environmentId) => [
environmentId,
{
environment: {
capabilities: {
threadSettlement: true,
threadSnooze: true,
threadPinning: true,
threadPinReorder: true,
threadTitleRegeneration: true,
atom === "connected-environments"
? state.connectedEnvironmentIds
: atom === "thread-drop-busy"
? state.dropBusy
: atom === "thread-shells"
? state.shells
: atom === "queued-thread-keys"
? new Set<string>()
: new Map(
[...state.scopes.keys()].map((environmentId) => [
environmentId,
{
environment: {
capabilities: {
threadSettlement: true,
threadSnooze: true,
threadPinning: true,
threadPinReorder: true,
threadTitleRegeneration: true,
},
},
},
},
]),
),
]),
),
},
}));
vi.mock("../../state/use-atom-command", () => ({
Expand Down Expand Up @@ -130,6 +136,7 @@ vi.mock("../../state/threads", () => ({
"pin",
"unpin",
"reorderPin",
"reorderActive",
"updateMetadata",
].map((action) => [
action,
Expand Down Expand Up @@ -179,6 +186,7 @@ const mutationCases = [
] as const;

beforeEach(() => {
state.connectedEnvironmentIds = [primaryEnvironmentId, otherEnvironmentId];
state.failedAction = null;
state.archivedRefreshes = [];
state.pendingOrder = null;
Expand Down Expand Up @@ -286,6 +294,76 @@ describe("pinned thread operation permissions", () => {
});
});

describe("thread reorder connection availability", () => {
it.each(["active", "pinned"] as const)(
"rejects %s materialization involving a cached capable offline neighbor",
async (section) => {
const moved = makeThread({
pinnedAt: section === "pinned" ? "2026-09-02T00:00:00.000Z" : null,
});
state.shells = [
moved,
makeThread({
id: ThreadId.make("offline-neighbor"),
environmentId: otherEnvironmentId,
createdAt: "2026-09-02T00:00:00.000Z",
pinnedAt: moved.pinnedAt,
}),
];
state.scopes.get(otherEnvironmentId)?.add(AuthOrchestrationOperateScope);
state.connectedEnvironmentIds = [primaryEnvironmentId];

const { moveThread } = useThreadListActions();
expect(await moveThread(moved, "up")).toBe(false);
expect(state.requests).toEqual([]);

state.connectedEnvironmentIds = [primaryEnvironmentId, otherEnvironmentId];

expect(await moveThread(moved, "up")).toBe(true);
expect(state.requests).toHaveLength(2);
expect(new Set(state.requests.map(({ environmentId }) => environmentId))).toEqual(
new Set([primaryEnvironmentId, otherEnvironmentId]),
);
},
);

it.each(["active", "pinned"] as const)(
"uses the latest connection state in a retained %s move callback",
async (section) => {
const moved = makeThread({
pinnedAt: section === "pinned" ? "2026-09-02T00:00:00.000Z" : null,
pinOrderKey: section === "pinned" ? "m" : null,
activeOrderKey: section === "active" ? "m" : null,
});
state.shells = [
moved,
makeThread({
id: ThreadId.make("offline-neighbor"),
environmentId: otherEnvironmentId,
pinnedAt: moved.pinnedAt,
pinOrderKey: section === "pinned" ? "g" : null,
activeOrderKey: section === "active" ? "g" : null,
}),
];
const { moveThread } = useThreadListActions();
state.connectedEnvironmentIds = [];

expect(await moveThread(moved, "up")).toBe(false);
expect(state.requests).toEqual([]);

state.connectedEnvironmentIds = [primaryEnvironmentId];

expect(await moveThread(moved, "up")).toBe(true);
expect(state.requests).toEqual([
expect.objectContaining({
action: section === "pinned" ? "reorderPin" : "reorderActive",
environmentId: primaryEnvironmentId,
}),
]);
},
);
});

describe("thread action undo", () => {
it.each([
["settleThread", "settle", "Settled", "unsettle"],
Expand Down
9 changes: 8 additions & 1 deletion apps/mobile/src/features/home/useThreadListActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThre
import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort";
import { appAtomRegistry } from "../../state/atom-registry";
import { environmentServerConfigsAtom } from "../../state/server";
import { connectedEnvironmentIdsAtom } from "../../state/presentation";
import { readEnvironmentScope } from "../../state/session";
import { environmentThreadShells, threadEnvironment } from "../../state/threads";
import { queuedThreadKeysAtom } from "../../state/use-thread-outbox";
Expand Down Expand Up @@ -591,6 +592,10 @@ export function useThreadListActions(): {
return settled;
}
const configs = appAtomRegistry.get(environmentServerConfigsAtom);
const connectedEnvironmentIds = new Set(appAtomRegistry.get(connectedEnvironmentIdsAtom));

if (!connectedEnvironmentIds.has(thread.environmentId)) return false;

if (!configs.has(thread.environmentId)) {
Dialog.alert(
"Could not move thread",
Expand All @@ -610,7 +615,9 @@ export function useThreadListActions(): {
allThreads: shells,
ordered,
section,
reorderableEnvironmentIds: new Set(configs.keys()),
reorderableEnvironmentIds: new Set(
[...configs.keys()].filter((environmentId) => connectedEnvironmentIds.has(environmentId)),
),
})(scopedThreadKey(thread.environmentId, thread.id), direction);
if (assignments === null) return false;
const lifecycle = threadDropLifecycle(thread, section, new Date().toISOString());
Expand Down
20 changes: 15 additions & 5 deletions apps/mobile/src/features/threads/ThreadArrangementSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { AppText as Text } from "../../components/ui/AppText";
import { SymbolView } from "../../components/ui/AppSymbol";
import { scopedThreadKey } from "../../lib/scopedEntities";
import { environmentServerConfigsAtom } from "../../state/server";
import { threadListEnvironmentsAtom } from "../../state/presentation";
import { environmentThreadShells } from "../../state/threads";
import { sortSettledThreads } from "@t3tools/client-runtime/state/thread-sort";
import { useOlderSettledShelf, useOlderSettledShelfGrowth } from "../../state/olderSettledShelf";
Expand Down Expand Up @@ -164,6 +165,7 @@ function DragHandle(props: {
function ThreadArrangementSheet(props: { onClose: () => void }) {
const threads = useAtomValue(environmentThreadShells.navigationThreadShellsAtom);
const configs = useAtomValue(environmentServerConfigsAtom);
const listEnvironments = useAtomValue(threadListEnvironmentsAtom);
const queuedThreadKeys = useAtomValue(queuedThreadKeysAtom);
const pendingOrder = useAtomValue(pendingThreadOrderAtom);
const dropBusy = useAtomValue(threadDropBusyAtom);
Expand Down Expand Up @@ -214,12 +216,15 @@ function ThreadArrangementSheet(props: { onClose: () => void }) {
ordered: sections[section],
allThreads: threads,
section,
reorderableEnvironmentIds: new Set(
section === "pinned" || !workingShelfEnabled ? configs.keys() : [],
),
reorderableEnvironmentIds:
section === "pinned"
? listEnvironments.pinReorderEnvironmentIds
: workingShelfEnabled
? new Set()
: listEnvironments.activeReorderEnvironmentIds,
});
return { pinned: planner("pinned"), active: planner("active") };
}, [sections, threads, configs, workingShelfEnabled]);
}, [sections, threads, listEnvironments, workingShelfEnabled]);
const rows = useMemo(() => {
const result: Row[] = [];
let offset = 0;
Expand Down Expand Up @@ -468,7 +473,12 @@ function ThreadArrangementSheet(props: { onClose: () => void }) {
disabled={
dropBusy ||
pendingOrder !== null ||
!configs.has(thread.environmentId)
(!listEnvironments.pinReorderEnvironmentIds.has(
thread.environmentId,
) &&
!listEnvironments.activeReorderEnvironmentIds.has(
thread.environmentId,
))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
sectionActions={sectionActions}
onSectionMove={(section) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import { useThreadSearch } from "../../state/queries";
import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
import { useThreadListNow } from "./use-thread-list-now";
import { usePendingThreadOrder } from "../../state/thread-order";
import { threadListEnvironmentsAtom } from "../../state/server";
import { threadListEnvironmentsAtom } from "../../state/presentation";
import { usePendingNewTasks, type PendingNewTask } from "../../state/use-pending-new-tasks";
import { useQueuedThreadKeys } from "../../state/use-thread-outbox";
import { useWorkspaceState } from "../../state/workspace";
Expand Down
Loading
Loading