authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-04 16:35:51-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-04 16:35:51-07:00
logda6e1a96782594cab3a6925f441731a65be57c11
tree8b6eaf5f3b003a304dbd170bbd419f480d527c41
parent8eca20005b47e197b3610f7996f3fd02355c1891
signature Signed by PGP key B5690EEEBB952194

feat(sidebar-v2): thread pinning for sidebar v2 (#5312)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

32 files changed, 1180 insertions(+), 80 deletions(-)

apps/mobile/src/components/AppSymbol.tsx+4
......@@ -50,6 +50,8 @@ import {
5050 IconMinus,
5151 IconNetwork,
5252 IconPalette,
53 IconPin,
54 IconPinnedOff,
5355 IconPlayerPlay,
5456 IconPlayerStopFilled,
5557 IconPlus,
......@@ -121,6 +123,8 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
121123 magnifyingglass: IconSearch,
122124 paintbrush: IconPalette,
123125 "person.crop.circle": IconUserCircle,
126 pin: IconPin,
127 "pin.slash": IconPinnedOff,
124128 play: IconPlayerPlay,
125129 plus: IconPlus,
126130 "qrcode.viewfinder": IconQrcode,
apps/mobile/src/features/home/HomeRouteScreen.tsx+4
......@@ -42,6 +42,8 @@ export function HomeRouteScreen() {
4242 settleThread,
4343 snoozeThread,
4444 unsnoozeThread,
45 pinThread,
46 unpinThread,
4547 unsettleThread,
4648 } = useThreadListActions();
4749 const pendingTasks = usePendingNewTasks();
......@@ -155,6 +157,8 @@ export function HomeRouteScreen() {
155157 onSnoozeThread={snoozeThread}
156158 onUnsnoozeThread={unsnoozeThread}
157159 onUnsettleThread={unsettleThread}
160 onPinThread={pinThread}
161 onUnpinThread={unpinThread}
158162 onEnvironmentChange={setSelectedEnvironmentId}
159163 onProjectChange={setSelectedProjectKey}
160164 onOpenEnvironments={() =>
apps/mobile/src/features/home/HomeScreen.tsx+30
......@@ -111,6 +111,8 @@ interface HomeScreenProps {
111111 ) => Promise<boolean>;
112112 readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
113113 readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
114 readonly onPinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
115 readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
114116 readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
115117 readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
116118 readonly onNewThreadInProject: (project: EnvironmentProject) => void;
......@@ -516,6 +518,18 @@ export function HomeScreen(props: HomeScreenProps) {
516518 },
517519 [props.onUnsnoozeThread],
518520 );
521 const handlePinThread = useCallback(
522 (thread: EnvironmentThreadShell) => {
523 void props.onPinThread(thread);
524 },
525 [props.onPinThread],
526 );
527 const handleUnpinThread = useCallback(
528 (thread: EnvironmentThreadShell) => {
529 void props.onUnpinThread(thread);
530 },
531 [props.onUnpinThread],
532 );
519533 const handleDeleteThread = props.onDeleteThread;
520534 const handleUnsettleThread = props.onUnsettleThread;
521535 // The settled tail renders in pages; expansion resets when the filter
......@@ -575,6 +589,15 @@ export function HomeScreen(props: HomeScreenProps) {
575589 }
576590 return supported;
577591 }, [serverConfigs]);
592 const pinningEnvironmentIds = useMemo(() => {
593 const supported = new Set<EnvironmentId>();
594 for (const [environmentId, config] of serverConfigs) {
595 if (config.environment.capabilities.threadPinning === true) {
596 supported.add(environmentId);
597 }
598 }
599 return supported;
600 }, [serverConfigs]);
578601 const threadListV2Layout = useMemo(() => {
579602 if (!threadListV2Enabled)
580603 return {
......@@ -718,6 +741,7 @@ export function HomeScreen(props: HomeScreenProps) {
718741 thread={thread}
719742 variant={item.item.variant}
720743 snoozed={item.item.snoozed}
744 pinned={item.item.pinned}
721745 snoozePresetMinute={nowMinute}
722746 snoozeWakeLabelText={item.snoozeWakeLabelText}
723747 project={
......@@ -753,9 +777,12 @@ export function HomeScreen(props: HomeScreenProps) {
753777 settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
754778 onSettleThread={handleSettleThread}
755779 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
780 pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
756781 onSnoozeThread={handleSnoozeThread}
757782 onUnsnoozeThread={handleUnsnoozeThread}
758783 onUnsettleThread={handleUnsettleThread}
784 onPinThread={handlePinThread}
785 onUnpinThread={handleUnpinThread}
759786 onChangeRequestState={handleChangeRequestState}
760787 projectCwd={
761788 projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null
......@@ -768,12 +795,15 @@ export function HomeScreen(props: HomeScreenProps) {
768795 [
769796 handleChangeRequestState,
770797 handleDeleteThread,
798 handlePinThread,
771799 handleSettleThread,
772800 handleSnoozeThread,
801 handleUnpinThread,
773802 handleUnsnoozeThread,
774803 handleSwipeableClose,
775804 handleSwipeableWillOpen,
776805 handleUnsettleThread,
806 pinningEnvironmentIds,
777807 projectByKey,
778808 projectCwdByKey,
779809 props.onArchiveThread,
apps/mobile/src/features/home/useThreadListActions.ts+69
......@@ -29,6 +29,13 @@ function environmentSupportsSnooze(environmentId: EnvironmentThreadShell["enviro
2929 );
3030}
3131
32function environmentSupportsPinning(environmentId: EnvironmentThreadShell["environmentId"]) {
33 return (
34 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
35 .threadPinning === true
36 );
37}
38
3239type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";
3340
3441const ACTION_VERBS: Record<ThreadListAction, string> = {
......@@ -202,10 +209,14 @@ export function useThreadListActions(): {
202209 readonly snoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => Promise<boolean>;
203210 readonly unsnoozeThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
204211 readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
212 readonly pinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
213 readonly unpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
205214} {
206215 const executeAction = useThreadActionExecutor();
207216 const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false });
208217 const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false });
218 const pinMutation = useAtomCommand(threadEnvironment.pin, { reportFailure: false });
219 const unpinMutation = useAtomCommand(threadEnvironment.unpin, { reportFailure: false });
209220 const snoozeInFlightThreadKeys = useRef(new Set<string>());
210221
211222 const archiveThread = useCallback(
......@@ -310,6 +321,62 @@ export function useThreadListActions(): {
310321 async (thread: EnvironmentThreadShell) => (await executeAction("unsettle", thread)) === true,
311322 [executeAction],
312323 );
324 const pinThread = useCallback(
325 async (thread: EnvironmentThreadShell) => {
326 if (!environmentSupportsPinning(thread.environmentId)) {
327 Alert.alert(
328 "Could not pin thread",
329 "This environment's server does not support pinning yet. Update the server to use Pin.",
330 );
331 return false;
332 }
333 selectionHaptic();
334 const result = await pinMutation({
335 environmentId: thread.environmentId,
336 input: { threadId: thread.id },
337 });
338 if (result._tag === "Failure") {
339 const error = Cause.squash(result.cause);
340 Alert.alert(
341 "Could not pin thread",
342 error instanceof Error && error.message.trim().length > 0
343 ? error.message
344 : "The thread could not be pinned.",
345 );
346 return false;
347 }
348 return true;
349 },
350 [pinMutation],
351 );
352 const unpinThread = useCallback(
353 async (thread: EnvironmentThreadShell) => {
354 if (!environmentSupportsPinning(thread.environmentId)) {
355 Alert.alert(
356 "Could not unpin thread",
357 "This environment's server does not support pinning yet. Update the server to use Pin.",
358 );
359 return false;
360 }
361 selectionHaptic();
362 const result = await unpinMutation({
363 environmentId: thread.environmentId,
364 input: { threadId: thread.id },
365 });
366 if (result._tag === "Failure") {
367 const error = Cause.squash(result.cause);
368 Alert.alert(
369 "Could not unpin thread",
370 error instanceof Error && error.message.trim().length > 0
371 ? error.message
372 : "The thread could not be unpinned.",
373 );
374 return false;
375 }
376 return true;
377 },
378 [unpinMutation],
379 );
313380
314381 const confirmDeleteThread = useConfirmDeleteThread(executeAction);
315382
......@@ -320,6 +387,8 @@ export function useThreadListActions(): {
320387 snoozeThread,
321388 unsnoozeThread,
322389 unsettleThread,
390 pinThread,
391 unpinThread,
323392 };
324393}
325394
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+19
......@@ -205,6 +205,8 @@ function ThreadNavigationSidebarPane(
205205 snoozeThread,
206206 unsnoozeThread,
207207 unsettleThread,
208 pinThread,
209 unpinThread,
208210 } = useThreadListActions();
209211 const threadListV2Enabled = useThreadListV2Enabled();
210212 const pendingTasks = usePendingNewTasks();
......@@ -481,6 +483,15 @@ function ThreadNavigationSidebarPane(
481483 }
482484 return supported;
483485 }, [serverConfigs]);
486 const pinningEnvironmentIds = useMemo(() => {
487 const supported = new Set<EnvironmentId>();
488 for (const [environmentId, config] of serverConfigs) {
489 if (config.environment.capabilities.threadPinning === true) {
490 supported.add(environmentId);
491 }
492 }
493 return supported;
494 }, [serverConfigs]);
484495 const threadListV2Layout = useMemo(() => {
485496 if (!threadListV2Enabled)
486497 return {
......@@ -792,6 +803,7 @@ function ThreadNavigationSidebarPane(
792803 previous.item.thread === item.item.thread &&
793804 previous.item.variant === item.item.variant &&
794805 previous.item.snoozed === item.item.snoozed &&
806 previous.item.pinned === item.item.pinned &&
795807 previous.snoozeWakeLabelText === item.snoozeWakeLabelText
796808 );
797809 }
......@@ -879,6 +891,7 @@ function ThreadNavigationSidebarPane(
879891 thread={thread}
880892 variant={item.item.variant}
881893 snoozed={item.item.snoozed}
894 pinned={item.item.pinned}
882895 snoozePresetMinute={nowMinute}
883896 snoozeWakeLabelText={item.snoozeWakeLabelText}
884897 project={projectByKey.get(scopeKey) ?? null}
......@@ -915,9 +928,12 @@ function ThreadNavigationSidebarPane(
915928 settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
916929 onSettleThread={settleThread}
917930 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
931 pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
918932 onSnoozeThread={snoozeThread}
919933 onUnsnoozeThread={unsnoozeThread}
920934 onUnsettleThread={unsettleThread}
935 onPinThread={pinThread}
936 onUnpinThread={unpinThread}
921937 onChangeRequestState={handleChangeRequestState}
922938 projectCwd={projectCwdByKey.get(scopeKey) ?? null}
923939 onSwipeableClose={handleSwipeableClose}
......@@ -1045,6 +1061,8 @@ function ThreadNavigationSidebarPane(
10451061 handleSwipeableClose,
10461062 handleSwipeableWillOpen,
10471063 openPendingTask,
1064 pinThread,
1065 pinningEnvironmentIds,
10481066 projectByKey,
10491067 projectCwdByKey,
10501068 projectTitleByProjectKey,
......@@ -1064,6 +1082,7 @@ function ThreadNavigationSidebarPane(
10641082 nowMinute,
10651083 toggleSettledShelf,
10661084 toggleSnoozedShelf,
1085 unpinThread,
10671086 unsettleThread,
10681087 unsnoozeThread,
10691088 updateGroupDisplay,
apps/mobile/src/features/threads/thread-list-v2-items.tsx+40-2
......@@ -305,6 +305,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
305305 readonly variant: "card" | "slim";
306306 /** Snoozed-shelf row: shows its wake time and offers Wake. */
307307 readonly snoozed?: boolean;
308 /** Pinned-block row: shows the pin glyph and offers Unpin. */
309 readonly pinned?: boolean;
308310 /** Preformatted against the parent minute tick so this memoized row's
309311 countdown keeps moving. */
310312 readonly snoozeWakeLabelText?: string;
......@@ -337,11 +339,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
337339 readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void;
338340 readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
339341 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
342 readonly onPinThread: (thread: EnvironmentThreadShell) => void;
343 readonly onUnpinThread: (thread: EnvironmentThreadShell) => void;
340344 /** False on environments whose server predates thread.settle/unsettle:
341345 swipe + menu fall back to Archive instead of failing on use. */
342346 readonly settlementSupported: boolean;
343347 /** False on servers that predate thread.snooze/unsnooze. */
344348 readonly snoozeSupported: boolean;
349 /** False on servers that predate thread.pin/unpin. */
350 readonly pinningSupported: boolean;
345351 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
346352 readonly onSwipeableClose: (methods: SwipeableMethods) => void;
347353 /** Reports this row's live PR state up so the partition can auto-settle
......@@ -368,9 +374,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
368374 onUnsnoozeThread,
369375 onUnsettleThread,
370376 onArchiveThread,
377 onPinThread,
378 onUnpinThread,
371379 onChangeRequestState,
372380 } = props;
373381 const snoozedRow = props.snoozed === true;
382 const pinnedRow = props.pinned === true;
374383
375384 const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null);
376385 const prState = pr?.state ?? null;
......@@ -383,6 +392,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
383392 const drawerColor = useThemeColor("--color-drawer");
384393 const pressedBackgroundColor = useThemeColor("--color-subtle");
385394 const selectedBackgroundColor = useThemeColor("--color-user-bubble");
395 const pinTintColor = useThemeColor("--color-foreground-muted");
386396 const sidebarPane = props.pane === "sidebar";
387397 const selected = props.selected === true;
388398
......@@ -398,6 +408,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
398408 );
399409 const handleUnsnooze = useCallback(() => onUnsnoozeThread(thread), [onUnsnoozeThread, thread]);
400410 const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]);
411 const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]);
412 const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]);
401413 const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);
402414
403415 // Swipe: the v2 primary action is the lifecycle transition. Every settled
......@@ -434,6 +446,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
434446 })),
435447 [snoozePresets],
436448 );
449 // Pinned cards keep the full lifecycle menu; only the pin item flips to
450 // Unpin. (Settling a pinned thread clears the pin server-side; snoozing
451 // hides the card until wake with the pin intact.)
452 const pinMenuItem = useMemo<MenuAction[]>(
453 () =>
454 props.pinningSupported
455 ? [
456 pinnedRow
457 ? { id: "unpin", title: "Unpin", image: "pin.slash" }
458 : { id: "pin", title: "Pin", image: "pin" },
459 ]
460 : [],
461 [pinnedRow, props.pinningSupported],
462 );
437463 const snoozableCardMenuActions = useMemo<MenuAction[]>(
438464 () => [
439465 { id: "settle", title: "Settle", image: "checkmark" },
......@@ -443,15 +469,22 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
443469 image: "clock",
444470 subactions: snoozePresetActions,
445471 },
472 ...pinMenuItem,
446473 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
447474 ],
448 [snoozePresetActions],
475 [pinMenuItem, snoozePresetActions],
476 );
477 const cardMenuActions = useMemo<MenuAction[]>(
478 () => [CARD_MENU_ACTIONS[0]!, ...pinMenuItem, ...CARD_MENU_ACTIONS.slice(1)],
479 [pinMenuItem],
449480 );
450481 const handleMenuAction = useCallback(
451482 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
452483 if (nativeEvent.event === "settle") handleSettle();
453484 if (nativeEvent.event === "unsettle") handleUnsettle();
454485 if (nativeEvent.event === "unsnooze") handleUnsnooze();
486 if (nativeEvent.event === "pin") handlePin();
487 if (nativeEvent.event === "unpin") handleUnpin();
455488 if (nativeEvent.event === "archive") handleArchive();
456489 if (nativeEvent.event === "delete") handleDelete();
457490 const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({
......@@ -468,8 +501,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
468501 [
469502 handleArchive,
470503 handleDelete,
504 handlePin,
471505 handleSettle,
472506 handleSnooze,
507 handleUnpin,
473508 handleUnsettle,
474509 handleUnsnooze,
475510 snoozePresets,
......@@ -560,6 +595,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
560595 >
561596 {props.projectTitle ?? props.project?.title ?? ""}
562597 </Text>
598 {pinnedRow ? (
599 <SymbolView name="pin" size={11} tintColor={pinTintColor} type="monochrome" />
600 ) : null}
563601 <Text
564602 className={cn(
565603 "text-xs tabular-nums",
......@@ -805,7 +843,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
805843 ? SLIM_MENU_ACTIONS
806844 : swipeActions.secondary === "snooze"
807845 ? snoozableCardMenuActions
808 : CARD_MENU_ACTIONS
846 : cardMenuActions
809847 }
810848 onPressAction={handleMenuAction}
811849 shouldOpenOnLongPress
apps/mobile/src/features/threads/threadListV2.test.ts+51
......@@ -288,6 +288,57 @@ describe("buildThreadListV2Items", () => {
288288 expect(layout.snoozedCount).toBe(1);
289289 });
290290
291 it("renders pinned threads first and exempts them from auto-settle — parity with web", () => {
292 const layout = buildThreadListV2Items({
293 threads: [
294 makeThread({ id: ThreadId.make("active"), title: "Active" }),
295 makeThread({
296 id: ThreadId.make("pinned-settled"),
297 title: "Pinned while settled",
298 pinnedAt: "2026-06-01T12:00:00.000Z",
299 // Stale settled state (the decider clears it on pin): the pin wins.
300 settledOverride: "settled",
301 settledAt: "2026-06-01T12:00:00.000Z",
302 }),
303 ],
304 environmentId: null,
305 searchQuery: "",
306 now: NOW,
307 });
308
309 expect(layout.items.map((item) => item.thread.id)).toEqual(["pinned-settled", "active"]);
310 expect(layout.items.map((item) => item.pinned)).toEqual([true, false]);
311 expect(layout.settledCount).toBe(0);
312 });
313
314 it("snooze hides a pinned thread and wake restores it to the pinned block", () => {
315 const snoozedInput = {
316 threads: [
317 makeThread({ id: ThreadId.make("active"), title: "Active" }),
318 makeThread({
319 id: ThreadId.make("pinned-snoozed"),
320 title: "Pinned and snoozed",
321 pinnedAt: "2026-06-01T12:00:00.000Z",
322 snoozedUntil: "2026-06-03T09:00:00.000Z",
323 snoozedAt: "2026-06-01T11:00:00.000Z",
324 }),
325 ],
326 environmentId: null,
327 searchQuery: "",
328 };
329
330 // Before the wake time: the snooze wins; the pin holds underneath.
331 const whileSnoozed = buildThreadListV2Items({ ...snoozedInput, now: NOW });
332 expect(whileSnoozed.items.map((item) => item.thread.id)).toEqual(["active"]);
333 expect(whileSnoozed.snoozedCount).toBe(1);
334
335 // After the wake time: the thread returns pinned, back on top.
336 const afterWake = buildThreadListV2Items({ ...snoozedInput, now: "2026-06-03T10:00:00.000Z" });
337 expect(afterWake.items.map((item) => item.thread.id)).toEqual(["pinned-snoozed", "active"]);
338 expect(afterWake.items[0]?.pinned).toBe(true);
339 expect(afterWake.snoozedCount).toBe(0);
340 });
341
291342 it("classifies snooze with the second-precise clock and reports the next wake", () => {
292343 const layout = buildThreadListV2Items({
293344 threads: [
apps/mobile/src/features/threads/threadListV2.ts+25-3
......@@ -180,6 +180,8 @@ export interface ThreadListV2Item {
180180 readonly variant: "card" | "slim";
181181 /** Snoozed-shelf row: shows the wake countdown and offers Wake. */
182182 readonly snoozed: boolean;
183 /** Pinned-block row: renders the pin glyph and offers Unpin. */
184 readonly pinned: boolean;
183185 readonly isLast: boolean;
184186}
185187
......@@ -351,6 +353,7 @@ export function buildThreadListV2Items(input: {
351353 ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
352354 : null;
353355
356 const pinned: EnvironmentThreadShell[] = [];
354357 const active: EnvironmentThreadShell[] = [];
355358 const settled: EnvironmentThreadShell[] = [];
356359 const snoozed: EnvironmentThreadShell[] = [];
......@@ -378,9 +381,10 @@ export function buildThreadListV2Items(input: {
378381 const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true;
379382 const changeRequestState =
380383 input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null;
381 // Visibility parity with web: a snoozed thread leaves the list until it
382 // wakes (or raises its hand — effectiveSnoozed refuses blocked/failed
383 // work). Snooze outranks settled classification, same as web.
384 // Visibility parity with web: snooze outranks everything, including a
385 // pin — a snoozed thread leaves the list until it wakes (or raises its
386 // hand). The pin survives underneath, so a woken thread reappears at
387 // its original spot in the creation-ordered pinned block.
384388 if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) {
385389 snoozed.push(thread);
386390 if (
......@@ -392,6 +396,12 @@ export function buildThreadListV2Items(input: {
392396 }
393397 continue;
394398 }
399 // A pin otherwise overrides the lifecycle: pinned threads render above
400 // the inbox and never auto-settle out of sight.
401 if (thread.pinnedAt != null) {
402 pinned.push(thread);
403 continue;
404 }
395405 if (
396406 supportsSettlement &&
397407 effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
......@@ -434,11 +444,21 @@ export function buildThreadListV2Items(input: {
434444 );
435445
436446 const items: ThreadListV2Item[] = [];
447 for (const thread of sortThreadsForListV2(pinned)) {
448 items.push({
449 thread,
450 variant: "card",
451 snoozed: false,
452 pinned: true,
453 isLast: false,
454 });
455 }
437456 for (const thread of orderedActive) {
438457 items.push({
439458 thread,
440459 variant: "card",
441460 snoozed: false,
461 pinned: false,
442462 isLast: false,
443463 });
444464 }
......@@ -448,6 +468,7 @@ export function buildThreadListV2Items(input: {
448468 thread,
449469 variant: "slim",
450470 snoozed: true,
471 pinned: false,
451472 isLast: false,
452473 });
453474 }
......@@ -457,6 +478,7 @@ export function buildThreadListV2Items(input: {
457478 thread,
458479 variant: "slim",
459480 snoozed: false,
481 pinned: false,
460482 isLast: false,
461483 });
462484 }
apps/server/src/environment/ServerEnvironment.ts+1
......@@ -145,6 +145,7 @@ export const make = Effect.gen(function* () {
145145 connectionProbe: true,
146146 threadSettlement: true,
147147 threadSnooze: true,
148 threadPinning: true,
148149 threadTitleRegeneration: true,
149150 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
150151 ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}),
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+31
......@@ -611,6 +611,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
611611 settledAt: null,
612612 snoozedUntil: null,
613613 snoozedAt: null,
614 pinnedAt: null,
614615 titleRegenerationRequestId: null,
615616 titleRegenerationStartedAt: null,
616617 latestUserMessageAt: null,
......@@ -717,6 +718,36 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
717718 return;
718719 }
719720
721 case "thread.pinned": {
722 const existingRow = yield* projectionThreadRepository.getById({
723 threadId: event.payload.threadId,
724 });
725 if (Option.isNone(existingRow)) {
726 return;
727 }
728 yield* projectionThreadRepository.upsert({
729 ...existingRow.value,
730 pinnedAt: event.payload.pinnedAt,
731 updatedAt: event.payload.updatedAt,
732 });
733 return;
734 }
735
736 case "thread.unpinned": {
737 const existingRow = yield* projectionThreadRepository.getById({
738 threadId: event.payload.threadId,
739 });
740 if (Option.isNone(existingRow)) {
741 return;
742 }
743 yield* projectionThreadRepository.upsert({
744 ...existingRow.value,
745 pinnedAt: null,
746 updatedAt: event.payload.updatedAt,
747 });
748 return;
749 }
750
720751 case "thread.meta-updated": {
721752 const existingRow = yield* projectionThreadRepository.getById({
722753 threadId: event.payload.threadId,
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+2
......@@ -312,6 +312,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
312312 settledAt: null,
313313 snoozedUntil: null,
314314 snoozedAt: null,
315 pinnedAt: null,
315316 titleRegeneration: null,
316317 deletedAt: null,
317318 messages: [
......@@ -427,6 +428,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
427428 settledAt: null,
428429 snoozedUntil: null,
429430 snoozedAt: null,
431 pinnedAt: null,
430432 titleRegeneration: null,
431433 session: {
432434 threadId: ThreadId.make("thread-1"),
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+10
......@@ -384,6 +384,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
384384 settled_at AS "settledAt",
385385 snoozed_until AS "snoozedUntil",
386386 snoozed_at AS "snoozedAt",
387 pinned_at AS "pinnedAt",
387388 title_regeneration_request_id AS "titleRegenerationRequestId",
388389 title_regeneration_started_at AS "titleRegenerationStartedAt",
389390 latest_user_message_at AS "latestUserMessageAt",
......@@ -418,6 +419,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
418419 settled_at AS "settledAt",
419420 snoozed_until AS "snoozedUntil",
420421 snoozed_at AS "snoozedAt",
422 pinned_at AS "pinnedAt",
421423 title_regeneration_request_id AS "titleRegenerationRequestId",
422424 title_regeneration_started_at AS "titleRegenerationStartedAt",
423425 latest_user_message_at AS "latestUserMessageAt",
......@@ -454,6 +456,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
454456 settled_at AS "settledAt",
455457 snoozed_until AS "snoozedUntil",
456458 snoozed_at AS "snoozedAt",
459 pinned_at AS "pinnedAt",
457460 title_regeneration_request_id AS "titleRegenerationRequestId",
458461 title_regeneration_started_at AS "titleRegenerationStartedAt",
459462 latest_user_message_at AS "latestUserMessageAt",
......@@ -890,6 +893,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
890893 settled_at AS "settledAt",
891894 snoozed_until AS "snoozedUntil",
892895 snoozed_at AS "snoozedAt",
896 pinned_at AS "pinnedAt",
893897 title_regeneration_request_id AS "titleRegenerationRequestId",
894898 title_regeneration_started_at AS "titleRegenerationStartedAt",
895899 latest_user_message_at AS "latestUserMessageAt",
......@@ -1328,6 +1332,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
13281332 settledAt: row.settledAt,
13291333 snoozedUntil: row.snoozedUntil,
13301334 snoozedAt: row.snoozedAt,
1335 pinnedAt: row.pinnedAt,
13311336 titleRegeneration: mapTitleRegeneration(row),
13321337 deletedAt: row.deletedAt,
13331338 messages: messagesByThread.get(row.threadId) ?? [],
......@@ -1531,6 +1536,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
15311536 settledAt: row.settledAt,
15321537 snoozedUntil: row.snoozedUntil,
15331538 snoozedAt: row.snoozedAt,
1539 pinnedAt: row.pinnedAt,
15341540 titleRegeneration: mapTitleRegeneration(row),
15351541 deletedAt: row.deletedAt,
15361542 messages: [],
......@@ -1665,6 +1671,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
16651671 settledAt: row.settledAt,
16661672 snoozedUntil: row.snoozedUntil,
16671673 snoozedAt: row.snoozedAt,
1674 pinnedAt: row.pinnedAt,
16681675 titleRegeneration: mapTitleRegeneration(row),
16691676 session: sessionByThread.get(row.threadId) ?? null,
16701677 latestUserMessageAt: row.latestUserMessageAt,
......@@ -1804,6 +1811,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
18041811 settledAt: row.settledAt,
18051812 snoozedUntil: row.snoozedUntil,
18061813 snoozedAt: row.snoozedAt,
1814 pinnedAt: row.pinnedAt,
18071815 titleRegeneration: mapTitleRegeneration(row),
18081816 session: sessionByThread.get(row.threadId) ?? null,
18091817 latestUserMessageAt: row.latestUserMessageAt,
......@@ -2075,6 +2083,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
20752083 settledAt: threadRow.value.settledAt,
20762084 snoozedUntil: threadRow.value.snoozedUntil,
20772085 snoozedAt: threadRow.value.snoozedAt,
2086 pinnedAt: threadRow.value.pinnedAt,
20782087 titleRegeneration: mapTitleRegeneration(threadRow.value),
20792088 session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null,
20802089 latestUserMessageAt: threadRow.value.latestUserMessageAt,
......@@ -2174,6 +2183,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
21742183 settledAt: threadRow.value.settledAt,
21752184 snoozedUntil: threadRow.value.snoozedUntil,
21762185 snoozedAt: threadRow.value.snoozedAt,
2186 pinnedAt: threadRow.value.pinnedAt,
21772187 titleRegeneration: mapTitleRegeneration(threadRow.value),
21782188 deletedAt: null,
21792189 messages: messageRows.map((row) => {
apps/server/src/orchestration/Schemas.ts+4
......@@ -13,6 +13,8 @@ import {
1313 ThreadUnsettledPayload as ContractsThreadUnsettledPayloadSchema,
1414 ThreadSnoozedPayload as ContractsThreadSnoozedPayloadSchema,
1515 ThreadUnsnoozedPayload as ContractsThreadUnsnoozedPayloadSchema,
16 ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema,
17 ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema,
1618 ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema,
1719 ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema,
1820 ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema,
......@@ -42,6 +44,8 @@ export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema;
4244export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema;
4345export const ThreadSnoozedPayload = ContractsThreadSnoozedPayloadSchema;
4446export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema;
47export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema;
48export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema;
4549
4650export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;
4751export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema;
apps/server/src/orchestration/decider.pinned.test.ts created+226
......@@ -0,0 +1,226 @@
1import {
2 CommandId,
3 ProjectId,
4 ProviderInstanceId,
5 ThreadId,
6 type OrchestrationReadModel,
7} from "@t3tools/contracts";
8import * as NodeServices from "@effect/platform-node/NodeServices";
9import { expect, it } from "@effect/vitest";
10import * as Effect from "effect/Effect";
11
12import { decideOrchestrationCommand } from "./decider.ts";
13
14const NOW = "2026-01-01T00:00:00.000Z";
15const PINNED_AT = "1969-12-30T00:00:00.000Z";
16
17function makeReadModel(input: {
18 readonly pinnedAt?: string | null;
19 readonly archivedAt?: string | null;
20 readonly settledOverride?: "settled" | "active" | null;
21 readonly settledAt?: string | null;
22 readonly snoozedUntil?: string | null;
23 readonly snoozedAt?: string | null;
24}): OrchestrationReadModel {
25 return {
26 snapshotSequence: 0,
27 projects: [],
28 threads: [
29 {
30 id: ThreadId.make("thread-1"),
31 projectId: ProjectId.make("project-1"),
32 title: "Thread",
33 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
34 runtimeMode: "full-access",
35 interactionMode: "default",
36 branch: null,
37 worktreePath: null,
38 latestTurn: null,
39 createdAt: NOW,
40 updatedAt: NOW,
41 archivedAt: input.archivedAt ?? null,
42 settledOverride: input.settledOverride ?? null,
43 settledAt: input.settledAt ?? (input.settledOverride === "settled" ? NOW : null),
44 snoozedUntil: input.snoozedUntil ?? null,
45 snoozedAt: input.snoozedAt ?? (input.snoozedUntil != null ? PINNED_AT : null),
46 pinnedAt: input.pinnedAt ?? null,
47 deletedAt: null,
48 messages: [],
49 proposedPlans: [],
50 activities: [],
51 checkpoints: [],
52 session: null,
53 },
54 ],
55 updatedAt: NOW,
56 };
57}
58
59it.layer(NodeServices.layer)("pinned thread decider", (it) => {
60 it.effect("pins a thread, stamping pinnedAt and updatedAt together", () =>
61 Effect.gen(function* () {
62 const event = yield* decideOrchestrationCommand({
63 command: {
64 type: "thread.pin",
65 commandId: CommandId.make("cmd-pin"),
66 threadId: ThreadId.make("thread-1"),
67 },
68 readModel: makeReadModel({}),
69 });
70 const events = Array.isArray(event) ? event : [event];
71 expect(events).toHaveLength(1);
72 expect(events[0]?.type).toBe("thread.pinned");
73 if (events[0]?.type === "thread.pinned") {
74 expect(events[0].payload.pinnedAt).toBe(events[0].payload.updatedAt);
75 }
76 }),
77 );
78
79 it.effect("re-pinning preserves the original pinnedAt and updatedAt", () =>
80 Effect.gen(function* () {
81 const event = yield* decideOrchestrationCommand({
82 command: {
83 type: "thread.pin",
84 commandId: CommandId.make("cmd-pin-again"),
85 threadId: ThreadId.make("thread-1"),
86 },
87 readModel: makeReadModel({ pinnedAt: PINNED_AT }),
88 });
89 const events = Array.isArray(event) ? event : [event];
90 expect(events[0]?.type).toBe("thread.pinned");
91 if (events[0]?.type === "thread.pinned") {
92 expect(events[0].payload.pinnedAt).toBe(PINNED_AT);
93 expect(events[0].payload.updatedAt).toBe(NOW);
94 }
95 }),
96 );
97
98 it.effect("unpins a pinned thread", () =>
99 Effect.gen(function* () {
100 const event = yield* decideOrchestrationCommand({
101 command: {
102 type: "thread.unpin",
103 commandId: CommandId.make("cmd-unpin"),
104 threadId: ThreadId.make("thread-1"),
105 },
106 readModel: makeReadModel({ pinnedAt: PINNED_AT }),
107 });
108 const events = Array.isArray(event) ? event : [event];
109 expect(events[0]?.type).toBe("thread.unpinned");
110 if (events[0]?.type === "thread.unpinned") {
111 expect(events[0].payload.updatedAt).not.toBe(NOW);
112 }
113 }),
114 );
115
116 it.effect("unpinning an unpinned thread preserves updatedAt", () =>
117 Effect.gen(function* () {
118 const event = yield* decideOrchestrationCommand({
119 command: {
120 type: "thread.unpin",
121 commandId: CommandId.make("cmd-unpin-noop"),
122 threadId: ThreadId.make("thread-1"),
123 },
124 readModel: makeReadModel({}),
125 });
126 const events = Array.isArray(event) ? event : [event];
127 expect(events[0]?.type).toBe("thread.unpinned");
128 if (events[0]?.type === "thread.unpinned") {
129 expect(events[0].payload.updatedAt).toBe(NOW);
130 }
131 }),
132 );
133
134 it.effect("pinning a settled thread also un-settles it", () =>
135 Effect.gen(function* () {
136 const event = yield* decideOrchestrationCommand({
137 command: {
138 type: "thread.pin",
139 commandId: CommandId.make("cmd-pin-settled"),
140 threadId: ThreadId.make("thread-1"),
141 },
142 readModel: makeReadModel({ settledOverride: "settled" }),
143 });
144 const events = Array.isArray(event) ? event : [event];
145 expect(events.map((entry) => entry.type)).toEqual(["thread.pinned", "thread.unsettled"]);
146 const unsettled = events.find((entry) => entry.type === "thread.unsettled");
147 if (unsettled?.type === "thread.unsettled") {
148 expect(unsettled.payload.reason).toBe("user");
149 }
150 }),
151 );
152
153 it.effect("pinning a snoozed thread also wakes it", () =>
154 Effect.gen(function* () {
155 const event = yield* decideOrchestrationCommand({
156 command: {
157 type: "thread.pin",
158 commandId: CommandId.make("cmd-pin-snoozed"),
159 threadId: ThreadId.make("thread-1"),
160 },
161 readModel: makeReadModel({ snoozedUntil: "1970-01-02T09:00:00.000Z" }),
162 });
163 const events = Array.isArray(event) ? event : [event];
164 expect(events.map((entry) => entry.type)).toEqual(["thread.pinned", "thread.unsnoozed"]);
165 }),
166 );
167
168 it.effect("pinning an unparked thread emits only thread.pinned", () =>
169 Effect.gen(function* () {
170 const event = yield* decideOrchestrationCommand({
171 command: {
172 type: "thread.pin",
173 commandId: CommandId.make("cmd-pin-plain"),
174 threadId: ThreadId.make("thread-1"),
175 },
176 readModel: makeReadModel({}),
177 });
178 const events = Array.isArray(event) ? event : [event];
179 expect(events.map((entry) => entry.type)).toEqual(["thread.pinned"]);
180 }),
181 );
182
183 it.effect("settling a pinned thread also unpins it", () =>
184 Effect.gen(function* () {
185 const event = yield* decideOrchestrationCommand({
186 command: {
187 type: "thread.settle",
188 commandId: CommandId.make("cmd-settle-pinned"),
189 threadId: ThreadId.make("thread-1"),
190 },
191 readModel: makeReadModel({ pinnedAt: PINNED_AT }),
192 });
193 const events = Array.isArray(event) ? event : [event];
194 expect(events.map((entry) => entry.type)).toEqual(["thread.settled", "thread.unpinned"]);
195 }),
196 );
197
198 it.effect("settling an unpinned thread emits no unpin event", () =>
199 Effect.gen(function* () {
200 const event = yield* decideOrchestrationCommand({
201 command: {
202 type: "thread.settle",
203 commandId: CommandId.make("cmd-settle-unpinned"),
204 threadId: ThreadId.make("thread-1"),
205 },
206 readModel: makeReadModel({}),
207 });
208 const events = Array.isArray(event) ? event : [event];
209 expect(events.map((entry) => entry.type)).toEqual(["thread.settled"]);
210 }),
211 );
212
213 it.effect("rejects pinning an archived thread", () =>
214 Effect.gen(function* () {
215 const error = yield* decideOrchestrationCommand({
216 command: {
217 type: "thread.pin",
218 commandId: CommandId.make("cmd-pin-archived"),
219 threadId: ThreadId.make("thread-1"),
220 },
221 readModel: makeReadModel({ archivedAt: NOW }),
222 }).pipe(Effect.flip);
223 expect(error._tag).toBe("OrchestrationCommandInvariantError");
224 }),
225 );
226});
apps/server/src/orchestration/decider.ts+117-2
......@@ -487,14 +487,14 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
487487 // settledAt: the engine rejects zero-event commands, and bulk-settle /
488488 // double-click must stay silent no-ops rather than surface errors.
489489 const alreadySettled = thread.settledOverride === "settled" && thread.settledAt !== null;
490 return {
490 const settledEvent = {
491491 ...(yield* withEventBase({
492492 aggregateKind: "thread",
493493 aggregateId: command.threadId,
494494 occurredAt,
495495 commandId: command.commandId,
496496 })),
497 type: "thread.settled",
497 type: "thread.settled" as const,
498498 payload: {
499499 threadId: command.threadId,
500500 settledAt: alreadySettled ? thread.settledAt : occurredAt,
......@@ -504,6 +504,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
504504 updatedAt: alreadySettled ? thread.updatedAt : occurredAt,
505505 },
506506 };
507 // Settling is "I'm done with this": it clears a pin the same way it
508 // parks the thread. Without this, settling a pinned thread would only
509 // stamp invisible state — the pin would hold the card in place until
510 // a separate unpin.
511 if (thread.pinnedAt == null) {
512 return settledEvent;
513 }
514 return [
515 settledEvent,
516 {
517 ...(yield* withEventBase({
518 aggregateKind: "thread",
519 aggregateId: command.threadId,
520 occurredAt,
521 commandId: command.commandId,
522 })),
523 type: "thread.unpinned" as const,
524 payload: {
525 threadId: command.threadId,
526 updatedAt: occurredAt,
527 },
528 },
529 ];
507530 }
508531
509532 case "thread.unsettle": {
......@@ -630,6 +653,98 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
630653 };
631654 }
632655
656 case "thread.pin": {
657 const thread = yield* requireThreadNotArchived({
658 readModel,
659 command,
660 threadId: command.threadId,
661 });
662 const occurredAt = yield* nowIso;
663 // Re-pinning an already-pinned thread is a duplicate (double-click,
664 // raced clients): re-emit with the original timestamps so the
665 // projection is a no-op. Pinning has no lifecycle invariants — a pin
666 // only ever promotes visibility, so it can never hide pending work.
667 const existingPinnedAt = thread.pinnedAt ?? null;
668 const pinnedEvent = {
669 ...(yield* withEventBase({
670 aggregateKind: "thread",
671 aggregateId: command.threadId,
672 occurredAt,
673 commandId: command.commandId,
674 })),
675 type: "thread.pinned" as const,
676 payload: {
677 threadId: command.threadId,
678 pinnedAt: existingPinnedAt ?? occurredAt,
679 updatedAt: existingPinnedAt !== null ? thread.updatedAt : occurredAt,
680 },
681 };
682 // Pinning is a promotion: it clears the parked states rather than
683 // silently outranking them. An explicit settle un-settles (reason
684 // "user", same override the un-settle button stamps), and a snooze's
685 // return ticket is spent — the thread is on top NOW, not on Tuesday.
686 const promotionEvents: Array<Omit<OrchestrationEvent, "sequence">> = [];
687 if (thread.settledOverride === "settled") {
688 promotionEvents.push({
689 ...(yield* withEventBase({
690 aggregateKind: "thread",
691 aggregateId: command.threadId,
692 occurredAt,
693 commandId: command.commandId,
694 })),
695 type: "thread.unsettled",
696 payload: {
697 threadId: command.threadId,
698 reason: "user",
699 updatedAt: occurredAt,
700 },
701 });
702 }
703 if (thread.snoozedUntil != null) {
704 promotionEvents.push({
705 ...(yield* withEventBase({
706 aggregateKind: "thread",
707 aggregateId: command.threadId,
708 occurredAt,
709 commandId: command.commandId,
710 })),
711 type: "thread.unsnoozed",
712 payload: {
713 threadId: command.threadId,
714 reason: "user",
715 updatedAt: occurredAt,
716 },
717 });
718 }
719 return promotionEvents.length > 0 ? [pinnedEvent, ...promotionEvents] : pinnedEvent;
720 }
721
722 case "thread.unpin": {
723 const thread = yield* requireThreadNotArchived({
724 readModel,
725 command,
726 threadId: command.threadId,
727 });
728 // Idempotent by re-emission (see thread.settle): unpinning a thread
729 // that is not pinned lands on the same null state without churning
730 // updatedAt.
731 const alreadyUnpinned = thread.pinnedAt == null;
732 const occurredAt = yield* nowIso;
733 return {
734 ...(yield* withEventBase({
735 aggregateKind: "thread",
736 aggregateId: command.threadId,
737 occurredAt,
738 commandId: command.commandId,
739 })),
740 type: "thread.unpinned",
741 payload: {
742 threadId: command.threadId,
743 updatedAt: alreadyUnpinned ? thread.updatedAt : occurredAt,
744 },
745 };
746 }
747
633748 case "thread.meta.update": {
634749 const thread = yield* requireThread({
635750 readModel,
apps/server/src/orchestration/projector.pinned.test.ts created+77
......@@ -0,0 +1,77 @@
1import {
2 CommandId,
3 EventId,
4 ProjectId,
5 ThreadId,
6 type OrchestrationEvent,
7} from "@t3tools/contracts";
8import { expect, it } from "@effect/vitest";
9import * as Effect from "effect/Effect";
10
11import { createEmptyReadModel, projectEvent } from "./projector.ts";
12
13function makeEvent(input: {
14 readonly sequence: number;
15 readonly type: OrchestrationEvent["type"];
16 readonly payload: unknown;
17}): OrchestrationEvent {
18 return {
19 sequence: input.sequence,
20 eventId: EventId.make(`event-${input.sequence}`),
21 type: input.type,
22 aggregateKind: "thread",
23 aggregateId: ThreadId.make("thread-1"),
24 occurredAt: "2026-01-01T00:00:00.000Z",
25 commandId: CommandId.make(`command-${input.sequence}`),
26 causationEventId: null,
27 correlationId: null,
28 metadata: {},
29 payload: input.payload as never,
30 } as OrchestrationEvent;
31}
32
33it.effect("projects pin lifecycle events", () =>
34 Effect.gen(function* () {
35 const now = "2026-01-01T00:00:00.000Z";
36 const created = yield* projectEvent(
37 createEmptyReadModel(now),
38 makeEvent({
39 sequence: 1,
40 type: "thread.created",
41 payload: {
42 threadId: ThreadId.make("thread-1"),
43 projectId: ProjectId.make("project-1"),
44 title: "Thread",
45 modelSelection: { provider: "codex", model: "gpt-5.4" },
46 runtimeMode: "full-access",
47 interactionMode: "default",
48 branch: null,
49 worktreePath: null,
50 createdAt: now,
51 updatedAt: now,
52 },
53 }),
54 );
55 expect(created.threads[0]?.pinnedAt ?? null).toBeNull();
56
57 const pinned = yield* projectEvent(
58 created,
59 makeEvent({
60 sequence: 2,
61 type: "thread.pinned",
62 payload: { threadId: ThreadId.make("thread-1"), pinnedAt: now, updatedAt: now },
63 }),
64 );
65 expect(pinned.threads[0]?.pinnedAt).toBe(now);
66
67 const unpinned = yield* projectEvent(
68 pinned,
69 makeEvent({
70 sequence: 3,
71 type: "thread.unpinned",
72 payload: { threadId: ThreadId.make("thread-1"), updatedAt: now },
73 }),
74 );
75 expect(unpinned.threads[0]?.pinnedAt).toBeNull();
76 }),
77);
apps/server/src/orchestration/projector.ts+24
......@@ -23,7 +23,9 @@ import {
2323 ThreadProposedPlanUpsertedPayload,
2424 ThreadRuntimeModeSetPayload,
2525 ThreadSettledPayload,
26 ThreadPinnedPayload,
2627 ThreadSnoozedPayload,
28 ThreadUnpinnedPayload,
2729 ThreadUnarchivedPayload,
2830 ThreadUnsettledPayload,
2931 ThreadUnsnoozedPayload,
......@@ -394,6 +396,28 @@ export function projectEvent(
394396 })),
395397 );
396398
399 case "thread.pinned":
400 return decodeForEvent(ThreadPinnedPayload, event.payload, event.type, "payload").pipe(
401 Effect.map((payload) => ({
402 ...nextBase,
403 threads: updateThread(nextBase.threads, payload.threadId, {
404 pinnedAt: payload.pinnedAt,
405 updatedAt: payload.updatedAt,
406 }),
407 })),
408 );
409
410 case "thread.unpinned":
411 return decodeForEvent(ThreadUnpinnedPayload, event.payload, event.type, "payload").pipe(
412 Effect.map((payload) => ({
413 ...nextBase,
414 threads: updateThread(nextBase.threads, payload.threadId, {
415 pinnedAt: null,
416 updatedAt: payload.updatedAt,
417 }),
418 })),
419 );
420
397421 case "thread.meta-updated":
398422 return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe(
399423 Effect.map((payload) => ({
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+5
......@@ -95,6 +95,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
9595 settledAt: null,
9696 snoozedUntil: null,
9797 snoozedAt: null,
98 pinnedAt: null,
9899 latestUserMessageAt: null,
99100 pendingApprovalCount: 0,
100101 pendingUserInputCount: 0,
......@@ -157,6 +158,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
157158 settledAt: "2026-03-25T00:00:00.000Z",
158159 snoozedUntil: "2026-03-26T09:00:00.000Z",
159160 snoozedAt: "2026-03-25T00:00:00.000Z",
161 pinnedAt: "2026-03-25T00:00:00.000Z",
160162 latestUserMessageAt: null,
161163 pendingApprovalCount: 0,
162164 pendingUserInputCount: 0,
......@@ -175,6 +177,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
175177 assert.strictEqual(row.settledAt, "2026-03-25T00:00:00.000Z");
176178 assert.strictEqual(row.snoozedUntil, "2026-03-26T09:00:00.000Z");
177179 assert.strictEqual(row.snoozedAt, "2026-03-25T00:00:00.000Z");
180 assert.strictEqual(row.pinnedAt, "2026-03-25T00:00:00.000Z");
178181
179182 // Un-settle to the keep-active pin and wake the snooze; confirm the
180183 // flips persist.
......@@ -184,6 +187,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
184187 settledAt: null,
185188 snoozedUntil: null,
186189 snoozedAt: null,
190 pinnedAt: null,
187191 });
188192 const repersisted = yield* threads.getById({
189193 threadId: ThreadId.make("thread-settled"),
......@@ -193,6 +197,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
193197 assert.strictEqual(updated?.settledAt, null);
194198 assert.strictEqual(updated?.snoozedUntil, null);
195199 assert.strictEqual(updated?.snoozedAt, null);
200 assert.strictEqual(updated?.pinnedAt, null);
196201 }),
197202 );
198203});
apps/server/src/persistence/Layers/ProjectionThreads.ts+5
......@@ -47,6 +47,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
4747 settled_at,
4848 snoozed_until,
4949 snoozed_at,
50 pinned_at,
5051 title_regeneration_request_id,
5152 title_regeneration_started_at,
5253 latest_user_message_at,
......@@ -72,6 +73,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
7273 ${row.settledAt},
7374 ${row.snoozedUntil},
7475 ${row.snoozedAt},
76 ${row.pinnedAt},
7577 ${row.titleRegenerationRequestId ?? null},
7678 ${row.titleRegenerationStartedAt ?? null},
7779 ${row.latestUserMessageAt},
......@@ -97,6 +99,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
9799 settled_at = excluded.settled_at,
98100 snoozed_until = excluded.snoozed_until,
99101 snoozed_at = excluded.snoozed_at,
102 pinned_at = excluded.pinned_at,
100103 title_regeneration_request_id = excluded.title_regeneration_request_id,
101104 title_regeneration_started_at = excluded.title_regeneration_started_at,
102105 latest_user_message_at = excluded.latest_user_message_at,
......@@ -129,6 +132,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
129132 settled_at AS "settledAt",
130133 snoozed_until AS "snoozedUntil",
131134 snoozed_at AS "snoozedAt",
135 pinned_at AS "pinnedAt",
132136 title_regeneration_request_id AS "titleRegenerationRequestId",
133137 title_regeneration_started_at AS "titleRegenerationStartedAt",
134138 latest_user_message_at AS "latestUserMessageAt",
......@@ -163,6 +167,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
163167 settled_at AS "settledAt",
164168 snoozed_until AS "snoozedUntil",
165169 snoozed_at AS "snoozedAt",
170 pinned_at AS "pinnedAt",
166171 title_regeneration_request_id AS "titleRegenerationRequestId",
167172 title_regeneration_started_at AS "titleRegenerationStartedAt",
168173 latest_user_message_at AS "latestUserMessageAt",
apps/server/src/persistence/Migrations.ts+2
......@@ -48,6 +48,7 @@ import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts";
4848import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts";
4949import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts";
5050import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts";
51import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts";
5152
5253/**
5354 * Migration loader with all migrations defined inline.
......@@ -95,6 +96,7 @@ export const migrationEntries = [
9596 [33, "ProjectionThreadsSettled", Migration0033],
9697 [34, "ProjectionThreadsSnoozed", Migration0034],
9798 [35, "ProjectionThreadTitleRegeneration", Migration0035],
99 [36, "ProjectionThreadsPinned", Migration0036],
98100] as const;
99101
100102export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
apps/server/src/persistence/Migrations/036_ProjectionThreadsPinned.ts created+16
......@@ -0,0 +1,16 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6 const columns = yield* sql<{ readonly name: string }>`
7 PRAGMA table_info(projection_threads)
8 `;
9
10 if (!columns.some((column) => column.name === "pinned_at")) {
11 yield* sql`
12 ALTER TABLE projection_threads
13 ADD COLUMN pinned_at TEXT
14 `;
15 }
16});
apps/server/src/persistence/Services/ProjectionThreads.ts+1
......@@ -41,6 +41,7 @@ export const ProjectionThread = Schema.Struct({
4141 settledAt: Schema.NullOr(IsoDateTime),
4242 snoozedUntil: Schema.NullOr(IsoDateTime),
4343 snoozedAt: Schema.NullOr(IsoDateTime),
44 pinnedAt: Schema.NullOr(IsoDateTime),
4445 titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)),
4546 titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
4647 latestUserMessageAt: Schema.NullOr(IsoDateTime),
apps/web/src/components/SidebarV2.tsx+187-73
......@@ -28,6 +28,7 @@ import {
2828 GitBranchIcon,
2929 EllipsisIcon,
3030 MessageSquareIcon,
31 PinIcon,
3132 PlusIcon,
3233 SearchIcon,
3334 ServerIcon,
......@@ -394,6 +395,11 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
394395 settlementSupported: boolean;
395396 // Same contract for thread.snooze/unsnooze.
396397 snoozeSupported: boolean;
398 // Renders the pin glyph. Pinned cards keep the full settle/snooze quick
399 // actions: settling clears the pin server-side, and snoozing hides the
400 // card until wake with the pin intact underneath. Pin/unpin themselves
401 // live in the context menu only.
402 isPinned: boolean;
397403 // Compact wake countdown ("2h") for rows in the snoozed shelf.
398404 snoozeWakeLabelText: string | null;
399405 // When a snooze ended (timer or early wake); drives the Woke pill until
......@@ -939,6 +945,13 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
939945 ) : (
940946 <span className="flex-1" />
941947 )}
948 {props.isPinned ? (
949 <PinIcon
950 aria-label="Pinned"
951 role="img"
952 className="size-3 shrink-0 text-muted-foreground/65"
953 />
954 ) : null}
942955 {/* The visible state owns this slot's width: status at rest,
943956 actions on hover/keyboard focus or while the popover is open. Keeping
944957 the hidden state out of flow lets the project label reclaim
......@@ -1186,8 +1199,15 @@ export default function SidebarV2() {
11861199 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
11871200 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
11881201 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
1189 const { settleThread, unsettleThread, snoozeThread, unsnoozeThread, deleteThread } =
1190 useThreadActions();
1202 const {
1203 settleThread,
1204 unsettleThread,
1205 snoozeThread,
1206 unsnoozeThread,
1207 pinThread,
1208 unpinThread,
1209 deleteThread,
1210 } = useThreadActions();
11911211 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
11921212 reportFailure: false,
11931213 });
......@@ -1564,76 +1584,91 @@ export default function SidebarV2() {
15641584 // merging, no optimistic holds. Archived threads remain hidden here —
15651585 // archive keeps its original "remove from sidebar" meaning.
15661586 const serverConfigs = useAtomValue(environmentServerConfigsAtom);
1567 const { activeThreads, snoozedThreads, settledThreads, snoozeNow } = useMemo(() => {
1568 const now = `${nowMinute}:00.000Z`;
1569 // Snooze classification uses a REAL clock, not the quantized minute:
1570 // wake times are second-precise and a woken thread must not linger on
1571 // the shelf for the rest of the minute. snoozeWakeTick re-runs this
1572 // memo exactly at the next wake boundary.
1573 void snoozeWakeTick;
1574 const preciseNow = new Date().toISOString();
1575 const visible = threads.filter(
1576 (thread) =>
1577 thread.archivedAt === null &&
1578 (scopedProjectKeys === null ||
1579 scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)),
1580 );
1581 const active: EnvironmentThreadShell[] = [];
1582 const snoozed: EnvironmentThreadShell[] = [];
1583 const settled: EnvironmentThreadShell[] = [];
1584 for (const thread of visible) {
1585 // Threads on servers without the settlement capability (old server,
1586 // or descriptor not loaded yet) never classify as settled: the user
1587 // could neither un-settle nor pin them, so auto-settling them would
1588 // strand rows in a tail with no working affordances.
1589 const supportsSettlement =
1590 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true;
1591 const supportsSnooze =
1592 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
1593 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
1594 const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null;
1595 // Snooze outranks settled classification: an explicitly snoozed thread
1596 // belongs to the shelf even if it would also auto-settle (the shelf's
1597 // wake time is a stronger statement about when it matters again).
1598 if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) {
1599 snoozed.push(thread);
1600 } else if (
1601 supportsSettlement &&
1602 effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
1603 ) {
1604 settled.push(thread);
1605 } else {
1606 active.push(thread);
1587 const { pinnedThreads, activeThreads, snoozedThreads, settledThreads, snoozeNow } =
1588 useMemo(() => {
1589 const now = `${nowMinute}:00.000Z`;
1590 // Snooze classification uses a REAL clock, not the quantized minute:
1591 // wake times are second-precise and a woken thread must not linger on
1592 // the shelf for the rest of the minute. snoozeWakeTick re-runs this
1593 // memo exactly at the next wake boundary.
1594 void snoozeWakeTick;
1595 const preciseNow = new Date().toISOString();
1596 const visible = threads.filter(
1597 (thread) =>
1598 thread.archivedAt === null &&
1599 (scopedProjectKeys === null ||
1600 scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)),
1601 );
1602 const pinned: EnvironmentThreadShell[] = [];
1603 const active: EnvironmentThreadShell[] = [];
1604 const snoozed: EnvironmentThreadShell[] = [];
1605 const settled: EnvironmentThreadShell[] = [];
1606 for (const thread of visible) {
1607 // Threads on servers without the settlement capability (old server,
1608 // or descriptor not loaded yet) never classify as settled: the user
1609 // could neither un-settle nor pin them, so auto-settling them would
1610 // strand rows in a tail with no working affordances.
1611 const supportsSettlement =
1612 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement ===
1613 true;
1614 const supportsSnooze =
1615 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
1616 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
1617 const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null;
1618 // Snooze outranks everything, including a pin: "hide until Tuesday"
1619 // temporarily suspends "keep on top". The pin survives underneath —
1620 // pinned cards are creation-ordered, so on wake the thread reappears
1621 // at its original spot in the pinned block. (For unpinned threads
1622 // this is also the snooze-beats-auto-settle rule: the wake time is a
1623 // stronger statement about when the thread matters again.)
1624 if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) {
1625 snoozed.push(thread);
1626 // A pin otherwise overrides the lifecycle: pinned threads never
1627 // auto-settle out of sight. (The decider clears settled state on
1628 // pin and the pin on settle, so pin-vs-settled conflicts only
1629 // arise from stale or raced writes.)
1630 } else if (thread.pinnedAt != null) {
1631 pinned.push(thread);
1632 } else if (
1633 supportsSettlement &&
1634 effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
1635 ) {
1636 settled.push(thread);
1637 } else {
1638 active.push(thread);
1639 }
16071640 }
1608 }
1609 return {
1610 activeThreads: sortThreadsForSidebarV2(active),
1611 // Soonest wake first: "what comes back next" is the shelf's question.
1612 snoozedThreads: snoozed.toSorted(
1613 (left, right) =>
1614 firstValidTimestampMs(left.snoozedUntil ?? null) -
1615 firstValidTimestampMs(right.snoozedUntil ?? null),
1616 ),
1617 settledThreads: sortSettledThreadsForSidebarV2(settled),
1618 snoozeNow: preciseNow,
1619 };
1620 }, [
1621 autoSettleAfterDays,
1622 changeRequestStateByKey,
1623 nowMinute,
1624 scopedProjectKeys,
1625 serverConfigs,
1626 snoozeWakeTick,
1627 threads,
1628 ]);
1641 return {
1642 // Same static creation order as the inbox: a pin freezes prominence,
1643 // it does not introduce a new ordering scheme.
1644 pinnedThreads: sortThreadsForSidebarV2(pinned),
1645 activeThreads: sortThreadsForSidebarV2(active),
1646 // Soonest wake first: "what comes back next" is the shelf's question.
1647 snoozedThreads: snoozed.toSorted(
1648 (left, right) =>
1649 firstValidTimestampMs(left.snoozedUntil ?? null) -
1650 firstValidTimestampMs(right.snoozedUntil ?? null),
1651 ),
1652 settledThreads: sortSettledThreadsForSidebarV2(settled),
1653 snoozeNow: preciseNow,
1654 };
1655 }, [
1656 autoSettleAfterDays,
1657 changeRequestStateByKey,
1658 nowMinute,
1659 scopedProjectKeys,
1660 serverConfigs,
1661 snoozeWakeTick,
1662 threads,
1663 ]);
16291664
16301665 const threadSearchInputRef = useRef<HTMLInputElement>(null);
16311666 const [threadSearchQuery, setThreadSearchQuery] = useState("");
16321667 const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0);
16331668 const isSearchingThreads = threadSearchQuery.trim().length > 0;
16341669 const searchableThreads = useMemo(
1635 () => [...activeThreads, ...snoozedThreads, ...settledThreads],
1636 [activeThreads, settledThreads, snoozedThreads],
1670 () => [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads],
1671 [activeThreads, pinnedThreads, settledThreads, snoozedThreads],
16371672 );
16381673 const threadSearchResults = useMemo(
16391674 () => searchSidebarThreadsByTitle(searchableThreads, threadSearchQuery),
......@@ -1737,8 +1772,8 @@ export default function SidebarV2() {
17371772 }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]);
17381773
17391774 const orderedThreads = useMemo(
1740 () => [...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads],
1741 [activeThreads, visibleSnoozedThreads, renderedSettledThreads],
1775 () => [...pinnedThreads, ...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads],
1776 [pinnedThreads, activeThreads, visibleSnoozedThreads, renderedSettledThreads],
17421777 );
17431778 const orderedThreadKeys = useMemo(
17441779 () =>
......@@ -2040,6 +2075,42 @@ export default function SidebarV2() {
20402075 },
20412076 [unsnoozeThread],
20422077 );
2078 const attemptPin = useCallback(
2079 (threadRef: ScopedThreadRef) => {
2080 void (async () => {
2081 const result = await pinThread(threadRef);
2082 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2083 const error = squashAtomCommandFailure(result);
2084 toastManager.add(
2085 stackedThreadToast({
2086 type: "error",
2087 title: "Failed to pin thread",
2088 description: error instanceof Error ? error.message : "An error occurred.",
2089 }),
2090 );
2091 }
2092 })();
2093 },
2094 [pinThread],
2095 );
2096 const attemptUnpin = useCallback(
2097 (threadRef: ScopedThreadRef) => {
2098 void (async () => {
2099 const result = await unpinThread(threadRef);
2100 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2101 const error = squashAtomCommandFailure(result);
2102 toastManager.add(
2103 stackedThreadToast({
2104 type: "error",
2105 title: "Failed to unpin thread",
2106 description: error instanceof Error ? error.message : "An error occurred.",
2107 }),
2108 );
2109 }
2110 })();
2111 },
2112 [unpinThread],
2113 );
20432114 // One snooze per thread at a time — same double-dispatch guard as settle.
20442115 const snoozingThreadKeysRef = useRef(new Set<string>());
20452116 const attemptSnooze = useCallback(
......@@ -2203,7 +2274,8 @@ export default function SidebarV2() {
22032274 // Post-settle navigation must skip threads settling in this same
22042275 // batch — they are all leaving the card block together. Rows that
22052276 // are already explicitly settled are skipped: nothing to do on a
2206 // valid mixed selection.
2277 // valid mixed selection. Pinned rows ARE included: the decider
2278 // clears the pin as part of settling, so they park like the rest.
22072279 const coSettlingKeys = new Set(threadKeys);
22082280 for (const threadKey of threadKeys) {
22092281 const thread = threadByKeyRef.current.get(threadKey);
......@@ -2300,12 +2372,15 @@ export default function SidebarV2() {
23002372 true;
23012373 const supportsSnooze =
23022374 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
2375 const supportsPinning =
2376 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinning === true;
23032377 const supportsTitleRegeneration =
23042378 serverConfigs.get(thread.environmentId)?.environment.capabilities
23052379 .threadTitleRegeneration === true;
23062380 const isRegeneratingTitle = thread.titleRegeneration != null;
23072381 const isSettled = settledThreadKeysRef.current.has(threadKey);
23082382 const isSnoozed = snoozedThreadKeysRef.current.has(threadKey);
2383 const isPinned = thread.pinnedAt != null;
23092384 // Presets resolve at menu-open time (same as the popover).
23102385 const snoozePresets = resolveSnoozePresets(new Date());
23112386 const clicked = await settlePromise(() =>
......@@ -2319,6 +2394,16 @@ export default function SidebarV2() {
23192394 },
23202395 ]
23212396 : []),
2397 ...(supportsPinning
2398 ? [
2399 isPinned
2400 ? { id: "unpin", label: "Unpin thread" }
2401 : { id: "pin", label: "Pin thread" },
2402 ]
2403 : []),
2404 // Both lifecycle actions stay available on pinned threads:
2405 // settling clears the pin ("done" beats "keep on top"), and
2406 // snoozing hides the card until wake with the pin intact.
23222407 ...(supportsSettlement
23232408 ? [
23242409 isSettled
......@@ -2400,6 +2485,12 @@ export default function SidebarV2() {
24002485 case "unsnooze":
24012486 attemptUnsnooze(threadRef);
24022487 return;
2488 case "pin":
2489 attemptPin(threadRef);
2490 return;
2491 case "unpin":
2492 attemptUnpin(threadRef);
2493 return;
24032494 case "rename":
24042495 startThreadRename(threadRef, thread.title);
24052496 return;
......@@ -2474,8 +2565,10 @@ export default function SidebarV2() {
24742565 })();
24752566 },
24762567 [
2568 attemptPin,
24772569 attemptSettle,
24782570 attemptSnooze,
2571 attemptUnpin,
24792572 attemptUnsettle,
24802573 attemptUnsnooze,
24812574 confirmThreadDelete,
......@@ -2830,7 +2923,7 @@ export default function SidebarV2() {
28302923 {(() => {
28312924 const renderThreadRow = (
28322925 thread: EnvironmentThreadShell,
2833 section: "active" | "snoozed" | "settled",
2926 section: "pinned" | "active" | "snoozed" | "settled",
28342927 ) => {
28352928 const threadKey = scopedThreadKey(
28362929 scopeThreadRef(thread.environmentId, thread.id),
......@@ -2839,7 +2932,7 @@ export default function SidebarV2() {
28392932 // row: every other thread is a full card. Density comes
28402933 // from users (or the auto rules) actually parking work,
28412934 // not from the sidebar second-guessing what still matters.
2842 const isCard = section === "active";
2935 const isCard = section === "active" || section === "pinned";
28432936 const rowVariant = isCard ? "card" : "slim";
28442937 return (
28452938 <SidebarV2Row
......@@ -2870,6 +2963,7 @@ export default function SidebarV2() {
28702963 serverConfigs.get(thread.environmentId)?.environment.capabilities
28712964 .threadSnooze === true
28722965 }
2966 isPinned={section === "pinned"}
28732967 snoozeWakeLabelText={
28742968 section === "snoozed" && thread.snoozedUntil != null
28752969 ? snoozeWakeLabel(thread.snoozedUntil, {
......@@ -2912,9 +3006,25 @@ export default function SidebarV2() {
29123006 />
29133007 );
29143008 };
2915 const items: ReactNode[] = activeThreads.map((thread) =>
2916 renderThreadRow(thread, "active"),
3009 // Pinned block: full cards above the inbox, closed by a
3010 // thin divider (the pin glyphs carry the meaning, so no
3011 // header text). Vanishes entirely at count 0.
3012 const items: ReactNode[] = pinnedThreads.map((thread) =>
3013 renderThreadRow(thread, "pinned"),
29173014 );
3015 if (pinnedThreads.length > 0) {
3016 items.push(
3017 <li
3018 key="pinned-divider"
3019 aria-hidden
3020 data-testid="sidebar-v2-pinned-divider"
3021 className="mx-2.5 my-1.5 h-px list-none bg-sidebar-border/60"
3022 />,
3023 );
3024 }
3025 for (const thread of activeThreads) {
3026 items.push(renderThreadRow(thread, "active"));
3027 }
29183028 // Snoozed shelf: between the inbox and Settled — out of the
29193029 // way, never gone. The header always renders while anything
29203030 // is snoozed (the count is the whole footprint when
......@@ -3006,7 +3116,11 @@ export default function SidebarV2() {
30063116 </TooltipProvider>
30073117 ) : null}
30083118 {!isSearchingThreads &&
3009 activeThreads.length + snoozedThreads.length + settledThreads.length === 0 ? (
3119 pinnedThreads.length +
3120 activeThreads.length +
3121 snoozedThreads.length +
3122 settledThreads.length ===
3123 0 ? (
30103124 <div className="flex flex-col items-center gap-2 px-2 py-6 text-center text-xs text-muted-foreground/60">
30113125 {projects.length === 0 ? (
30123126 <>
apps/web/src/hooks/useThreadActions.ts+64
......@@ -21,6 +21,7 @@ import { useNewThreadHandler } from "./useHandleNewThread";
2121import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState";
2222import { readLocalApi } from "../localApi";
2323import {
24 readEnvironmentSupportsPinning,
2425 readEnvironmentSupportsSettlement,
2526 readEnvironmentSupportsSnooze,
2627 readEnvironmentThreadRefs,
......@@ -94,6 +95,18 @@ export class ThreadSnoozeBlockedError extends Schema.TaggedErrorClass<ThreadSnoo
9495 }
9596}
9697
98export class ThreadPinningUnsupportedError extends Schema.TaggedErrorClass<ThreadPinningUnsupportedError>()(
99 "ThreadPinningUnsupportedError",
100 {
101 environmentId: EnvironmentId,
102 threadId: ThreadId,
103 },
104) {
105 override get message(): string {
106 return "This environment's server does not support pinning yet. Update the server to use Pin.";
107 }
108}
109
97110export function useThreadActions() {
98111 const closeTerminal = useAtomCommand(terminalEnvironment.close);
99112 const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, {
......@@ -111,6 +124,12 @@ export function useThreadActions() {
111124 const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, {
112125 reportFailure: false,
113126 });
127 const pinThreadMutation = useAtomCommand(threadEnvironment.pin, {
128 reportFailure: false,
129 });
130 const unpinThreadMutation = useAtomCommand(threadEnvironment.unpin, {
131 reportFailure: false,
132 });
114133 const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, {
115134 reportFailure: false,
116135 });
......@@ -472,6 +491,47 @@ export function useThreadActions() {
472491 [unsettleThreadMutation],
473492 );
474493
494 const pinThread = useCallback(
495 async (target: ScopedThreadRef) => {
496 // Version skew: never send the command to a server that predates it.
497 if (!readEnvironmentSupportsPinning(target.environmentId)) {
498 return AsyncResult.failure(
499 Cause.fail(
500 new ThreadPinningUnsupportedError({
501 environmentId: target.environmentId,
502 threadId: target.threadId,
503 }),
504 ),
505 );
506 }
507 return pinThreadMutation({
508 environmentId: target.environmentId,
509 input: { threadId: target.threadId },
510 });
511 },
512 [pinThreadMutation],
513 );
514
515 const unpinThread = useCallback(
516 async (target: ScopedThreadRef) => {
517 if (!readEnvironmentSupportsPinning(target.environmentId)) {
518 return AsyncResult.failure(
519 Cause.fail(
520 new ThreadPinningUnsupportedError({
521 environmentId: target.environmentId,
522 threadId: target.threadId,
523 }),
524 ),
525 );
526 }
527 return unpinThreadMutation({
528 environmentId: target.environmentId,
529 input: { threadId: target.threadId },
530 });
531 },
532 [unpinThreadMutation],
533 );
534
475535 const snoozeThread = useCallback(
476536 async (target: ScopedThreadRef, snoozedUntil: string) => {
477537 // Version skew: never send the command to a server that predates it.
......@@ -565,14 +625,18 @@ export function useThreadActions() {
565625 unsettleThread,
566626 snoozeThread,
567627 unsnoozeThread,
628 pinThread,
629 unpinThread,
568630 }),
569631 [
570632 archiveThread,
571633 confirmAndDeleteThread,
572634 deleteThread,
635 pinThread,
573636 settleThread,
574637 snoozeThread,
575638 unarchiveThread,
639 unpinThread,
576640 unsettleThread,
577641 unsnoozeThread,
578642 ],
apps/web/src/state/entities.ts+9
......@@ -241,6 +241,15 @@ export function readEnvironmentSupportsSnooze(environmentId: EnvironmentId): boo
241241 );
242242}
243243
244/** Whether the environment's server understands thread.pin/unpin.
245 Same version-skew contract as settlement. */
246export function readEnvironmentSupportsPinning(environmentId: EnvironmentId): boolean {
247 return (
248 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
249 .threadPinning === true
250 );
251}
252
244253export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null {
245254 return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref));
246255}
packages/client-runtime/src/operations/commands.ts+22
......@@ -39,6 +39,8 @@ export type SettleThreadInput = CommandInput<"thread.settle">;
3939export type UnsettleThreadInput = CommandInput<"thread.unsettle">;
4040export type SnoozeThreadInput = CommandInput<"thread.snooze">;
4141export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">;
42export type PinThreadInput = CommandInput<"thread.pin">;
43export type UnpinThreadInput = CommandInput<"thread.unpin">;
4244export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">;
4345export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">;
4446export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">;
......@@ -197,6 +199,26 @@ export const unsnoozeThread: (input: UnsnoozeThreadInput) => CommandEffect = Eff
197199 });
198200});
199201
202export const pinThread: (input: PinThreadInput) => CommandEffect = Effect.fn(
203 "EnvironmentCommands.pinThread",
204)(function* (input) {
205 return yield* dispatch({
206 ...input,
207 type: "thread.pin",
208 commandId: yield* commandId(input),
209 });
210});
211
212export const unpinThread: (input: UnpinThreadInput) => CommandEffect = Effect.fn(
213 "EnvironmentCommands.unpinThread",
214)(function* (input) {
215 return yield* dispatch({
216 ...input,
217 type: "thread.unpin",
218 commandId: yield* commandId(input),
219 });
220});
221
200222export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn(
201223 "EnvironmentCommands.updateThreadMetadata",
202224)(function* (input) {
packages/client-runtime/src/state/threadCommands.ts+18
......@@ -12,11 +12,13 @@ import {
1212 type RevertThreadCheckpointInput,
1313 type SetThreadInteractionModeInput,
1414 type SetThreadRuntimeModeInput,
15 type PinThreadInput,
1516 type SettleThreadInput,
1617 type SnoozeThreadInput,
1718 type StartThreadTurnInput,
1819 type StopThreadSessionInput,
1920 type UnarchiveThreadInput,
21 type UnpinThreadInput,
2022 type UnsettleThreadInput,
2123 type UnsnoozeThreadInput,
2224 type UpdateThreadMetadataInput,
......@@ -29,11 +31,13 @@ import {
2931 revertThreadCheckpoint,
3032 setThreadInteractionMode,
3133 setThreadRuntimeMode,
34 pinThread,
3235 settleThread,
3336 snoozeThread,
3437 startThreadTurn,
3538 stopThreadSession,
3639 unarchiveThread,
40 unpinThread,
3741 unsettleThread,
3842 unsnoozeThread,
3943 updateThreadMetadata,
......@@ -50,11 +54,13 @@ export type {
5054 RevertThreadCheckpointInput,
5155 SetThreadInteractionModeInput,
5256 SetThreadRuntimeModeInput,
57 PinThreadInput,
5358 SettleThreadInput,
5459 SnoozeThreadInput,
5560 StartThreadTurnInput,
5661 StopThreadSessionInput,
5762 UnarchiveThreadInput,
63 UnpinThreadInput,
5864 UnsettleThreadInput,
5965 UnsnoozeThreadInput,
6066 UpdateThreadMetadataInput,
......@@ -118,6 +124,18 @@ export function createThreadEnvironmentAtoms<R, E>(
118124 scheduler,
119125 concurrency,
120126 }),
127 pin: createEnvironmentCommand(runtime, {
128 label: "environment-data:commands:thread:pin",
129 execute: (input: PinThreadInput) => pinThread(input),
130 scheduler,
131 concurrency,
132 }),
133 unpin: createEnvironmentCommand(runtime, {
134 label: "environment-data:commands:thread:unpin",
135 execute: (input: UnpinThreadInput) => unpinThread(input),
136 scheduler,
137 concurrency,
138 }),
121139 updateMetadata: createEnvironmentCommand(runtime, {
122140 label: "environment-data:commands:thread:update-metadata",
123141 execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input),
packages/client-runtime/src/state/threadDetail.ts+1
......@@ -61,6 +61,7 @@ export function mergeEnvironmentThread(
6161 settledAt: shell.settledAt,
6262 snoozedUntil: shell.snoozedUntil,
6363 snoozedAt: shell.snoozedAt,
64 pinnedAt: shell.pinnedAt,
6465 session: shell.session,
6566 };
6667}
packages/client-runtime/src/state/threadReducer.test.ts+49
......@@ -231,6 +231,55 @@ describe("applyThreadDetailEvent", () => {
231231 });
232232 });
233233
234 describe("thread.pinned / thread.unpinned", () => {
235 it("sets pinnedAt", () => {
236 const pinnedAt = "2026-04-01T05:00:00.000Z";
237 const result = applyThreadDetailEvent(baseThread, {
238 ...baseEventFields,
239 sequence: 5,
240 occurredAt: pinnedAt,
241 aggregateKind: "thread",
242 aggregateId: ThreadId.make("thread-1"),
243 type: "thread.pinned",
244 payload: {
245 threadId: ThreadId.make("thread-1"),
246 pinnedAt,
247 updatedAt: pinnedAt,
248 },
249 });
250
251 expect(result.kind).toBe("updated");
252 if (result.kind === "updated") {
253 expect(result.thread.pinnedAt).toBe(pinnedAt);
254 }
255 });
256
257 it("clears pinnedAt", () => {
258 const pinnedThread: OrchestrationThread = {
259 ...baseThread,
260 pinnedAt: "2026-04-01T05:00:00.000Z",
261 };
262 const updatedAt = "2026-04-01T06:00:00.000Z";
263 const result = applyThreadDetailEvent(pinnedThread, {
264 ...baseEventFields,
265 sequence: 6,
266 occurredAt: updatedAt,
267 aggregateKind: "thread",
268 aggregateId: ThreadId.make("thread-1"),
269 type: "thread.unpinned",
270 payload: {
271 threadId: ThreadId.make("thread-1"),
272 updatedAt,
273 },
274 });
275
276 expect(result.kind).toBe("updated");
277 if (result.kind === "updated") {
278 expect(result.thread.pinnedAt).toBeNull();
279 }
280 });
281 });
282
234283 describe("thread.meta-updated", () => {
235284 it("patches title and branch", () => {
236285 const result = applyThreadDetailEvent(baseThread, {
packages/client-runtime/src/state/threadReducer.ts+20
......@@ -167,6 +167,26 @@ export function applyThreadDetailEvent(
167167 },
168168 };
169169
170 case "thread.pinned":
171 return {
172 kind: "updated",
173 thread: {
174 ...thread,
175 pinnedAt: event.payload.pinnedAt,
176 updatedAt: event.payload.updatedAt,
177 },
178 };
179
180 case "thread.unpinned":
181 return {
182 kind: "updated",
183 thread: {
184 ...thread,
185 pinnedAt: null,
186 updatedAt: event.payload.updatedAt,
187 },
188 };
189
170190 // ── Thread metadata ─────────────────────────────────────────────
171191 case "thread.meta-updated":
172192 return {
packages/contracts/src/environment.ts+3
......@@ -47,6 +47,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
4747 /** Server understands thread.snooze / thread.unsnooze commands. Same
4848 version-skew contract as threadSettlement. */
4949 threadSnooze: Schema.optionalKey(Schema.Boolean),
50 /** Server understands thread.pin / thread.unpin commands. Same
51 version-skew contract as threadSettlement. */
52 threadPinning: Schema.optionalKey(Schema.Boolean),
5053 /** Server understands regenerateTitle on thread.meta.update. Absent on
5154 older servers, so clients hide the action instead of sending it. */
5255 threadTitleRegeneration: Schema.optionalKey(Schema.Boolean),
packages/contracts/src/orchestration.ts+44
......@@ -373,6 +373,10 @@ export const OrchestrationThread = Schema.Struct({
373373 // Optional so payloads from pre-snooze servers still decode.
374374 snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)),
375375 snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
376 // A pin overrides the settled/snoozed lifecycle: while pinnedAt is set the
377 // thread renders in the pinned block and never classifies into a shelf.
378 // Optional so payloads from pre-pinning servers still decode.
379 pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
376380 // Pending-only state. Optional so older servers remain compatible.
377381 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),
378382 deletedAt: Schema.NullOr(IsoDateTime),
......@@ -427,6 +431,7 @@ export const OrchestrationThreadShell = Schema.Struct({
427431 settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))),
428432 snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)),
429433 snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
434 pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
430435 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),
431436 session: Schema.NullOr(OrchestrationSession),
432437 latestUserMessageAt: Schema.NullOr(IsoDateTime),
......@@ -621,6 +626,18 @@ const ThreadUnsnoozeCommand = Schema.Struct({
621626 reason: Schema.Literal("user"),
622627});
623628
629const ThreadPinCommand = Schema.Struct({
630 type: Schema.Literal("thread.pin"),
631 commandId: CommandId,
632 threadId: ThreadId,
633});
634
635const ThreadUnpinCommand = Schema.Struct({
636 type: Schema.Literal("thread.unpin"),
637 commandId: CommandId,
638 threadId: ThreadId,
639});
640
624641const ThreadMetaUpdateCommand = Schema.Struct({
625642 type: Schema.Literal("thread.meta.update"),
626643 commandId: CommandId,
......@@ -774,6 +791,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([
774791 ThreadUnsettleCommand,
775792 ThreadSnoozeCommand,
776793 ThreadUnsnoozeCommand,
794 ThreadPinCommand,
795 ThreadUnpinCommand,
777796 ThreadMetaUpdateCommand,
778797 ThreadRuntimeModeSetCommand,
779798 ThreadInteractionModeSetCommand,
......@@ -799,6 +818,8 @@ export const ClientOrchestrationCommand = Schema.Union([
799818 ThreadUnsettleCommand,
800819 ThreadSnoozeCommand,
801820 ThreadUnsnoozeCommand,
821 ThreadPinCommand,
822 ThreadUnpinCommand,
802823 ThreadMetaUpdateCommand,
803824 ThreadRuntimeModeSetCommand,
804825 ThreadInteractionModeSetCommand,
......@@ -914,6 +935,8 @@ export const OrchestrationEventType = Schema.Literals([
914935 "thread.unsettled",
915936 "thread.snoozed",
916937 "thread.unsnoozed",
938 "thread.pinned",
939 "thread.unpinned",
917940 "thread.meta-updated",
918941 "thread.runtime-mode-set",
919942 "thread.interaction-mode-set",
......@@ -1022,6 +1045,17 @@ export const ThreadUnsnoozedPayload = Schema.Struct({
10221045 updatedAt: IsoDateTime,
10231046});
10241047
1048export const ThreadPinnedPayload = Schema.Struct({
1049 threadId: ThreadId,
1050 pinnedAt: IsoDateTime,
1051 updatedAt: IsoDateTime,
1052});
1053
1054export const ThreadUnpinnedPayload = Schema.Struct({
1055 threadId: ThreadId,
1056 updatedAt: IsoDateTime,
1057});
1058
10251059export const ThreadMetaUpdatedPayload = Schema.Struct({
10261060 threadId: ThreadId,
10271061 title: Schema.optional(TrimmedNonEmptyString),
......@@ -1216,6 +1250,16 @@ export const OrchestrationEvent = Schema.Union([
12161250 type: Schema.Literal("thread.unsnoozed"),
12171251 payload: ThreadUnsnoozedPayload,
12181252 }),
1253 Schema.Struct({
1254 ...EventBaseFields,
1255 type: Schema.Literal("thread.pinned"),
1256 payload: ThreadPinnedPayload,
1257 }),
1258 Schema.Struct({
1259 ...EventBaseFields,
1260 type: Schema.Literal("thread.unpinned"),
1261 payload: ThreadUnpinnedPayload,
1262 }),
12191263 Schema.Struct({
12201264 ...EventBaseFields,
12211265 type: Schema.Literal("thread.meta-updated"),