Skip to content
Closed
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,
reorderPinnedThread,
unsettleThread,
} = useThreadListActions();
const pendingTasks = usePendingNewTasks();
Expand Down Expand Up @@ -159,6 +160,7 @@ export function HomeRouteScreen() {
onUnsettleThread={unsettleThread}
onPinThread={pinThread}
onUnpinThread={unpinThread}
onReorderPinnedThread={reorderPinnedThread}
onEnvironmentChange={setSelectedEnvironmentId}
onProjectChange={setSelectedProjectKey}
onOpenEnvironments={() =>
Expand Down
134 changes: 132 additions & 2 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,20 @@ import {
threadSearchMatchKey,
type EnvironmentThreadSearchMatch,
} from "@t3tools/client-runtime/state/thread-search";
import {
pinnedThreadOrderUpdatesForMove,
sortPinnedThreads,
} from "@t3tools/client-runtime/state/thread-sort";
import type {
EnvironmentId,
PinnedThreadOrder,
SidebarProjectGroupingMode,
SidebarThreadSortOrder,
} from "@t3tools/contracts";
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityIndicator, FlatList, Platform, Pressable, View } from "react-native";
import { ActivityIndicator, Alert, FlatList, Platform, Pressable, View } from "react-native";
import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useThemeColor } from "../../lib/useThemeColor";
Expand All @@ -28,7 +33,7 @@ import { AppText as Text } from "../../components/AppText";
import { EmptyState } from "../../components/EmptyState";
import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel";
import type { SavedRemoteConnection } from "../../lib/connection";
import { scopedProjectKey } from "../../lib/scopedEntities";
import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
import { useThreadSearch } from "../../state/queries";
Expand Down Expand Up @@ -113,6 +118,10 @@ interface HomeScreenProps {
readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
readonly onPinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
readonly onReorderPinnedThread: (
thread: EnvironmentThreadShell,
pinnedOrder: PinnedThreadOrder,
) => Promise<boolean>;
readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
readonly onNewThreadInProject: (project: EnvironmentProject) => void;
Expand Down Expand Up @@ -598,6 +607,15 @@ export function HomeScreen(props: HomeScreenProps) {
}
return supported;
}, [serverConfigs]);
const pinReorderingEnvironmentIds = useMemo(() => {
const supported = new Set<EnvironmentId>();
for (const [environmentId, config] of serverConfigs) {
if (config.environment.capabilities.threadPinReordering === true) {
supported.add(environmentId);
}
}
return supported;
}, [serverConfigs]);
const threadListV2Layout = useMemo(() => {
if (!threadListV2Enabled)
return {
Expand Down Expand Up @@ -643,6 +661,98 @@ export function HomeScreen(props: HomeScreenProps) {
threadListV2Enabled,
v2ScopedProjectGroup,
]);
const orderedPinnedThreads = useMemo(
() =>
sortPinnedThreads(
props.threads.filter((thread) => thread.archivedAt === null && thread.pinnedAt !== null),
),
[props.threads],
Comment thread
cursor[bot] marked this conversation as resolved.
);
const pinnedOrderingFullyVisible = useMemo(() => {
const visible = threadListV2Layout.items
.filter((item) => item.pinned)
.map((item) => scopedThreadKey(item.thread.environmentId, item.thread.id));
return (
visible.length === orderedPinnedThreads.length &&
visible.every(
(threadKey, index) =>
threadKey ===
scopedThreadKey(
orderedPinnedThreads[index]!.environmentId,
orderedPinnedThreads[index]!.id,
),
)
);
}, [orderedPinnedThreads, threadListV2Layout.items]);
const pinnedReorderInFlightRef = useRef(false);
const [pinnedReorderInFlight, setPinnedReorderInFlight] = useState(false);
const handleMovePinnedThread = useCallback(
(thread: EnvironmentThreadShell, direction: "up" | "down") => {
if (pinnedReorderInFlightRef.current || !pinnedOrderingFullyVisible) return;
const index = orderedPinnedThreads.findIndex(
(candidate) =>
candidate.environmentId === thread.environmentId && candidate.id === thread.id,
);
const target = orderedPinnedThreads[index + (direction === "up" ? -1 : 1)];
if (index < 0 || !target) return;
const updates = pinnedThreadOrderUpdatesForMove(
orderedPinnedThreads,
scopedThreadKey(thread.environmentId, thread.id),
scopedThreadKey(target.environmentId, target.id),
);
if (updates === null) return;
const requests = updates.flatMap((update) => {
Comment thread
f-trycua marked this conversation as resolved.
const updateThread = orderedPinnedThreads.find(
(candidate) => scopedThreadKey(candidate.environmentId, candidate.id) === update.threadId,
);
return updateThread ? [{ update, thread: updateThread }] : [];
});
if (requests.length !== updates.length) return;
if (
requests.length > 1 &&
requests.some((request) => !pinReorderingEnvironmentIds.has(request.thread.environmentId))
) {
Alert.alert(
"Could not reorder pinned thread",
"Pinned ordering needs compacting. Update all connected servers before trying again.",
);
return;
}
pinnedReorderInFlightRef.current = true;
setPinnedReorderInFlight(true);
void (async () => {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
try {
const completed: typeof requests = [];
for (const request of requests) {
const succeeded = await props.onReorderPinnedThread(
request.thread,
request.update.pinnedOrder,
);
if (!succeeded) {
for (let index = completed.length - 1; index >= 0; index -= 1) {
const applied = completed[index]!;
await props.onReorderPinnedThread(
applied.thread,
applied.update.previousPinnedOrder,
);
}
return;
}
completed.push(request);
}
} finally {
pinnedReorderInFlightRef.current = false;
setPinnedReorderInFlight(false);
}
})();
},
[
orderedPinnedThreads,
pinnedOrderingFullyVisible,
pinReorderingEnvironmentIds,
props.onReorderPinnedThread,
],
);
// Re-partition the moment the earliest snooze expires (clamped to the
// signed-32-bit setTimeout range; far-future wakes re-arm at the clamp).
const nextSnoozeWakeAt = threadListV2Layout.nextSnoozeWakeAt;
Expand Down Expand Up @@ -741,6 +851,12 @@ export function HomeScreen(props: HomeScreenProps) {
);
}
const thread = item.item.thread;
const pinnedIndex = item.item.pinned
? orderedPinnedThreads.findIndex(
(candidate) =>
candidate.environmentId === thread.environmentId && candidate.id === thread.id,
)
: -1;
return (
<ThreadListV2Row
thread={thread}
Expand Down Expand Up @@ -784,11 +900,20 @@ export function HomeScreen(props: HomeScreenProps) {
onSettleThread={handleSettleThread}
snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
pinReorderingSupported={pinReorderingEnvironmentIds.has(thread.environmentId)}
canMovePinnedUp={pinnedOrderingFullyVisible && !pinnedReorderInFlight && pinnedIndex > 0}
canMovePinnedDown={
pinnedOrderingFullyVisible &&
!pinnedReorderInFlight &&
pinnedIndex >= 0 &&
pinnedIndex < orderedPinnedThreads.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 @@ -802,6 +927,7 @@ export function HomeScreen(props: HomeScreenProps) {
handleChangeRequestState,
handleDeleteThread,
handlePinThread,
handleMovePinnedThread,
handleSettleThread,
handleSnoozeThread,
handleUnpinThread,
Expand All @@ -810,6 +936,10 @@ export function HomeScreen(props: HomeScreenProps) {
handleSwipeableWillOpen,
handleUnsettleThread,
pinningEnvironmentIds,
pinReorderingEnvironmentIds,
orderedPinnedThreads,
pinnedOrderingFullyVisible,
pinnedReorderInFlight,
projectByKey,
projectCwdByKey,
props.onArchiveThread,
Expand Down
44 changes: 44 additions & 0 deletions apps/mobile/src/features/home/useThreadListActions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import type { PinnedThreadOrder } from "@t3tools/contracts";
import { canSettle, canSnooze } from "@t3tools/client-runtime/state/thread-settled";
import * as Cause from "effect/Cause";
import * as Haptics from "expo-haptics";
Expand Down Expand Up @@ -36,6 +37,13 @@ function environmentSupportsPinning(environmentId: EnvironmentThreadShell["envir
);
}

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

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

const ACTION_VERBS: Record<ThreadListAction, string> = {
Expand Down Expand Up @@ -211,12 +219,19 @@ export function useThreadListActions(): {
readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
readonly pinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
readonly unpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
readonly reorderPinnedThread: (
thread: EnvironmentThreadShell,
pinnedOrder: PinnedThreadOrder,
) => Promise<boolean>;
} {
const executeAction = useThreadActionExecutor();
const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false });
const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false });
const pinMutation = useAtomCommand(threadEnvironment.pin, { reportFailure: false });
const unpinMutation = useAtomCommand(threadEnvironment.unpin, { reportFailure: false });
const reorderPinnedMutation = useAtomCommand(threadEnvironment.reorderPinned, {
reportFailure: false,
});
const snoozeInFlightThreadKeys = useRef(new Set<string>());

const archiveThread = useCallback(
Expand Down Expand Up @@ -377,6 +392,34 @@ export function useThreadListActions(): {
},
[unpinMutation],
);
const reorderPinnedThread = useCallback(
async (thread: EnvironmentThreadShell, pinnedOrder: PinnedThreadOrder) => {
if (!environmentSupportsPinReordering(thread.environmentId)) {
Alert.alert(
"Could not reorder pinned thread",
"This environment's server does not support pinned ordering yet. Update the server to reorder pins.",
);
return false;
}
selectionHaptic();
const result = await reorderPinnedMutation({
environmentId: thread.environmentId,
input: { threadId: thread.id, pinnedOrder },
});
if (result._tag === "Failure") {
const error = Cause.squash(result.cause);
Alert.alert(
"Could not reorder pinned thread",
error instanceof Error && error.message.trim().length > 0
? error.message
: "The pinned thread could not be reordered.",
);
return false;
}
return true;
},
[reorderPinnedMutation],
);

const confirmDeleteThread = useConfirmDeleteThread(executeAction);

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

Expand Down
Loading
Loading