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: 2 additions & 0 deletions .agents/upstream-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ Historical groups are indexed in the linked archive. This file migration changes
| Browser profiles and login imports / `062987b2fb0ef48cc7776d654931bd9f3bcf1f9f` | Full seven-source list in [#411](https://github.com/pylon-code/pylon/pull/411), from #7254 through #9797 | Adopted final profile/import stack. Preserve Pylon partitions and legacy logins, client settings queues, provider/Prime behavior, terminal link fallback, panel transitions and release identity. Custom/Incognito opens require same-session server capability support. Named Safari profiles receive the Full Disk Access check. No remaining partial scope in this group. | [Browser profiles #411](https://github.com/pylon-code/pylon/pull/411); focused suites, eight package typechecks, scoped lint/dependency/export checks, isolated Electron restart/import probe and renderer evidence. |
| Sidebar actions and draft visibility / `062987b2fb0ef48cc7776d654931bd9f3bcf1f9f` | Full 12-source list and brand-only skip in PR #412 | Adopted filter persistence, draft markers/discard, bulk unpin, both-layout deletion recovery, project-settings entries and row readability. Pylon attention/delegation states and serial unpin confirmation are preserved. `bf40fa786c521b552eb554bbd4f2c75c4123cd03` is skipped because its T3 wordmark baseline issue is absent from Pylon. No partial source; cursor unchanged. | [Sidebar actions #412](https://github.com/pylon-code/pylon/pull/412); focused tests, web types, scoped checks and integrated client evidence in PR. |

| Active thread order and cross-section arrangement / `062987b2fb0ef48cc7776d654931bd9f3bcf1f9f` | Full eight-source list in [#413](https://github.com/pylon-code/pylon/pull/413), from #9729 through #10464 | Adopted saved active order across server, web and both mobile lists; final drag cues, cancellation and bulk-update performance fixes. Preserve Pylon migration lineage (57), archived snapshots, provider/Prime lifecycle, draft actions, attention/delegation states and older-server guards. Hidden keys remain reserved; no partial scope. | [Thread arrangement #413](https://github.com/pylon-code/pylon/pull/413); 550 focused tests, six package typechecks, scoped checks and integrated browser/iOS evidence. |

## Deferred register

Open decisions retain their original dates and revisit conditions. Check triggers once per integration cycle using the skill; detailed history is in the archive.
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export function HomeRouteScreen() {
unsnoozeThread,
pinThread,
unpinThread,
movePinnedThread,
moveThread,
regenerateThreadTitle,
unsettleThread,
} = useThreadListActions();
Expand Down Expand Up @@ -199,7 +199,7 @@ export function HomeRouteScreen() {
onUnsettleThread={unsettleThread}
onPinThread={pinThread}
onUnpinThread={unpinThread}
onMovePinnedThread={movePinnedThread}
onMoveThread={moveThread}
onRegenerateThreadTitle={regenerateThreadTitle}
onEnvironmentChange={setSelectedEnvironmentId}
onProjectChange={setSelectedProjectKey}
Expand Down
94 changes: 66 additions & 28 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
import { createThreadMovePlanner } from "../threads/threadOrder";
import {
LegendList,
type LegendListRef,
Expand All @@ -12,7 +13,6 @@ import {
threadSearchMatchKey,
type EnvironmentThreadSearchMatch,
} from "@t3tools/client-runtime/state/thread-search";
import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
import type {
EnvironmentId,
SidebarProjectGroupingMode,
Expand All @@ -35,6 +35,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
import { useThreadSearch } from "../../state/queries";
import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
import { usePendingThreadOrder } from "../../state/thread-order";
import { environmentServerConfigsAtom } from "../../state/server";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
Expand All @@ -51,6 +52,7 @@ import {
} from "../threads/thread-list-v2-items";
import {
buildThreadListV2Items,
getThreadListV2OrderedSection,
buildThreadListV2ListItems,
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
Expand Down Expand Up @@ -114,7 +116,7 @@ interface HomeScreenProps {
readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
readonly onPinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
readonly onMovePinnedThread: (
readonly onMoveThread: (
thread: EnvironmentThreadShell,
direction: "up" | "down",
) => Promise<boolean>;
Expand Down Expand Up @@ -520,11 +522,11 @@ export function HomeScreen(props: HomeScreenProps) {
},
[props.onPinThread],
);
const handleMovePinnedThread = useCallback(
const handleMoveThread = useCallback(
(thread: EnvironmentThreadShell, direction: "up" | "down") => {
void props.onMovePinnedThread(thread, direction);
void props.onMoveThread(thread, direction);
},
[props.onMovePinnedThread],
[props.onMoveThread],
);
const handleUnpinThread = useCallback(
(thread: EnvironmentThreadShell) => {
Expand Down Expand Up @@ -616,6 +618,15 @@ export function HomeScreen(props: HomeScreenProps) {
}
return supported;
}, [serverConfigs]);
const activeReorderEnvironmentIds = useMemo(() => {
const supported = new Set<EnvironmentId>();
for (const [environmentId, config] of serverConfigs) {
if (config.environment.capabilities.threadActiveReorder === true) {
supported.add(environmentId);
}
}
return supported;
}, [serverConfigs]);
const titleRegenerationEnvironmentIds = useMemo(() => {
const supported = new Set<EnvironmentId>();
for (const [environmentId, config] of serverConfigs) {
Expand All @@ -635,20 +646,40 @@ export function HomeScreen(props: HomeScreenProps) {
),
[serverConfigs],
);
// Canonical arranged pinned order (reorder-capable threads only) for the
// Move up/down position flags. Computed from all shells, not the rendered
// list, so search/scope filtering never disables or misdirects a move.
const arrangedPinnedKeys = useMemo(() => {
const pinned = sortPinnedThreadsByOrderKey(
props.threads.filter(
(thread) =>
thread.pinnedAt != null &&
thread.archivedAt === null &&
pinReorderEnvironmentIds.has(thread.environmentId),
),
);
return pinned.map((thread) => `${thread.environmentId}:${thread.id}`);
}, [pinReorderEnvironmentIds, props.threads]);
const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
const threadMovePlanners = useMemo(() => {
const sectionPlanner = (section: "pinned" | "active") =>
createThreadMovePlanner({
allThreads: props.threads,
section,
reorderableEnvironmentIds: new Set(
[...serverConfigs].flatMap(([id, config]) =>
(section === "pinned"
? config.environment.capabilities.threadPinReorder
: config.environment.capabilities.threadActiveReorder) === true
? [id]
: [],
),
),
ordered: getThreadListV2OrderedSection({
threads: props.threads,
section,
pendingOrder,
now: new Date().toISOString(),
settlementEnvironmentIds,
snoozeEnvironmentIds,
}),
});
return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") };
}, [
serverConfigs,
props.threads,
pendingOrder,
settlementEnvironmentIds,
snoozeEnvironmentIds,
nowMinute,
snoozeWakeTick,
]);
const threadListV2Layout = useMemo(() => {
if (!threadListV2Enabled)
return {
Expand All @@ -663,6 +694,7 @@ export function HomeScreen(props: HomeScreenProps) {
// Settled threads are live shells; archived threads keep their original
// "hidden from lists" meaning.
return buildThreadListV2Items({
pendingOrder,
threads: props.threads.filter((thread) => thread.archivedAt === null),
environmentId: props.selectedEnvironmentId,
projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs,
Expand All @@ -677,6 +709,7 @@ export function HomeScreen(props: HomeScreenProps) {
selectedThreadKey: null,
});
}, [
pendingOrder,
nowMinute,
snoozeWakeTick,
snoozedShelfExpanded,
Expand Down Expand Up @@ -792,6 +825,8 @@ export function HomeScreen(props: HomeScreenProps) {
);
}
const thread = item.item.thread;
const movePlanner = item.item.pinned ? threadMovePlanners.pinned : threadMovePlanners.active;
const movedId = `${thread.environmentId}:${thread.id}`;
return (
<ThreadListV2Row
thread={thread}
Expand Down Expand Up @@ -838,18 +873,19 @@ export function HomeScreen(props: HomeScreenProps) {
onSettleThread={handleSettleThread}
snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
pinReorderSupported={pinReorderEnvironmentIds.has(thread.environmentId)}
canMovePinnedUp={arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`) > 0}
canMovePinnedDown={(() => {
const index = arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`);
return index !== -1 && index < arrangedPinnedKeys.length - 1;
})()}
reorderSupported={
item.item.pinned
? pinReorderEnvironmentIds.has(thread.environmentId)
: activeReorderEnvironmentIds.has(thread.environmentId)
}
canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null}
canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null}
onSnoozeThread={handleSnoozeThread}
onUnsnoozeThread={handleUnsnoozeThread}
onUnsettleThread={handleUnsettleThread}
onPinThread={handlePinThread}
onUnpinThread={handleUnpinThread}
onMovePinnedThread={handleMovePinnedThread}
onMoveThread={handleMoveThread}
projectCwd={
projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null
}
Expand All @@ -860,8 +896,10 @@ export function HomeScreen(props: HomeScreenProps) {
},
[
handleDeleteThread,
arrangedPinnedKeys,
handleMovePinnedThread,
activeReorderEnvironmentIds,
threadMovePlanners,
pendingOrder,
handleMoveThread,
handlePinThread,
handleRegenerateThreadTitle,
handleSettleThread,
Expand Down
108 changes: 61 additions & 47 deletions apps/mobile/src/features/home/useThreadListActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,14 @@ import { Alert } from "react-native";
import { showConfirmDialog } from "../../components/ConfirmDialogHost";
import { scopedThreadKey } from "../../lib/scopedEntities";
import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots";
import {
pinOrderKeyBetween,
planPinnedMove,
sortPinnedThreadsByOrderKey,
} from "@t3tools/client-runtime/state/thread-sort";
import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort";
import { appAtomRegistry } from "../../state/atom-registry";
import { environmentServerConfigsAtom } from "../../state/server";
import { environmentThreadShells, threadEnvironment } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";
import { beginPendingThreadOrder, getPendingThreadOrder } from "../../state/thread-order";
import { createPendingThreadOrder, createThreadMovePlanner } from "../threads/threadOrder";
import { getThreadListV2OrderedSection } from "../threads/threadListV2";

/** Version skew: never send settle/unsettle to a server that predates them
(capability defaults false on decode for older servers). */
Expand Down Expand Up @@ -222,7 +221,7 @@ export function useThreadListActions(): {
readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
readonly pinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
readonly unpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
readonly movePinnedThread: (
readonly moveThread: (
thread: EnvironmentThreadShell,
direction: "up" | "down",
) => Promise<boolean>;
Expand Down Expand Up @@ -451,60 +450,75 @@ export function useThreadListActions(): {
[updateThreadMetadata],
);

// Move up / Move down for the pinned block. Computed against the CANONICAL
// keyed pinned order (not the rendered list), so the move is valid even
// while search or a project scope filters rows: the same fractional-key
// scheme web dragging uses, one write to one thread per move (plus a
// one-time section materialization when legacy keyless pins are involved).
// Plan against the complete section so filtering does not change a move.
const reorderPinnedMutation = useAtomCommand(threadEnvironment.reorderPin, {
reportFailure: false,
});
// One move at a time: a second tap before the first write's event lands
// would plan from the same stale snapshot and silently collapse two moves
// into one — same double-dispatch guard as snoozeThread.
const movePinnedInFlightRef = useRef(false);
const movePinnedThread = useCallback(
const reorderActiveMutation = useAtomCommand(threadEnvironment.reorderActive, {
reportFailure: false,
});
const moveThread = useCallback(
async (thread: EnvironmentThreadShell, direction: "up" | "down") => {
if (movePinnedInFlightRef.current) return false;
if (!environmentSupportsPinReorder(thread.environmentId)) {
if (getPendingThreadOrder() !== null) return false;
const section = thread.pinnedAt != null ? "pinned" : "active";
const configs = appAtomRegistry.get(environmentServerConfigsAtom);
const supportsReorder = (environmentId: EnvironmentThreadShell["environmentId"]) => {
const capabilities = configs.get(environmentId)?.environment.capabilities;
return section === "pinned"
? capabilities?.threadPinReorder === true
: capabilities?.threadActiveReorder === true;
};
if (!supportsReorder(thread.environmentId)) {
Alert.alert(
"Could not move thread",
"This environment's server does not support pinned reordering yet. Update the server to reorder pins.",
"This environment's server does not support reordering these threads. Update the server to arrange them.",
);
return false;
}
const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom);
const pinned = sortPinnedThreadsByOrderKey(
shells.filter(
(shell) =>
shell.pinnedAt != null &&
shell.archivedAt === null &&
environmentSupportsPinReorder(shell.environmentId),
const ordered = getThreadListV2OrderedSection({
threads: shells,
section,
now: new Date().toISOString(),
settlementEnvironmentIds: new Set(
[...configs].flatMap(([id, config]) =>
config.environment.capabilities.threadSettlement === true ? [id] : [],
),
),
);
const orderedIds = pinned.map((shell) => scopedThreadKey(shell.environmentId, shell.id));
const assignments = planPinnedMove({
orderedIds,
keysById: new Map(
pinned.map((shell) => [
scopedThreadKey(shell.environmentId, shell.id),
shell.pinOrderKey ?? null,
]),
snoozeEnvironmentIds: new Set(
[...configs].flatMap(([id, config]) =>
config.environment.capabilities.threadSnooze === true ? [id] : [],
),
),
movedId: scopedThreadKey(thread.environmentId, thread.id),
direction,
});
if (assignments === null || assignments.length === 0) return false;
const assignments = createThreadMovePlanner({
allThreads: shells,
ordered,
section,
reorderableEnvironmentIds: new Set([...configs.keys()].filter(supportsReorder)),
})(scopedThreadKey(thread.environmentId, thread.id), direction);
if (assignments === null) return false;
const shellByKey = new Map(
pinned.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]),
ordered.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]),
);
selectionHaptic();
movePinnedInFlightRef.current = true;
const pending = beginPendingThreadOrder(
createPendingThreadOrder({
section,
ordered,
movedId: scopedThreadKey(thread.environmentId, thread.id),
direction,
assignments,
}),
);
let succeeded = false;
const reorder = section === "pinned" ? reorderPinnedMutation : reorderActiveMutation;
try {
for (const assignment of assignments) {
if (!pending.isPending()) return false;
const target = shellByKey.get(assignment.id);
if (target === undefined) continue;
const result = await reorderPinnedMutation({
const result = await reorder({
environmentId: target.environmentId,
input: { threadId: target.id, orderKey: assignment.orderKey },
});
Expand All @@ -514,20 +528,20 @@ export function useThreadListActions(): {
"Could not move thread",
error instanceof Error && error.message.trim().length > 0
? error.message
: "The pinned thread could not be moved.",
: "The thread could not be moved.",
);
// No rollback: keys already written are valid orderings on their
// own (each write is a complete, consistent placement), so a
// partial materialization leaves the list sensible, not corrupt.
// Keep confirmed keys when a later environment rejects its write.
return false;
}
}
succeeded = true;
pending.complete();
return true;
} finally {
movePinnedInFlightRef.current = false;
if (!succeeded) pending.cancel();
}
},
[reorderPinnedMutation],
[reorderActiveMutation, reorderPinnedMutation],
);

const confirmDeleteThread = useConfirmDeleteThread(executeAction);
Expand All @@ -541,7 +555,7 @@ export function useThreadListActions(): {
unsettleThread,
pinThread,
unpinThread,
movePinnedThread,
moveThread,
regenerateThreadTitle,
};
}
Expand Down
Loading
Loading