Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 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