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 apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export function HomeRouteScreen() {
unsnoozeThread,
pinThread,
unpinThread,
movePinnedThread,
unsettleThread,
} = useThreadListActions();
const pendingTasks = usePendingNewTasks();
Expand Down Expand Up @@ -159,6 +160,7 @@ export function HomeRouteScreen() {
onUnsettleThread={unsettleThread}
onPinThread={pinThread}
onUnpinThread={unpinThread}
onMovePinnedThread={movePinnedThread}
onEnvironmentChange={setSelectedEnvironmentId}
onProjectChange={setSelectedProjectKey}
onOpenEnvironments={() =>
Expand Down
44 changes: 44 additions & 0 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ 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 Down Expand Up @@ -113,6 +114,10 @@ interface HomeScreenProps {
readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
readonly onPinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
readonly onMovePinnedThread: (
thread: EnvironmentThreadShell,
direction: "up" | "down",
) => Promise<boolean>;
readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
readonly onNewThreadInProject: (project: EnvironmentProject) => void;
Expand Down Expand Up @@ -524,6 +529,12 @@ export function HomeScreen(props: HomeScreenProps) {
},
[props.onPinThread],
);
const handleMovePinnedThread = useCallback(
(thread: EnvironmentThreadShell, direction: "up" | "down") => {
void props.onMovePinnedThread(thread, direction);
},
[props.onMovePinnedThread],
);
const handleUnpinThread = useCallback(
(thread: EnvironmentThreadShell) => {
void props.onUnpinThread(thread);
Expand Down Expand Up @@ -598,6 +609,29 @@ export function HomeScreen(props: HomeScreenProps) {
}
return supported;
}, [serverConfigs]);
const pinReorderEnvironmentIds = useMemo(() => {
const supported = new Set<EnvironmentId>();
for (const [environmentId, config] of serverConfigs) {
if (config.environment.capabilities.threadPinReorder === true) {
supported.add(environmentId);
}
}
return supported;
}, [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 threadListV2Layout = useMemo(() => {
if (!threadListV2Enabled)
return {
Expand Down Expand Up @@ -784,11 +818,18 @@ 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;
})()}
onSnoozeThread={handleSnoozeThread}
onUnsnoozeThread={handleUnsnoozeThread}
onUnsettleThread={handleUnsettleThread}
onPinThread={handlePinThread}
onUnpinThread={handleUnpinThread}
onMovePinnedThread={handleMovePinnedThread}
onChangeRequestState={handleChangeRequestState}
projectCwd={
projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null
Expand All @@ -801,6 +842,8 @@ export function HomeScreen(props: HomeScreenProps) {
[
handleChangeRequestState,
handleDeleteThread,
arrangedPinnedKeys,
handleMovePinnedThread,
handlePinThread,
handleSettleThread,
handleSnoozeThread,
Expand All @@ -810,6 +853,7 @@ export function HomeScreen(props: HomeScreenProps) {
handleSwipeableWillOpen,
handleUnsettleThread,
pinningEnvironmentIds,
pinReorderEnvironmentIds,
projectByKey,
projectCwdByKey,
props.onArchiveThread,
Expand Down
112 changes: 110 additions & 2 deletions apps/mobile/src/features/home/useThreadListActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +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 { appAtomRegistry } from "../../state/atom-registry";
import { environmentServerConfigsAtom } from "../../state/server";
import { threadEnvironment } from "../../state/threads";
import { environmentThreadShells, threadEnvironment } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";

/** Version skew: never send settle/unsettle to a server that predates them
Expand All @@ -36,6 +41,13 @@ function environmentSupportsPinning(environmentId: EnvironmentThreadShell["envir
);
}

function environmentSupportsPinReorder(environmentId: EnvironmentThreadShell["environmentId"]) {
return (
appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
.threadPinReorder === true
);
}

type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";

const ACTION_VERBS: Record<ThreadListAction, string> = {
Expand Down Expand Up @@ -211,6 +223,10 @@ export function useThreadListActions(): {
readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
readonly pinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
readonly unpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
readonly movePinnedThread: (
thread: EnvironmentThreadShell,
direction: "up" | "down",
) => Promise<boolean>;
} {
const executeAction = useThreadActionExecutor();
const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false });
Expand Down Expand Up @@ -331,9 +347,21 @@ export function useThreadListActions(): {
return false;
}
selectionHaptic();
// Same placement as web: a fresh pin takes the top of the arranged
// run. Servers that predate reordering get the bare pin (keyless).
let orderKey: string | undefined;
if (environmentSupportsPinReorder(thread.environmentId)) {
const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom);
let firstKey: string | null = null;
for (const shell of shells) {
if (shell.pinnedAt == null || shell.pinOrderKey == null) continue;
if (firstKey === null || shell.pinOrderKey < firstKey) firstKey = shell.pinOrderKey;
}
orderKey = pinOrderKeyBetween(null, firstKey) ?? undefined;
}
const result = await pinMutation({
environmentId: thread.environmentId,
input: { threadId: thread.id },
input: { threadId: thread.id, ...(orderKey !== undefined ? { orderKey } : {}) },
});
if (result._tag === "Failure") {
const error = Cause.squash(result.cause);
Expand Down Expand Up @@ -378,6 +406,85 @@ export function useThreadListActions(): {
[unpinMutation],
);

// 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).
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(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
async (thread: EnvironmentThreadShell, direction: "up" | "down") => {
if (movePinnedInFlightRef.current) return false;
if (!environmentSupportsPinReorder(thread.environmentId)) {
Alert.alert(
"Could not move thread",
"This environment's server does not support pinned reordering yet. Update the server to reorder pins.",
);
return false;
}
const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom);
const pinned = sortPinnedThreadsByOrderKey(
shells.filter(
(shell) =>
shell.pinnedAt != null &&
shell.archivedAt === null &&
environmentSupportsPinReorder(shell.environmentId),
),
);
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,
]),
),
movedId: scopedThreadKey(thread.environmentId, thread.id),
direction,
});
if (assignments === null || assignments.length === 0) return false;
const shellByKey = new Map(
pinned.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]),
);
selectionHaptic();
movePinnedInFlightRef.current = true;
try {
for (const assignment of assignments) {
const target = shellByKey.get(assignment.id);
if (target === undefined) continue;
const result = await reorderPinnedMutation({
environmentId: target.environmentId,
input: { threadId: target.id, orderKey: assignment.orderKey },
});
if (result._tag === "Failure") {
const error = Cause.squash(result.cause);
Alert.alert(
"Could not move thread",
error instanceof Error && error.message.trim().length > 0
? error.message
: "The pinned 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.
return false;
}
}
return true;
} finally {
movePinnedInFlightRef.current = false;
}
},
[reorderPinnedMutation],
);

const confirmDeleteThread = useConfirmDeleteThread(executeAction);

return {
Expand All @@ -389,6 +496,7 @@ export function useThreadListActions(): {
unsettleThread,
pinThread,
unpinThread,
movePinnedThread,
};
}

Expand Down
36 changes: 36 additions & 0 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { LegendList } from "@legendapp/list/react-native";
import type { MenuAction } from "@react-native-menu/menu";
import { useAtomValue } from "@effect/atom-react";
import type { EnvironmentId } from "@t3tools/contracts";
import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";
import { Platform, Pressable, StyleSheet, TextInput, View, useColorScheme } from "react-native";
Expand Down Expand Up @@ -207,6 +208,7 @@ function ThreadNavigationSidebarPane(
unsettleThread,
pinThread,
unpinThread,
movePinnedThread,
} = useThreadListActions();
const threadListV2Enabled = useThreadListV2Enabled();
const pendingTasks = usePendingNewTasks();
Expand Down Expand Up @@ -492,6 +494,28 @@ function ThreadNavigationSidebarPane(
}
return supported;
}, [serverConfigs]);
const pinReorderEnvironmentIds = useMemo(() => {
const supported = new Set<EnvironmentId>();
for (const [environmentId, config] of serverConfigs) {
if (config.environment.capabilities.threadPinReorder === true) {
supported.add(environmentId);
}
}
return supported;
}, [serverConfigs]);
// Canonical arranged pinned order for Move up/down flags — computed from
// all shells so search/scope filtering never disables a valid move.
const arrangedPinnedKeys = useMemo(() => {
const pinned = sortPinnedThreadsByOrderKey(
threads.filter(
(thread) =>
thread.pinnedAt != null &&
thread.archivedAt === null &&
pinReorderEnvironmentIds.has(thread.environmentId),
),
);
return pinned.map((thread) => `${thread.environmentId}:${thread.id}`);
}, [pinReorderEnvironmentIds, threads]);
const threadListV2Layout = useMemo(() => {
if (!threadListV2Enabled)
return {
Expand Down Expand Up @@ -929,11 +953,20 @@ function ThreadNavigationSidebarPane(
onSettleThread={settleThread}
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;
})()}
onSnoozeThread={snoozeThread}
onUnsnoozeThread={unsnoozeThread}
onUnsettleThread={unsettleThread}
onPinThread={pinThread}
onUnpinThread={unpinThread}
onMovePinnedThread={movePinnedThread}
onChangeRequestState={handleChangeRequestState}
projectCwd={projectCwdByKey.get(scopeKey) ?? null}
onSwipeableClose={handleSwipeableClose}
Expand Down Expand Up @@ -1054,13 +1087,16 @@ function ThreadNavigationSidebarPane(
},
[
archiveThread,
arrangedPinnedKeys,
confirmDeletePendingTask,
confirmDeleteThread,
handleChangeRequestState,
handleSelectThread,
handleSwipeableClose,
handleSwipeableWillOpen,
movePinnedThread,
openPendingTask,
pinReorderEnvironmentIds,
pinThread,
pinningEnvironmentIds,
projectByKey,
Expand Down
Loading
Loading