Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
79 changes: 75 additions & 4 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,15 +309,14 @@ describe("buildThreadListV2Items", () => {
expect(layout.snoozedCount).toBe(1);
});

it("renders pinned threads first and exempts them from auto-settle — parity with web", () => {
it("places settled pinned threads in the settled shelf", () => {
const layout = buildThreadListV2Items({
threads: [
makeThread({ id: ThreadId.make("active"), title: "Active" }),
makeThread({
id: ThreadId.make("pinned-settled"),
title: "Pinned while settled",
pinnedAt: "2026-06-01T12:00:00.000Z",
// Stale settled state (the decider clears it on pin): the pin wins.
settledOverride: "settled",
settledAt: "2026-06-01T12:00:00.000Z",
}),
Expand All @@ -327,8 +326,80 @@ describe("buildThreadListV2Items", () => {
now: NOW,
});

expect(layout.items.map((item) => item.thread.id)).toEqual(["pinned-settled", "active"]);
expect(layout.items.map((item) => item.pinned)).toEqual([true, false]);
expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-settled"]);
expect(layout.items.map((item) => item.pinned)).toEqual([false, false]);
expect(layout.settledCount).toBe(1);
});

it("moves pinned threads to the settled shelf when their pull request merges", () => {
const merged = makeThread({
id: ThreadId.make("pinned-merged"),
title: "Pinned merged pull request",
pinnedAt: "2026-06-01T12:00:00.000Z",
});
const layout = buildThreadListV2Items({
threads: [makeThread({ id: ThreadId.make("active"), title: "Active" }), merged],
environmentId: null,
searchQuery: "",
changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]),
now: NOW,
});

expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-merged"]);
expect(layout.items.map((item) => item.variant)).toEqual(["card", "slim"]);
expect(layout.settledCount).toBe(1);
});

it("moves inactive pinned threads to the settled shelf", () => {
const inactive = makeThread({
id: ThreadId.make("pinned-inactive"),
title: "Pinned inactive thread",
createdAt: "2026-05-20T00:00:00.000Z",
pinnedAt: "2026-05-21T00:00:00.000Z",
latestTurn: {
turnId: TurnId.make("turn-inactive"),
state: "completed",
requestedAt: "2026-05-21T00:00:00.000Z",
startedAt: "2026-05-21T00:00:01.000Z",
completedAt: "2026-05-21T00:00:02.000Z",
assistantMessageId: null,
},
});
const layout = buildThreadListV2Items({
threads: [inactive],
environmentId: null,
searchQuery: "",
now: NOW,
});

expect(layout.items[0]).toMatchObject({
thread: { id: "pinned-inactive" },
variant: "slim",
pinned: false,
});
expect(layout.settledCount).toBe(1);
});

it("keeps pinned merged threads pinned when auto-settle on merge is off", () => {
const merged = makeThread({
id: ThreadId.make("pinned-merged"),
title: "Pinned merged pull request",
pinnedAt: "2026-06-01T12:00:00.000Z",
});
const layout = buildThreadListV2Items({
threads: [merged],
environmentId: null,
searchQuery: "",
changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]),
autoSettleOnMerge: false,
now: NOW,
});

expect(layout.items[0]).toMatchObject({
thread: { id: "pinned-merged" },
variant: "card",
pinned: true,
});
expect(layout.settledCount).toBe(0);
});

Expand Down
13 changes: 3 additions & 10 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,10 +386,7 @@ export function buildThreadListV2Items(input: {
const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true;
const changeRequest =
input.changeRequestByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null;
// Visibility parity with web: snooze outranks everything, including a
// pin — a snoozed thread leaves the list until it wakes (or raises its
// hand). The pin (and its pinOrderKey) survives underneath, so a woken
// thread reappears at its exact spot in the pinned block.
// Snooze outranks settlement and pinning until the thread wakes.
if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) {
snoozed.push(thread);
if (
Expand All @@ -401,12 +398,6 @@ export function buildThreadListV2Items(input: {
}
continue;
}
// A pin otherwise overrides the lifecycle: pinned threads render above
// the inbox and never auto-settle out of sight.
if (thread.pinnedAt != null) {
pinned.push(thread);
continue;
}
if (
supportsSettlement &&
effectiveSettled(thread, {
Expand All @@ -417,6 +408,8 @@ export function buildThreadListV2Items(input: {
})
) {
settled.push(thread);
} else if (thread.pinnedAt != null) {
pinned.push(thread);
Comment thread
t3dotgg marked this conversation as resolved.
} else {
active.push(thread);
}
Expand Down
15 changes: 3 additions & 12 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2043,20 +2043,9 @@ export default function Sidebar() {
snapshot != null && (thread.worktreePath === null || snapshot.branch === thread.branch)
? snapshot.pr
: null;
// Snooze outranks everything, including a pin: "hide until Tuesday"
// temporarily suspends "keep on top". The pin survives underneath —
// and so does its pinOrderKey, so on wake the thread reappears at
// its exact slot in the pinned block. (For unpinned threads
// this is also the snooze-beats-auto-settle rule: the wake time is a
// stronger statement about when the thread matters again.)
// Snooze outranks settlement and pinning until the thread wakes.
if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) {
snoozed.push(thread);
// A pin otherwise overrides the lifecycle: pinned threads never
// auto-settle out of sight. (The decider clears settled state on
// pin and the pin on settle, so pin-vs-settled conflicts only
// arise from stale or raced writes.)
} else if (thread.pinnedAt != null) {
pinned.push(thread);
} else if (
supportsSettlement &&
effectiveSettled(thread, {
Expand All @@ -2067,6 +2056,8 @@ export default function Sidebar() {
})
) {
settled.push(thread);
} else if (thread.pinnedAt != null) {
pinned.push(thread);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
} else {
active.push(thread);
}
Expand Down
2 changes: 2 additions & 0 deletions docs/user/thread-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Pin a thread from its context menu to keep it in the pinned section above your a
Pinned threads are shown independently of their project, including when you connect to more than
one environment.

Pinned threads still move to **Settled** when their pull request merges or they become inactive.
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated

On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu
and choose **Move up** or **Move down**. The order is stored by the server and appears on your
other connected devices.
Expand Down
4 changes: 2 additions & 2 deletions packages/contracts/src/orchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,8 +401,8 @@ export const OrchestrationThread = Schema.Struct({
// Optional so payloads from pre-snooze servers still decode.
snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)),
snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
// A pin overrides the settled/snoozed lifecycle: while pinnedAt is set the
// thread renders in the pinned block and never classifies into a shelf.
// Active pinned threads render in the pinned block. Settled and snoozed
// threads remain in their respective shelves even when pinned.
// Optional so payloads from pre-pinning servers still decode.
pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
// Fractional index for user-arranged pinned order. Keyed threads sort by
Expand Down
Loading