Skip to content
Open
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
7059405
feat(web): add sidebar thread drag and drop
tarik02 Aug 21, 2026
bf2a2eb
refactor(web): split sidebar drag and drop
tarik02 Aug 21, 2026
5c6f904
refactor: simplify sidebar drag and drop
tarik02 Aug 21, 2026
0a8cf5c
fix(web): stabilize sidebar dragging
tarik02 Aug 21, 2026
9ffd1bc
refactor(web): use one sortable sidebar list
tarik02 Aug 22, 2026
558b7a1
fix(web): stabilize sidebar drag transitions
tarik02 Aug 22, 2026
9c07c4e
fix(web): keep dragged sidebar card under pointer
tarik02 Aug 23, 2026
cf15ad9
fix(web): stabilize sidebar drag geometry
tarik02 Aug 23, 2026
4e87b6e
fix(web): harden sidebar drag and drop
tarik02 Aug 23, 2026
cfea127
fix(web): preserve settled pinned sidebar state
tarik02 Aug 23, 2026
e36477d
refactor(server): keep sidebar lifecycle diff focused
tarik02 Aug 23, 2026
69f372c
refactor(web): use dnd-kit drag overlay for sidebar threads
tarik02 Aug 23, 2026
687fdb0
refactor(web): use dnd-kit layout shift compensation
tarik02 Aug 23, 2026
9db6943
refactor(web): simplify sidebar drag collision ownership
tarik02 Aug 23, 2026
9a66fce
refactor(web): model sidebar drag transaction phases
tarik02 Aug 23, 2026
f51c02f
refactor(web): clean up sidebar drag implementation
tarik02 Aug 23, 2026
399a71d
fix(web): stabilize pinned thread reordering
tarik02 Aug 23, 2026
9d856a3
fix(web): fix sidebar drag edge cases
tarik02 Aug 23, 2026
ab35282
fix(web): stabilize sidebar drag completion
tarik02 Aug 23, 2026
5ff4194
fix(web): remove empty regular drag rail
tarik02 Aug 23, 2026
1f62d2b
Merge remote-tracking branch 'upstream/main' into feature/sidebar-dnd…
tarik02 Aug 23, 2026
8d0b2e6
fix(web): preserve sidebar drag destinations
tarik02 Aug 23, 2026
d7ea7c2
fix(web): morph canceled snooze drags
tarik02 Aug 23, 2026
ebe62fe
fix(web): hide decorative sidebar boundaries
tarik02 Aug 23, 2026
d4975c7
fix(web): restore un-settle tooltip
tarik02 Aug 23, 2026
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 @@ -451,8 +451,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 @@ -483,9 +485,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 @@ -531,17 +547,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 @@ -552,14 +563,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 @@ -943,7 +955,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
: !props.settlementSupported
? legacyMenuActions
: canUnsettle
? slimMenuActions
? settledMenuActions
: swipeActions.secondary === "snooze"
? snoozableCardMenuActions
: cardMenuActions
Expand Down
23 changes: 15 additions & 8 deletions apps/server/src/orchestration/decider.pinned.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ 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);
Expand All @@ -86,7 +86,7 @@ it.layer(NodeServices.layer)("pinned thread decider", (it) => {
commandId: CommandId.make("cmd-pin-again"),
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.pinned");
Expand All @@ -105,7 +105,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 @@ -160,14 +160,17 @@ it.layer(NodeServices.layer)("pinned thread decider", (it) => {
commandId: CommandId.make("cmd-pin-snoozed"),
threadId: ThreadId.make("thread-1"),
},
readModel: makeReadModel({ snoozedUntil: "1970-01-02T09:00:00.000Z" }),
readModel: makeReadModel({
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"]);
}),
);

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 @@ -178,7 +181,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 @@ -190,7 +193,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 @@ -256,7 +259,11 @@ it.layer(NodeServices.layer)("pinned thread decider", (it) => {
threadId: ThreadId.make("thread-1"),
orderKey: "t",
},
readModel: makeReadModel({ pinnedAt: PINNED_AT, pinOrderKey: "g" }),
readModel: makeReadModel({
pinnedAt: PINNED_AT,
pinOrderKey: "g",
settledOverride: "active",
}),
});
const events = Array.isArray(event) ? event : [event];
expect(events[0]?.type).toBe("thread.pinned");
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 @@ -74,7 +75,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 @@ -148,7 +149,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 @@ -169,7 +173,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 @@ -188,7 +195,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 @@ -204,7 +214,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