Skip to content
Merged
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
12 changes: 9 additions & 3 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
} satisfies MenuAction,
]
: []),
pinnedRow
thread.pinnedAt != null
? { id: "unpin", title: "Unpin", image: "pin.slash" }
: { id: "pin", title: "Pin", image: "pin" },
]
Expand All @@ -517,6 +517,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
props.canMovePinnedUp,
props.pinReorderSupported,
props.pinningSupported,
thread.pinnedAt,
],
);
const titleRegenerationMenuItems = useMemo<MenuAction[]>(
Expand Down Expand Up @@ -552,8 +553,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
[pinMenuItem, titleRegenerationMenuItems],
);
const slimMenuActions = useMemo<MenuAction[]>(
() => [SLIM_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SLIM_MENU_ACTIONS[1]!],
[titleRegenerationMenuItems],
() => [
SLIM_MENU_ACTIONS[0]!,
...(thread.pinnedAt != null ? pinMenuItem : []),
...titleRegenerationMenuItems,
SLIM_MENU_ACTIONS[1]!,
],
[pinMenuItem, thread.pinnedAt, titleRegenerationMenuItems],
);
const snoozedMenuActions = useMemo<MenuAction[]>(
() => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!],
Expand Down
80 changes: 76 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,81 @@ 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.items[1]?.thread.pinnedAt).toBe("2026-06-01T12:00:00.000Z");
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
78 changes: 33 additions & 45 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -701,13 +701,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
autoSettleOnMerge: boolean;
// Same contract for thread.snooze/unsnooze.
snoozeSupported: boolean;
// Renders the pin glyph. Pinned cards keep the full settle/snooze quick
// actions: settling clears the pin server-side, and snoozing hides the
// card until wake with the pin intact underneath. The glyph is also the
// in-row pin state cue (the pinned block has no header), so it always
// shows while pinned; it only becomes a clickable unpin quick-action once
// the pinning capability is confirmed, and stays a passive marker while
// the descriptor is not loaded. Pinning itself lives in the context menu.
// Pinned threads show the same pin marker in active, settled, and snoozed
// rows. The marker can unpin the thread when the server supports pinning.
pinningSupported: boolean;
isPinned: boolean;
// Present only on pinned cards whose server supports reordering: dnd-kit
Expand Down Expand Up @@ -1192,6 +1187,31 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
<TerminalIcon className={cn("size-3.5", terminalStatus.pulse && "animate-status-pulse")} />
</span>
) : null;
const pinIndicator = props.isPinned ? (
props.pinningSupported ? (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label="Unpin thread"
onClick={handleUnpinClick}
className="inline-flex cursor-pointer items-center rounded-sm text-muted-foreground/65 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
/>
}
>
<PinIcon aria-hidden className="size-3 shrink-0" />
</TooltipTrigger>
<TooltipPopup>Unpin thread</TooltipPopup>
</Tooltip>
) : (
<PinIcon
aria-label="Pinned"
role="img"
className="size-3 shrink-0 text-muted-foreground/65"
/>
)
) : null;

if (variant === "slim") {
return (
Expand Down Expand Up @@ -1233,6 +1253,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
/>
</span>
{title}
{pinIndicator}
{terminalStatusIcon}
{isRegeneratingTitle ? (
<span role="status" className="sr-only">
Expand Down Expand Up @@ -1395,31 +1416,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
) : (
<span className="flex-1" />
)}
{props.isPinned ? (
props.pinningSupported ? (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label="Unpin thread"
onClick={handleUnpinClick}
className="inline-flex cursor-pointer items-center rounded-sm text-muted-foreground/65 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
/>
}
>
<PinIcon aria-hidden className="size-3 shrink-0" />
</TooltipTrigger>
<TooltipPopup>Unpin thread</TooltipPopup>
</Tooltip>
) : (
<PinIcon
aria-label="Pinned"
role="img"
className="size-3 shrink-0 text-muted-foreground/65"
/>
)
) : null}
{pinIndicator}
{/* The visible state owns this slot's width: status at rest,
actions on hover/keyboard focus or while the popover is open. Keeping
the hidden state out of flow lets the project label reclaim
Expand Down Expand Up @@ -2043,20 +2040,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 +2053,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 Expand Up @@ -3697,7 +3685,7 @@ export default function Sidebar() {
serverConfigs.get(thread.environmentId)?.environment.capabilities
.threadPinning === true
}
isPinned={section === "pinned"}
isPinned={thread.pinnedAt != null}
sortable={sortable}
snoozeWakeLabelText={
section === "snoozed" && thread.snoozedUntil != null
Expand Down
3 changes: 3 additions & 0 deletions docs/user/thread-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ 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 they become inactive. They also move when their pull
request merges if **Auto-settle merged threads** is enabled.

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