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
5 changes: 5 additions & 0 deletions apps/mobile/src/components/ControlPill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,11 @@ export function ControlPillMenu(
child.props.onPress?.(event);
},
});
menuProps.onDragBegin = () => {
menuPress.current.suppressPress = true;
pendingPress.current = null;
props.onDragBegin?.();
};
menuProps.onMenuInteractionStart = () => {
menuPress.current.isPreparing = true;
props.onMenuInteractionStart?.();
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
ThreadListShowMoreRow,
} from "../threads/thread-list-items";
import {
ThreadListV2DropHeader,
ThreadListV2PendingRow,
ThreadListV2Row,
ThreadListV2SettledShelfHeader,
Expand Down Expand Up @@ -762,6 +763,7 @@ export function HomeScreen(props: HomeScreenProps) {
const threadListV2Items = useMemo(
() =>
buildThreadListV2ListItems({
arrangementTargets: Platform.OS === "ios",
items: threadListV2Layout.items,
pendingTasks: v2PendingTasks,
snoozedCount: threadListV2Layout.snoozedCount,
Expand All @@ -781,6 +783,7 @@ export function HomeScreen(props: HomeScreenProps) {
const showTrailingDivider =
nextItem?.type === "v2-thread" ||
(nextItem?.type === "v2-pending" && !nextItem.showPendingDivider);
if (item.type === "v2-drop-header") return <ThreadListV2DropHeader section={item.section} />;
if (item.type === "v2-pending") {
const pendingScopeKey = scopedProjectKey(
item.pendingTask.environmentId,
Expand Down Expand Up @@ -873,6 +876,7 @@ export function HomeScreen(props: HomeScreenProps) {
? pinReorderEnvironmentIds.has(thread.environmentId)
: activeReorderEnvironmentIds.has(thread.environmentId)
}
reorderBusy={pendingOrder !== null}
canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null}
canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null}
onSnoozeThread={handleSnoozeThread}
Expand Down
9 changes: 8 additions & 1 deletion apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import {
ThreadListShowMoreRow,
} from "./thread-list-items";
import {
ThreadListV2DropHeader,
ThreadListV2PendingRow,
ThreadListV2Row,
ThreadListV2SettledShelfHeader,
Expand Down Expand Up @@ -594,6 +595,7 @@ function ThreadNavigationSidebarPane(
pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)),
);
const items: SidebarListItem[] = buildThreadListV2ListItems({
arrangementTargets: Platform.OS === "ios",
items: threadListV2Layout.items,
pendingTasks: v2PendingTasks,
snoozedCount: threadListV2Layout.snoozedCount,
Expand Down Expand Up @@ -833,12 +835,14 @@ function ThreadNavigationSidebarPane(
previous.type === "v2-show-more" ||
previous.type === "v2-pending" ||
previous.type === "v2-snoozed-shelf" ||
previous.type === "v2-drop-header" ||
previous.type === "v2-settled-shelf" ||
item.type === "v2-thread" ||
item.type === "v2-show-more" ||
item.type === "v2-pending" ||
item.type === "v2-snoozed-shelf" ||
item.type === "v2-settled-shelf"
item.type === "v2-settled-shelf" ||
item.type === "v2-drop-header"
) {
return false;
}
Expand Down Expand Up @@ -866,6 +870,8 @@ function ThreadNavigationSidebarPane(
const renderListItem = useCallback(
({ item }: { readonly item: SidebarListItem }) => {
switch (item.type) {
case "v2-drop-header":
return <ThreadListV2DropHeader section={item.section} />;
case "v2-pending": {
const pendingScopeKey = scopedProjectKey(
item.pendingTask.environmentId,
Expand Down Expand Up @@ -941,6 +947,7 @@ function ThreadNavigationSidebarPane(
? pinReorderEnvironmentIds.has(thread.environmentId)
: activeReorderEnvironmentIds.has(thread.environmentId)
}
reorderBusy={pendingOrder !== null}
canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null}
canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null}
onSnoozeThread={snoozeThread}
Expand Down
147 changes: 119 additions & 28 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import type { ReactNode } from "react";
import { useThreadListActions } from "../home/useThreadListActions";
import { useAtomValue } from "@effect/atom-react";
import { threadDropBusyAtom } from "../../state/thread-order";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { appAtomRegistry } from "../../state/atom-registry";
import { threadArrangementOpenAtom } from "../../state/thread-order";
import type { ThreadMoveDestination } from "./threadOrder";
import { environmentThreadShells } from "../../state/threads";
import { scopedThreadKey } from "../../lib/scopedEntities";
import { threadDragAction, type ThreadMoveDestination } from "./threadOrder";
import type {
EnvironmentProject,
EnvironmentThreadShell,
Expand Down Expand Up @@ -158,33 +164,37 @@ export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledS
readonly pane?: "screen" | "sidebar";
}) {
return (
<Pressable
accessibilityHint={
props.expanded ? "Collapses the settled threads." : "Expands the settled threads."
}
accessibilityLabel={props.count === 1 ? "1 settled thread" : `${props.count} settled threads`}
accessibilityRole="button"
accessibilityState={{ disabled: props.disabled, expanded: props.expanded }}
className={cn(
"mb-1.5 mt-4 flex-row items-center gap-2.5",
props.pane === "sidebar" ? "px-3" : "px-5",
)}
disabled={props.disabled}
onPress={props.onToggle}
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
>
<Text className="text-xs font-t3-medium text-foreground-tertiary">
{props.expanded ? "Settled" : `Settled (${props.count})`}
</Text>
<View className="h-px flex-1 bg-border" />
<SymbolView
name="chevron.down"
size={10}
tintColorClassName={"accent-foreground-muted"}
type="monochrome"
style={{ transform: [{ rotate: props.expanded ? "180deg" : "0deg" }] }}
/>
</Pressable>
<ThreadListV2DropHeader section="settled">
<Pressable
accessibilityHint={
props.expanded ? "Collapses the settled threads." : "Expands the settled threads."
}
accessibilityLabel={
props.count === 1 ? "1 settled thread" : `${props.count} settled threads`
}
accessibilityRole="button"
accessibilityState={{ disabled: props.disabled, expanded: props.expanded }}
className={cn(
"mb-1.5 mt-4 flex-row items-center gap-2.5",
props.pane === "sidebar" ? "px-3" : "px-5",
)}
disabled={props.disabled}
onPress={props.onToggle}
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
>
<Text className="text-xs font-t3-medium text-foreground-tertiary">
{props.expanded ? "Settled" : `Settled (${props.count})`}
</Text>
<View className="h-px flex-1 bg-border" />
<SymbolView
name="chevron.down"
size={10}
tintColorClassName={"accent-foreground-muted"}
type="monochrome"
style={{ transform: [{ rotate: props.expanded ? "180deg" : "0deg" }] }}
/>
</Pressable>
</ThreadListV2DropHeader>
);
});

Expand Down Expand Up @@ -398,6 +408,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
readonly titleRegenerationSupported: boolean;
/** Server supports reordering this card's section. */
readonly reorderSupported?: boolean;
readonly reorderBusy?: boolean;
readonly onMoveThread?: (
thread: EnvironmentThreadShell,
direction: ThreadMoveDestination,
Expand Down Expand Up @@ -431,6 +442,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
onUnpinThread,
onMoveThread,
} = props;
const dropBusy = useAtomValue(threadDropBusyAtom);
const snoozedRow = props.snoozed === true;
const pinnedRow = props.pinned === true;

Expand Down Expand Up @@ -1079,6 +1091,45 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
? snoozableCardMenuActions
: cardMenuActions),
]}
dragItemId={
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Platform.OS === "ios" ? scopedThreadKey(thread.environmentId, thread.id) : ""
}
dragEnabled={props.reorderSupported === true && props.reorderBusy !== true && !dropBusy}
dropEnabled={
variant === "card" && !snoozedRow && props.reorderBusy !== true && !dropBusy
}
dragGroup="t3-thread-arrange"
dropSection={pinnedRow ? "pinned" : "active"}
dragActionLabels={JSON.stringify(
Object.fromEntries(
(["pinned", "active", "settled"] as const).flatMap((destination) => {
if (destination === "settled" && !props.settlementSupported) return [];
const action = threadDragAction(
pinnedRow
? "pinned"
: snoozedRow
? "snoozed"
: variant === "slim"
? "settled"
: "active",
destination,
);
return action === null ? [] : [[destination, action]];
}),
),
)}
onItemDrop={({ nativeEvent }) => {
if (nativeEvent.placement !== "before" && nativeEvent.placement !== "after") return;
const source = appAtomRegistry
.get(environmentThreadShells.threadShellsAtom)
.find((row) => scopedThreadKey(row.environmentId, row.id) === nativeEvent.itemId);
if (!source || snoozedRow || variant !== "card") return;
onMoveThread?.(source, {
targetId: scopedThreadKey(thread.environmentId, thread.id),
section: pinnedRow ? "pinned" : "active",
placement: nativeEvent.placement,
});
}}
onPressAction={handleMenuAction}
shouldOpenOnLongPress
>
Expand All @@ -1089,3 +1140,43 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
</>
);
});

/** Always present on iOS so an empty section is still a reachable drop target. */
export function ThreadListV2DropHeader(props: {
section: "pinned" | "active" | "settled";
children?: ReactNode;
}) {
const { moveThread } = useThreadListActions();
const busy = useAtomValue(threadDropBusyAtom);
if (Platform.OS !== "ios") return props.children ?? null;
return (
<ControlPillMenu
actions={[]}
shouldOpenOnLongPress
dragEnabled={false}
dropEnabled={!busy}
dragItemId={`t3-drop-section:${props.section}`}
dragGroup="t3-thread-arrange"
dropSection={props.section}
onItemDrop={({ nativeEvent }) => {
const source = appAtomRegistry
.get(environmentThreadShells.threadShellsAtom)
.find((row) => scopedThreadKey(row.environmentId, row.id) === nativeEvent.itemId);
if (source)
void moveThread(source, { section: props.section, targetId: null, placement: "before" });
}}
>
{props.children ?? (
<View style={{ minHeight: 44, justifyContent: "center", paddingHorizontal: 20 }}>
<Text className="text-xs font-t3-medium text-foreground-muted">
{props.section === "pinned"
? "Pinned"
: props.section === "active"
? "Active"
: "Settled"}
</Text>
</View>
)}
</ControlPillMenu>
);
}
10 changes: 10 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1477,3 +1477,13 @@ describe("cross-section thread drops", () => {
).toEqual({ pin: false, unpin: false, unsettle: false, unsnooze: false });
});
});

it("keeps native drop targets available when Pinned, Active, and Settled are empty", () => {
expect(
buildThreadListV2ListItems({ items: [], pendingTasks: [], arrangementTargets: true }),
).toEqual([
{ type: "v2-drop-header", key: "v2-pinned-drop", section: "pinned" },
{ type: "v2-drop-header", key: "v2-active-drop", section: "active" },
{ type: "v2-drop-header", key: "v2-settled-drop", section: "settled" },
]);
});
21 changes: 20 additions & 1 deletion apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,11 @@ export interface ThreadListV2SettledShelfListItem {
}

export type ThreadListV2ListItem =
| {
readonly type: "v2-drop-header";
readonly key: string;
readonly section: "pinned" | "active" | "settled";
}
| ThreadListV2ThreadListItem
| ThreadListV2PendingListItem
| ThreadListV2SnoozedShelfListItem
Expand All @@ -279,6 +284,7 @@ export type ThreadListV2ListItem =
* reachable without competing with either the inbox or settled history.
*/
export function buildThreadListV2ListItems(input: {
readonly arrangementTargets?: boolean;
readonly items: ReadonlyArray<ThreadListV2Item>;
readonly pendingTasks: ReadonlyArray<PendingNewTask>;
readonly snoozedCount?: number;
Expand Down Expand Up @@ -310,7 +316,18 @@ export function buildThreadListV2ListItems(input: {
const settledShelfHeaderIndex = input.settledShelfHeaderIndex ?? null;
const activeEnd = snoozedShelfHeaderIndex ?? settledShelfHeaderIndex ?? threadItems.length;
const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length;
const result: ThreadListV2ListItem[] = [...threadItems.slice(0, activeEnd), ...pendingItems];
const live = threadItems.slice(0, activeEnd);
const firstActive = live.findIndex((item) => item.type === "v2-thread" && !item.item.pinned);
const split = firstActive === -1 ? live.length : firstActive;
const result: ThreadListV2ListItem[] = input.arrangementTargets
? [
{ type: "v2-drop-header", key: "v2-pinned-drop", section: "pinned" },
...live.slice(0, split),
{ type: "v2-drop-header", key: "v2-active-drop", section: "active" },
...live.slice(split),
...pendingItems,
]
: [...live, ...pendingItems];
if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) {
result.push({
type: "v2-snoozed-shelf",
Expand All @@ -328,6 +345,8 @@ export function buildThreadListV2ListItems(input: {
expanded: input.settledShelfExpanded !== false,
});
result.push(...threadItems.slice(settledShelfHeaderIndex));
} else if (input.arrangementTargets) {
result.push({ type: "v2-drop-header", key: "v2-settled-drop", section: "settled" });
}
return result;
}
Expand Down
7 changes: 5 additions & 2 deletions docs/user/thread-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,11 @@ spot where the thread will land. Drops into either section keep the position you
mobile, open a thread's menu and choose **Arrange threads**. Drag a handle within or between
**Pinned** and **Active** to reorder, pin, or unpin. Drop onto the **Settled** divider to
settle a thread. The dragged card shows the action before you release it. Expand **Snoozed**
or **Settled** to drag a parked thread back into either live section. Each drop saves; **Done** returns to the thread list.
**Move up** and **Move down** are also available in the thread menu. The server
or **Settled** to drag a parked thread back into either live section. Each drop saves;
**Done** returns to the thread list. **Move up** and **Move down** are also available in the
thread menu. On iOS, hold a thread and move your finger to drag it directly, including after
its context menu appears. Drop into **Pinned** or **Active** to pin, unpin, wake, or
un-settle it, or onto the **Settled** divider to settle it. The server
saves the order, so it survives a refresh and appears on your other connected devices.

On web and desktop, the list also animates section changes made with thread actions such as
Expand Down
Loading
Loading