Skip to content

Commit 503bcaa

Browse files
fix: keep offline environments out of thread reorder writes (#22887)
Co-authored-by: maria-rcks <[email protected]>
1 parent 9ceb949 commit 503bcaa

12 files changed

Lines changed: 592 additions & 53 deletions

‎apps/mobile/src/features/home/HomeScreen.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ import { useThreadListV2 } from "../threads/useThreadListV2";
5353
import { useThreadListNow } from "../threads/use-thread-list-now";
5454
import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts";
5555
import { usePendingThreadOrder } from "../../state/thread-order";
56-
import { threadListEnvironmentsAtom } from "../../state/server";
56+
import { threadListEnvironmentsAtom } from "../../state/presentation";
5757
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
5858
import { useQueuedThreadKeys } from "../../state/use-thread-outbox";
5959
import {

‎apps/mobile/src/features/home/useThreadListActions.test.ts‎

Lines changed: 98 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { AsyncResult } from "effect/reactivity";
1313
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
1414

1515
const state = vi.hoisted(() => ({
16+
connectedEnvironmentIds: [] as EnvironmentId[],
1617
pendingOrder: null as object | null,
1718
dropBusy: false,
1819
failedAction: null as string | null,
@@ -60,6 +61,9 @@ vi.mock("../../state/session", () => ({
6061
readEnvironmentScope: (environmentId: string, scope: string) =>
6162
state.scopes.get(environmentId)?.has(scope) === true,
6263
}));
64+
vi.mock("../../state/presentation", () => ({
65+
connectedEnvironmentIdsAtom: "connected-environments",
66+
}));
6367
vi.mock("../../state/server", () => ({
6468
environmentServerConfigsAtom: "server-configs",
6569
}));
@@ -69,28 +73,30 @@ vi.mock("../../state/atom-registry", () => ({
6973
state.dropBusy = value;
7074
},
7175
get: (atom: string) =>
72-
atom === "thread-drop-busy"
73-
? state.dropBusy
74-
: atom === "thread-shells"
75-
? state.shells
76-
: atom === "queued-thread-keys"
77-
? new Set<string>()
78-
: new Map(
79-
[...state.scopes.keys()].map((environmentId) => [
80-
environmentId,
81-
{
82-
environment: {
83-
capabilities: {
84-
threadSettlement: true,
85-
threadSnooze: true,
86-
threadPinning: true,
87-
threadPinReorder: true,
88-
threadTitleRegeneration: true,
76+
atom === "connected-environments"
77+
? state.connectedEnvironmentIds
78+
: atom === "thread-drop-busy"
79+
? state.dropBusy
80+
: atom === "thread-shells"
81+
? state.shells
82+
: atom === "queued-thread-keys"
83+
? new Set<string>()
84+
: new Map(
85+
[...state.scopes.keys()].map((environmentId) => [
86+
environmentId,
87+
{
88+
environment: {
89+
capabilities: {
90+
threadSettlement: true,
91+
threadSnooze: true,
92+
threadPinning: true,
93+
threadPinReorder: true,
94+
threadTitleRegeneration: true,
95+
},
8996
},
9097
},
91-
},
92-
]),
93-
),
98+
]),
99+
),
94100
},
95101
}));
96102
vi.mock("../../state/use-atom-command", () => ({
@@ -130,6 +136,7 @@ vi.mock("../../state/threads", () => ({
130136
"pin",
131137
"unpin",
132138
"reorderPin",
139+
"reorderActive",
133140
"updateMetadata",
134141
].map((action) => [
135142
action,
@@ -179,6 +186,7 @@ const mutationCases = [
179186
] as const;
180187

181188
beforeEach(() => {
189+
state.connectedEnvironmentIds = [primaryEnvironmentId, otherEnvironmentId];
182190
state.failedAction = null;
183191
state.archivedRefreshes = [];
184192
state.pendingOrder = null;
@@ -286,6 +294,76 @@ describe("pinned thread operation permissions", () => {
286294
});
287295
});
288296

297+
describe("thread reorder connection availability", () => {
298+
it.each(["active", "pinned"] as const)(
299+
"rejects %s materialization involving a cached capable offline neighbor",
300+
async (section) => {
301+
const moved = makeThread({
302+
pinnedAt: section === "pinned" ? "2026-09-02T00:00:00.000Z" : null,
303+
});
304+
state.shells = [
305+
moved,
306+
makeThread({
307+
id: ThreadId.make("offline-neighbor"),
308+
environmentId: otherEnvironmentId,
309+
createdAt: "2026-09-02T00:00:00.000Z",
310+
pinnedAt: moved.pinnedAt,
311+
}),
312+
];
313+
state.scopes.get(otherEnvironmentId)?.add(AuthOrchestrationOperateScope);
314+
state.connectedEnvironmentIds = [primaryEnvironmentId];
315+
316+
const { moveThread } = useThreadListActions();
317+
expect(await moveThread(moved, "up")).toBe(false);
318+
expect(state.requests).toEqual([]);
319+
320+
state.connectedEnvironmentIds = [primaryEnvironmentId, otherEnvironmentId];
321+
322+
expect(await moveThread(moved, "up")).toBe(true);
323+
expect(state.requests).toHaveLength(2);
324+
expect(new Set(state.requests.map(({ environmentId }) => environmentId))).toEqual(
325+
new Set([primaryEnvironmentId, otherEnvironmentId]),
326+
);
327+
},
328+
);
329+
330+
it.each(["active", "pinned"] as const)(
331+
"uses the latest connection state in a retained %s move callback",
332+
async (section) => {
333+
const moved = makeThread({
334+
pinnedAt: section === "pinned" ? "2026-09-02T00:00:00.000Z" : null,
335+
pinOrderKey: section === "pinned" ? "m" : null,
336+
activeOrderKey: section === "active" ? "m" : null,
337+
});
338+
state.shells = [
339+
moved,
340+
makeThread({
341+
id: ThreadId.make("offline-neighbor"),
342+
environmentId: otherEnvironmentId,
343+
pinnedAt: moved.pinnedAt,
344+
pinOrderKey: section === "pinned" ? "g" : null,
345+
activeOrderKey: section === "active" ? "g" : null,
346+
}),
347+
];
348+
const { moveThread } = useThreadListActions();
349+
state.connectedEnvironmentIds = [];
350+
351+
expect(await moveThread(moved, "up")).toBe(false);
352+
expect(state.requests).toEqual([]);
353+
354+
state.connectedEnvironmentIds = [primaryEnvironmentId];
355+
356+
expect(await moveThread(moved, "up")).toBe(true);
357+
expect(state.requests).toEqual([
358+
expect.objectContaining({
359+
action: section === "pinned" ? "reorderPin" : "reorderActive",
360+
environmentId: primaryEnvironmentId,
361+
}),
362+
]);
363+
},
364+
);
365+
});
366+
289367
describe("thread action undo", () => {
290368
it.each([
291369
["settleThread", "settle", "Settled", "unsettle"],

‎apps/mobile/src/features/home/useThreadListActions.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThre
1515
import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort";
1616
import { appAtomRegistry } from "../../state/atom-registry";
1717
import { environmentServerConfigsAtom } from "../../state/server";
18+
import { connectedEnvironmentIdsAtom } from "../../state/presentation";
1819
import { readEnvironmentScope } from "../../state/session";
1920
import { environmentThreadShells, threadEnvironment } from "../../state/threads";
2021
import { queuedThreadKeysAtom } from "../../state/use-thread-outbox";
@@ -591,6 +592,10 @@ export function useThreadListActions(): {
591592
return settled;
592593
}
593594
const configs = appAtomRegistry.get(environmentServerConfigsAtom);
595+
const connectedEnvironmentIds = new Set(appAtomRegistry.get(connectedEnvironmentIdsAtom));
596+
597+
if (!connectedEnvironmentIds.has(thread.environmentId)) return false;
598+
594599
if (!configs.has(thread.environmentId)) {
595600
Dialog.alert(
596601
"Could not move thread",
@@ -610,7 +615,9 @@ export function useThreadListActions(): {
610615
allThreads: shells,
611616
ordered,
612617
section,
613-
reorderableEnvironmentIds: new Set(configs.keys()),
618+
reorderableEnvironmentIds: new Set(
619+
[...configs.keys()].filter((environmentId) => connectedEnvironmentIds.has(environmentId)),
620+
),
614621
})(scopedThreadKey(thread.environmentId, thread.id), direction);
615622
if (assignments === null) return false;
616623
const lifecycle = threadDropLifecycle(thread, section, new Date().toISOString());

‎apps/mobile/src/features/threads/ThreadArrangementSheet.tsx‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { AppText as Text } from "../../components/ui/AppText";
1515
import { SymbolView } from "../../components/ui/AppSymbol";
1616
import { scopedThreadKey } from "../../lib/scopedEntities";
1717
import { environmentServerConfigsAtom } from "../../state/server";
18+
import { threadListEnvironmentsAtom } from "../../state/presentation";
1819
import { environmentThreadShells } from "../../state/threads";
1920
import { sortSettledThreads } from "@t3tools/client-runtime/state/thread-sort";
2021
import { useOlderSettledShelf, useOlderSettledShelfGrowth } from "../../state/olderSettledShelf";
@@ -164,6 +165,7 @@ function DragHandle(props: {
164165
function ThreadArrangementSheet(props: { onClose: () => void }) {
165166
const threads = useAtomValue(environmentThreadShells.navigationThreadShellsAtom);
166167
const configs = useAtomValue(environmentServerConfigsAtom);
168+
const listEnvironments = useAtomValue(threadListEnvironmentsAtom);
167169
const queuedThreadKeys = useAtomValue(queuedThreadKeysAtom);
168170
const pendingOrder = useAtomValue(pendingThreadOrderAtom);
169171
const dropBusy = useAtomValue(threadDropBusyAtom);
@@ -214,12 +216,15 @@ function ThreadArrangementSheet(props: { onClose: () => void }) {
214216
ordered: sections[section],
215217
allThreads: threads,
216218
section,
217-
reorderableEnvironmentIds: new Set(
218-
section === "pinned" || !workingShelfEnabled ? configs.keys() : [],
219-
),
219+
reorderableEnvironmentIds:
220+
section === "pinned"
221+
? listEnvironments.pinReorderEnvironmentIds
222+
: workingShelfEnabled
223+
? new Set()
224+
: listEnvironments.activeReorderEnvironmentIds,
220225
});
221226
return { pinned: planner("pinned"), active: planner("active") };
222-
}, [sections, threads, configs, workingShelfEnabled]);
227+
}, [sections, threads, listEnvironments, workingShelfEnabled]);
223228
const rows = useMemo(() => {
224229
const result: Row[] = [];
225230
let offset = 0;
@@ -468,7 +473,12 @@ function ThreadArrangementSheet(props: { onClose: () => void }) {
468473
disabled={
469474
dropBusy ||
470475
pendingOrder !== null ||
471-
!configs.has(thread.environmentId)
476+
(!listEnvironments.pinReorderEnvironmentIds.has(
477+
thread.environmentId,
478+
) &&
479+
!listEnvironments.activeReorderEnvironmentIds.has(
480+
thread.environmentId,
481+
))
472482
}
473483
sectionActions={sectionActions}
474484
onSectionMove={(section) => {

‎apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import { useThreadSearch } from "../../state/queries";
3737
import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
3838
import { useThreadListNow } from "./use-thread-list-now";
3939
import { usePendingThreadOrder } from "../../state/thread-order";
40-
import { threadListEnvironmentsAtom } from "../../state/server";
40+
import { threadListEnvironmentsAtom } from "../../state/presentation";
4141
import { usePendingNewTasks, type PendingNewTask } from "../../state/use-pending-new-tasks";
4242
import { useQueuedThreadKeys } from "../../state/use-thread-outbox";
4343
import { useWorkspaceState } from "../../state/workspace";

0 commit comments

Comments
 (0)