Skip to content
Open
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
44 changes: 28 additions & 16 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ const SLIM_MENU_ACTIONS: MenuAction[] = [
];

const SNOOZED_MENU_ACTIONS: MenuAction[] = [
{ id: "unsnooze", title: "Wake thread", image: "clock" },
{ id: "unsnooze", title: "Wake", image: "clock" },
{ id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
];

Expand Down Expand Up @@ -455,8 +455,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {

// Swipe: the v2 primary action is the lifecycle transition. Every settled
// row can un-settle — explicit settles clear the override, auto-settled
// rows get pinned active until real activity clears the pin.
const canUnsettle = variant === "slim";
// rows stay explicitly active until real activity clears the override.
// The row's rendered category is authoritative for its lifecycle menu.
// Snoozed rows are slim too, but their only transition is Wake.
const canUnsettle = variant === "slim" && !snoozedRow;
const [snoozeGateTick, bumpSnoozeGateTick] = useState(0);
const snoozeGateExpiryMs = props.snoozeSupported
? resolveThreadListV2SnoozeGateExpiryMs(thread, { now: new Date().toISOString() })
Expand Down Expand Up @@ -487,9 +489,23 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
})),
[snoozePresets],
);
const snoozeMenuItem = useMemo<MenuAction[]>(
() =>
swipeActions.secondary === "snooze"
? [
{
id: "snooze",
title: "Snooze",
image: "clock",
subactions: snoozePresetActions,
},
]
: [],
[snoozePresetActions, swipeActions.secondary],
);
// Pinned cards keep the full lifecycle menu; only the pin item flips to
// Unpin. (Settling a pinned thread clears the pin server-side; snoozing
// hides the card until wake with the pin intact.)
// Unpin. Settling or snoozing a pinned thread moves it out of Pinned;
// Wake returns a snoozed thread to Regular.
const pinMenuItem = useMemo<MenuAction[]>(
() =>
props.pinningSupported
Expand Down Expand Up @@ -535,17 +551,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const snoozableCardMenuActions = useMemo<MenuAction[]>(
() => [
{ id: "settle", title: "Settle", image: "checkmark" },
{
id: "snooze",
title: "Snooze",
image: "clock",
subactions: snoozePresetActions,
},
...snoozeMenuItem,
...pinMenuItem,
...titleRegenerationMenuItems,
{ id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
],
[pinMenuItem, snoozePresetActions, titleRegenerationMenuItems],
[pinMenuItem, snoozeMenuItem, titleRegenerationMenuItems],
);
const cardMenuActions = useMemo<MenuAction[]>(
() => [
Expand All @@ -556,14 +567,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
],
[pinMenuItem, titleRegenerationMenuItems],
);
const slimMenuActions = useMemo<MenuAction[]>(
const settledMenuActions = useMemo<MenuAction[]>(
() => [
SLIM_MENU_ACTIONS[0]!,
...(thread.pinnedAt != null ? pinMenuItem : []),
...snoozeMenuItem,
...pinMenuItem,
...titleRegenerationMenuItems,
SLIM_MENU_ACTIONS[1]!,
],
[pinMenuItem, thread.pinnedAt, titleRegenerationMenuItems],
[pinMenuItem, snoozeMenuItem, titleRegenerationMenuItems],
);
const snoozedMenuActions = useMemo<MenuAction[]>(
() => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!],
Expand Down Expand Up @@ -947,7 +959,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
: !props.settlementSupported
? legacyMenuActions
: canUnsettle
? slimMenuActions
? settledMenuActions
: swipeActions.secondary === "snooze"
? snoozableCardMenuActions
: cardMenuActions
Expand Down
82 changes: 54 additions & 28 deletions apps/server/src/orchestration/decider.pinned.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,28 +71,33 @@ it.layer(NodeServices.layer)("pinned thread decider", (it) => {
readModel: makeReadModel({}),
});
const events = Array.isArray(event) ? event : [event];
expect(events).toHaveLength(1);
expect(events.map((entry) => entry.type)).toEqual(["thread.pinned", "thread.unsettled"]);
expect(events[0]?.type).toBe("thread.pinned");
if (events[0]?.type === "thread.pinned") {
expect(events[0].payload.pinnedAt).toBe(events[0].payload.updatedAt);
}
}),
);

it.effect("re-pinning preserves the original pinnedAt and updatedAt", () =>
it.effect("re-pinning without an order key preserves the existing pin", () =>
Effect.gen(function* () {
const event = yield* decideOrchestrationCommand({
command: {
type: "thread.pin",
commandId: CommandId.make("cmd-pin-again"),
threadId: ThreadId.make("thread-1"),
},
readModel: makeReadModel({ pinnedAt: PINNED_AT }),
readModel: makeReadModel({
pinnedAt: PINNED_AT,
pinOrderKey: "g",
settledOverride: "active",
}),
});
const events = Array.isArray(event) ? event : [event];
expect(events[0]?.type).toBe("thread.pinned");
if (events[0]?.type === "thread.pinned") {
expect(events[0].payload.pinnedAt).toBe(PINNED_AT);
expect(events[0].payload.pinOrderKey).toBeUndefined();
expect(events[0].payload.updatedAt).toBe(NOW);
}
}),
Expand All @@ -106,7 +111,7 @@ it.layer(NodeServices.layer)("pinned thread decider", (it) => {
commandId: CommandId.make("cmd-unpin"),
threadId: ThreadId.make("thread-1"),
},
readModel: makeReadModel({ pinnedAt: PINNED_AT }),
readModel: makeReadModel({ pinnedAt: PINNED_AT, settledOverride: "active" }),
});
const events = Array.isArray(event) ? event : [event];
expect(events[0]?.type).toBe("thread.unpinned");
Expand Down Expand Up @@ -141,11 +146,20 @@ it.layer(NodeServices.layer)("pinned thread decider", (it) => {
type: "thread.pin",
commandId: CommandId.make("cmd-pin-settled"),
threadId: ThreadId.make("thread-1"),
orderKey: "t",
},
readModel: makeReadModel({ settledOverride: "settled" }),
readModel: makeReadModel({
pinnedAt: PINNED_AT,
pinOrderKey: "g",
settledOverride: "settled",
}),
});
const events = Array.isArray(event) ? event : [event];
expect(events.map((entry) => entry.type)).toEqual(["thread.pinned", "thread.unsettled"]);
expect(events[0]?.type).toBe("thread.pinned");
if (events[0]?.type === "thread.pinned") {
expect(events[0].payload.pinOrderKey).toBe("t");
}
const unsettled = events.find((entry) => entry.type === "thread.unsettled");
if (unsettled?.type === "thread.unsettled") {
expect(unsettled.payload.reason).toBe("user");
Expand All @@ -160,15 +174,25 @@ it.layer(NodeServices.layer)("pinned thread decider", (it) => {
type: "thread.pin",
commandId: CommandId.make("cmd-pin-snoozed"),
threadId: ThreadId.make("thread-1"),
orderKey: "t",
},
readModel: makeReadModel({ snoozedUntil: "1970-01-02T09:00:00.000Z" }),
readModel: makeReadModel({
pinnedAt: PINNED_AT,
pinOrderKey: "g",
settledOverride: "active",
snoozedUntil: "1970-01-02T09:00:00.000Z",
}),
});
const events = Array.isArray(event) ? event : [event];
expect(events.map((entry) => entry.type)).toEqual(["thread.pinned", "thread.unsnoozed"]);
expect(events[0]?.type).toBe("thread.pinned");
if (events[0]?.type === "thread.pinned") {
expect(events[0].payload.pinOrderKey).toBe("t");
}
}),
);

it.effect("pinning an unparked thread emits only thread.pinned", () =>
it.effect("pinning a neutral thread explicitly keeps it active", () =>
Effect.gen(function* () {
const event = yield* decideOrchestrationCommand({
command: {
Expand All @@ -179,7 +203,7 @@ it.layer(NodeServices.layer)("pinned thread decider", (it) => {
readModel: makeReadModel({}),
});
const events = Array.isArray(event) ? event : [event];
expect(events.map((entry) => entry.type)).toEqual(["thread.pinned"]);
expect(events.map((entry) => entry.type)).toEqual(["thread.pinned", "thread.unsettled"]);
}),
);

Expand All @@ -191,7 +215,7 @@ it.layer(NodeServices.layer)("pinned thread decider", (it) => {
commandId: CommandId.make("cmd-settle-pinned"),
threadId: ThreadId.make("thread-1"),
},
readModel: makeReadModel({ pinnedAt: PINNED_AT }),
readModel: makeReadModel({ pinnedAt: PINNED_AT, settledOverride: "active" }),
});
const events = Array.isArray(event) ? event : [event];
expect(events.map((entry) => entry.type)).toEqual(["thread.settled", "thread.unpinned"]);
Expand Down Expand Up @@ -246,25 +270,27 @@ it.layer(NodeServices.layer)("pinned thread decider", (it) => {
}),
);

it.effect(
"re-pinning ignores the incoming order key so raced pins cannot move a placed thread",
() =>
Effect.gen(function* () {
const event = yield* decideOrchestrationCommand({
command: {
type: "thread.pin",
commandId: CommandId.make("cmd-pin-keyed-again"),
threadId: ThreadId.make("thread-1"),
orderKey: "t",
},
readModel: makeReadModel({ pinnedAt: PINNED_AT, pinOrderKey: "g" }),
});
const events = Array.isArray(event) ? event : [event];
expect(events[0]?.type).toBe("thread.pinned");
if (events[0]?.type === "thread.pinned") {
expect(events[0].payload.pinOrderKey).toBeUndefined();
}
}),
it.effect("re-pinning with an order key applies the requested placement", () =>
Effect.gen(function* () {
const event = yield* decideOrchestrationCommand({
command: {
type: "thread.pin",
commandId: CommandId.make("cmd-pin-keyed-again"),
threadId: ThreadId.make("thread-1"),
orderKey: "t",
},
readModel: makeReadModel({
pinnedAt: PINNED_AT,
pinOrderKey: "g",
settledOverride: "active",
}),
});
const events = Array.isArray(event) ? event : [event];
expect(events[0]?.type).toBe("thread.pinned");
if (events[0]?.type === "thread.pinned") {
expect(events[0].payload.pinOrderKey).toBe("t");
}
}),
);

it.effect("reorders a pinned thread, stamping the new key", () =>
Expand Down
22 changes: 16 additions & 6 deletions apps/server/src/orchestration/decider.snoozed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ function makeReadModel(input: {
readonly snoozedUntil?: string | null;
readonly snoozedAt?: string | null;
readonly archivedAt?: string | null;
readonly settledOverride?: "settled" | "active" | null;
readonly activities?: OrchestrationThread["activities"];
readonly messages?: OrchestrationThread["messages"];
}): OrchestrationReadModel {
Expand All @@ -45,7 +46,7 @@ function makeReadModel(input: {
createdAt: NOW,
updatedAt: NOW,
archivedAt: input.archivedAt ?? null,
settledOverride: null,
settledOverride: input.settledOverride ?? null,
settledAt: null,
snoozedUntil: input.snoozedUntil ?? null,
snoozedAt: input.snoozedAt ?? (input.snoozedUntil != null ? SNOOZED_AT : null),
Expand Down Expand Up @@ -75,7 +76,7 @@ it.layer(NodeServices.layer)("snoozed thread decider", (it) => {
readModel: makeReadModel({}),
});
const events = Array.isArray(event) ? event : [event];
expect(events).toHaveLength(1);
expect(events.map((entry) => entry.type)).toEqual(["thread.snoozed", "thread.unsettled"]);
expect(events[0]?.type).toBe("thread.snoozed");
if (events[0]?.type === "thread.snoozed") {
expect(events[0].payload.snoozedUntil).toBe(FUTURE_WAKE);
Expand Down Expand Up @@ -149,7 +150,10 @@ it.layer(NodeServices.layer)("snoozed thread decider", (it) => {
threadId: ThreadId.make("thread-1"),
snoozedUntil: FUTURE_WAKE,
},
readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }),
readModel: makeReadModel({
snoozedUntil: FUTURE_WAKE,
settledOverride: "active",
}),
});
const events = Array.isArray(reEmit) ? reEmit : [reEmit];
expect(events).toHaveLength(1);
Expand All @@ -170,7 +174,10 @@ it.layer(NodeServices.layer)("snoozed thread decider", (it) => {
threadId: ThreadId.make("thread-1"),
snoozedUntil: "1970-01-03T09:00:00.000Z",
},
readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }),
readModel: makeReadModel({
snoozedUntil: FUTURE_WAKE,
settledOverride: "active",
}),
});
const events = Array.isArray(event) ? event : [event];
if (events[0]?.type === "thread.snoozed") {
Expand All @@ -189,7 +196,10 @@ it.layer(NodeServices.layer)("snoozed thread decider", (it) => {
threadId: ThreadId.make("thread-1"),
reason: "user",
},
readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }),
readModel: makeReadModel({
snoozedUntil: FUTURE_WAKE,
settledOverride: "active",
}),
});
const events = Array.isArray(event) ? event : [event];
expect(events[0]?.type).toBe("thread.unsnoozed");
Expand All @@ -205,7 +215,7 @@ it.layer(NodeServices.layer)("snoozed thread decider", (it) => {
threadId: ThreadId.make("thread-1"),
reason: "user",
},
readModel: makeReadModel({}),
readModel: makeReadModel({ settledOverride: "active" }),
});
const awakeEvents = Array.isArray(awake) ? awake : [awake];
expect(awakeEvents[0]?.type).toBe("thread.unsnoozed");
Expand Down
Loading
Loading