Skip to content

Commit d18f96a

Browse files
committed
fix: Working shelf does not hide a group's live threads; filtered children keep Remove from group
1 parent 99228ca commit d18f96a

5 files changed

Lines changed: 74 additions & 27 deletions

File tree

‎apps/mobile/src/features/threads/ThreadArrangementSheet.tsx‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ import {
2727
threadDragAction,
2828
type ThreadMoveDestination,
2929
} from "./threadOrder";
30-
import { getThreadListV2OrderedSection, threadListInboxReturns } from "./threadListV2";
30+
import {
31+
getThreadListV2LiveGroupThreads,
32+
getThreadListV2OrderedSection,
33+
threadListInboxReturns,
34+
} from "./threadListV2";
3135
import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
3236

3337
const ROW_HEIGHT = 56;
@@ -193,12 +197,14 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) {
193197
const pinned = getThreadListV2OrderedSection({ ...shared, section: "pinned" });
194198
const active = getThreadListV2OrderedSection({ ...shared, section: "active" });
195199
const visible = new Set([...pinned, ...active].map(keyOf));
196-
// Live threads grouped under another one move with their group, so only
197-
// snoozed and settled threads fill the parked lists.
200+
// Threads in a live group move with it, so only snoozed and settled
201+
// threads outside such groups fill the parked lists.
202+
const liveGrouped = getThreadListV2LiveGroupThreads(shared);
198203
const parked = threads.filter(
199204
(thread) =>
200205
thread.archivedAt === null &&
201206
!visible.has(keyOf(thread)) &&
207+
!liveGrouped.has(thread) &&
202208
(thread.settledOverride === "settled" || effectiveSnoozed(thread, { now })),
203209
);
204210
return {

‎apps/mobile/src/features/threads/thread-list-v2-items.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -647,18 +647,18 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
647647
const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);
648648
const groupMenuItems = useMemo<MenuAction[]>(
649649
() =>
650-
onUngroupThreads === undefined || group === null
650+
onUngroupThreads === undefined || (group === null && thread.groupedUnderThreadId == null)
651651
? []
652652
: [
653-
group.role === "parent"
653+
group?.role === "parent"
654654
? { id: "ungroup", title: "Ungroup", image: "rectangle.stack.badge.minus" }
655655
: {
656656
id: "remove-from-group",
657657
title: "Remove from group",
658658
image: "rectangle.stack.badge.minus",
659659
},
660660
],
661-
[group, onUngroupThreads],
661+
[group, onUngroupThreads, thread.groupedUnderThreadId],
662662
);
663663

664664
// Swipe: the v2 primary action is the lifecycle transition. Un-settling a

‎apps/mobile/src/features/threads/threadListV2.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts";
3030
import {
3131
buildThreadListV2Items,
3232
buildThreadListV2ListItems,
33+
getThreadListV2LiveGroupThreads,
3334
getThreadListV2OrderedSection,
3435
isThreadListV2ListItem,
3536
resolveThreadListV2SnoozeMenuSelection,
@@ -478,6 +479,16 @@ describe("thread groups", () => {
478479
expect(layout.settledCount).toBe(0);
479480
});
480481

482+
it("keeps a settled top thread with its live group out of the parked lists", () => {
483+
const settledParent = { ...parent, settledOverride: "settled" as const, settledAt: NOW };
484+
const lone = { ...solo, settledOverride: "settled" as const, settledAt: NOW };
485+
expect(
486+
[...getThreadListV2LiveGroupThreads({ threads: [settledParent, child, lone], now: NOW })]
487+
.map((thread) => thread.title)
488+
.toSorted(),
489+
).toEqual(["Child", "Parent"]);
490+
});
491+
481492
it("moves a group as one row", () => {
482493
expect(
483494
getThreadListV2OrderedSection({ threads, section: "active", now: NOW }).map(

‎apps/mobile/src/features/threads/threadListV2.ts‎

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -244,18 +244,16 @@ function groupListKey(
244244
: `${thread.environmentId}:${thread.groupedUnderThreadId}`;
245245
}
246246

247-
/** Canonical card section for Move up/down, independent of search or scope.
248-
A thread grouped under another one moves with its group, so the section
249-
lists only the row that leads each group. */
250-
export function getThreadListV2OrderedSection(input: {
247+
interface ThreadListV2OrderInput {
251248
readonly threads: readonly EnvironmentThreadShell[];
252-
readonly section: "pinned" | "active";
253-
readonly pendingOrder?: PendingThreadOrder | null;
254249
readonly now: string;
255250
readonly settlementEnvironmentIds?: ReadonlySet<EnvironmentId>;
256251
readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
257252
readonly queuedThreadKeys?: ReadonlySet<string>;
258-
}): EnvironmentThreadShell[] {
253+
}
254+
255+
/** Pinned, Active, and parked threads in saved order, grouped into blocks. */
256+
function layoutThreadListV2OrderSections(input: ThreadListV2OrderInput) {
259257
const pinned: EnvironmentThreadShell[] = [];
260258
const active: EnvironmentThreadShell[] = [];
261259
const parked: EnvironmentThreadShell[] = [];
@@ -275,15 +273,27 @@ export function getThreadListV2OrderedSection(input: {
275273
active: sortActiveThreadsByOrderKey(active),
276274
parked,
277275
};
278-
const leads = new Set(
279-
layoutThreadGroups({
280-
sections,
281-
order: ["pinned", "active", "parked"],
282-
live: ORDER_LIVE_SECTIONS,
283-
keyOf: threadListKey,
284-
groupKeyOf: groupListKey,
285-
})[input.section].map((block) => block.leadKey),
286-
);
276+
const layout = layoutThreadGroups({
277+
sections,
278+
order: ["pinned", "active", "parked"],
279+
live: ORDER_LIVE_SECTIONS,
280+
keyOf: threadListKey,
281+
groupKeyOf: groupListKey,
282+
});
283+
return { sections, layout };
284+
}
285+
286+
/** Canonical card section for Move up/down, independent of search or scope.
287+
A thread grouped under another one moves with its group, so the section
288+
lists only the row that leads each group. */
289+
export function getThreadListV2OrderedSection(
290+
input: ThreadListV2OrderInput & {
291+
readonly section: "pinned" | "active";
292+
readonly pendingOrder?: PendingThreadOrder | null;
293+
},
294+
): EnvironmentThreadShell[] {
295+
const { sections, layout } = layoutThreadListV2OrderSections(input);
296+
const leads = new Set(layout[input.section].map((block) => block.leadKey));
287297
const ordered = sections[input.section].filter((thread) => leads.has(threadListKey(thread)));
288298
const pending =
289299
input.pendingOrder?.section === input.section
@@ -292,6 +302,20 @@ export function getThreadListV2OrderedSection(input: {
292302
return applyPendingThreadOrder(ordered, input.section, pending);
293303
}
294304

305+
/** Every thread in a group that sits in Pinned or Active, including a
306+
snoozed or settled top thread. It moves with its group, so the
307+
arrangement sheet keeps it out of the parked lists. */
308+
export function getThreadListV2LiveGroupThreads(
309+
input: ThreadListV2OrderInput,
310+
): ReadonlySet<EnvironmentThreadShell> {
311+
const { layout } = layoutThreadListV2OrderSections(input);
312+
return new Set(
313+
[...layout.pinned, ...layout.active].flatMap((block) =>
314+
block.rows.length > 1 ? block.rows.map((row) => row.thread) : [],
315+
),
316+
);
317+
}
318+
295319
const ORDER_LIVE_SECTIONS: ReadonlySet<"pinned" | "active" | "parked"> = new Set([
296320
"pinned",
297321
"active",
@@ -317,8 +341,10 @@ export interface ThreadListV2Item {
317341

318342
const LIST_SECTIONS = ["pinned", "active", "working", "snoozed", "settled"] as const;
319343
type ThreadListSection = (typeof LIST_SECTIONS)[number];
320-
// A group whose top thread is snoozed or settled moves to its first thread here.
321-
const LIVE_LIST_SECTIONS: ReadonlySet<ThreadListSection> = new Set(["pinned", "active", "working"]);
344+
// A group whose top thread is outside these sections moves to a member in
345+
// them. The Working shelf folds away like the parked shelves, so a group
346+
// there must not hide a member that needs you in Active.
347+
const LIVE_LIST_SECTIONS: ReadonlySet<ThreadListSection> = new Set(["pinned", "active"]);
322348
type ThreadListBlock = ThreadGroupBlock<EnvironmentThreadShell, ThreadListSection>;
323349

324350
export interface ThreadListV2Layout {

‎apps/web/src/components/Sidebar.tsx‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -313,8 +313,10 @@ const SIDEBAR_SECTION_ORDER = [
313313
"snoozed",
314314
"settled",
315315
] as const satisfies readonly SidebarSection[];
316-
// A group whose top thread is snoozed or settled moves to its first thread here.
317-
const LIVE_SIDEBAR_SECTIONS: ReadonlySet<SidebarSection> = new Set(["pinned", "active", "working"]);
316+
// A group whose top thread is outside these sections moves to a member in
317+
// them. The Working shelf folds away like the parked shelves, so a group
318+
// there must not hide a member that needs you in Active.
319+
const LIVE_SIDEBAR_SECTIONS: ReadonlySet<SidebarSection> = new Set(["pinned", "active"]);
318320

319321
/** A launch block as rendered: `rows` drops what a folded group hides. */
320322
interface RenderedLaunchBlock {
@@ -5141,7 +5143,9 @@ export default function Sidebar() {
51415143
isSnoozed,
51425144
canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }),
51435145
isRegeneratingTitle,
5144-
inLaunchGroup: launchChildKeysRef.current.has(threadKey),
5146+
// Also when the group's top thread is filtered out of the list.
5147+
inLaunchGroup:
5148+
launchChildKeysRef.current.has(threadKey) || thread.groupedUnderThreadId != null,
51455149
...(ledGroup ? { leadsLaunchGroup: { settleCount: groupSettleable.length } } : {}),
51465150
isRunning: !threadRuntimeCanArchive(thread.runtime),
51475151
supports: {

0 commit comments

Comments
 (0)