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 {...@@ -50,6 +50,8 @@ import {
50 IconMinus,50 IconMinus,
51 IconNetwork,51 IconNetwork,
52 IconPalette,52 IconPalette,
53 IconPin,
54 IconPinnedOff,
53 IconPlayerPlay,55 IconPlayerPlay,
54 IconPlayerStopFilled,56 IconPlayerStopFilled,
55 IconPlus,57 IconPlus,
...@@ -121,6 +123,8 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {...@@ -121,6 +123,8 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
121 magnifyingglass: IconSearch,123 magnifyingglass: IconSearch,
122 paintbrush: IconPalette,124 paintbrush: IconPalette,
123 "person.crop.circle": IconUserCircle,125 "person.crop.circle": IconUserCircle,
126 pin: IconPin,
127 "pin.slash": IconPinnedOff,
124 play: IconPlayerPlay,128 play: IconPlayerPlay,
125 plus: IconPlus,129 plus: IconPlus,
126 "qrcode.viewfinder": IconQrcode,130 "qrcode.viewfinder": IconQrcode,
apps/mobile/src/features/home/HomeRouteScreen.tsx+4
...@@ -42,6 +42,8 @@ export function HomeRouteScreen() {...@@ -42,6 +42,8 @@ export function HomeRouteScreen() {
42 settleThread,42 settleThread,
43 snoozeThread,43 snoozeThread,
44 unsnoozeThread,44 unsnoozeThread,
45 pinThread,
46 unpinThread,
45 unsettleThread,47 unsettleThread,
46 } = useThreadListActions();48 } = useThreadListActions();
47 const pendingTasks = usePendingNewTasks();49 const pendingTasks = usePendingNewTasks();
...@@ -155,6 +157,8 @@ export function HomeRouteScreen() {...@@ -155,6 +157,8 @@ export function HomeRouteScreen() {
155 onSnoozeThread={snoozeThread}157 onSnoozeThread={snoozeThread}
156 onUnsnoozeThread={unsnoozeThread}158 onUnsnoozeThread={unsnoozeThread}
157 onUnsettleThread={unsettleThread}159 onUnsettleThread={unsettleThread}
160 onPinThread={pinThread}
161 onUnpinThread={unpinThread}
158 onEnvironmentChange={setSelectedEnvironmentId}162 onEnvironmentChange={setSelectedEnvironmentId}
159 onProjectChange={setSelectedProjectKey}163 onProjectChange={setSelectedProjectKey}
160 onOpenEnvironments={() =>164 onOpenEnvironments={() =>
apps/mobile/src/features/home/HomeScreen.tsx+30
...@@ -111,6 +111,8 @@ interface HomeScreenProps {...@@ -111,6 +111,8 @@ interface HomeScreenProps {
111 ) => Promise<boolean>;111 ) => Promise<boolean>;
112 readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => Promise<boolean>;112 readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
113 readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;113 readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
114 readonly onPinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
115 readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
114 readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;116 readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
115 readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;117 readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
116 readonly onNewThreadInProject: (project: EnvironmentProject) => void;118 readonly onNewThreadInProject: (project: EnvironmentProject) => void;
...@@ -516,6 +518,18 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -516,6 +518,18 @@ export function HomeScreen(props: HomeScreenProps) {
516 },518 },
517 [props.onUnsnoozeThread],519 [props.onUnsnoozeThread],
518 );520 );
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 );
519 const handleDeleteThread = props.onDeleteThread;533 const handleDeleteThread = props.onDeleteThread;
520 const handleUnsettleThread = props.onUnsettleThread;534 const handleUnsettleThread = props.onUnsettleThread;
521 // The settled tail renders in pages; expansion resets when the filter535 // The settled tail renders in pages; expansion resets when the filter
...@@ -575,6 +589,15 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -575,6 +589,15 @@ export function HomeScreen(props: HomeScreenProps) {
575 }589 }
576 return supported;590 return supported;
577 }, [serverConfigs]);591 }, [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]);
578 const threadListV2Layout = useMemo(() => {601 const threadListV2Layout = useMemo(() => {
579 if (!threadListV2Enabled)602 if (!threadListV2Enabled)
580 return {603 return {
...@@ -718,6 +741,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -718,6 +741,7 @@ export function HomeScreen(props: HomeScreenProps) {
718 thread={thread}741 thread={thread}
719 variant={item.item.variant}742 variant={item.item.variant}
720 snoozed={item.item.snoozed}743 snoozed={item.item.snoozed}
744 pinned={item.item.pinned}
721 snoozePresetMinute={nowMinute}745 snoozePresetMinute={nowMinute}
722 snoozeWakeLabelText={item.snoozeWakeLabelText}746 snoozeWakeLabelText={item.snoozeWakeLabelText}
723 project={747 project={
...@@ -753,9 +777,12 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -753,9 +777,12 @@ export function HomeScreen(props: HomeScreenProps) {
753 settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}777 settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
754 onSettleThread={handleSettleThread}778 onSettleThread={handleSettleThread}
755 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}779 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
780 pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
756 onSnoozeThread={handleSnoozeThread}781 onSnoozeThread={handleSnoozeThread}
757 onUnsnoozeThread={handleUnsnoozeThread}782 onUnsnoozeThread={handleUnsnoozeThread}
758 onUnsettleThread={handleUnsettleThread}783 onUnsettleThread={handleUnsettleThread}
784 onPinThread={handlePinThread}
785 onUnpinThread={handleUnpinThread}
759 onChangeRequestState={handleChangeRequestState}786 onChangeRequestState={handleChangeRequestState}
760 projectCwd={787 projectCwd={
761 projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null788 projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null
...@@ -768,12 +795,15 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -768,12 +795,15 @@ export function HomeScreen(props: HomeScreenProps) {
768 [795 [
769 handleChangeRequestState,796 handleChangeRequestState,
770 handleDeleteThread,797 handleDeleteThread,
798 handlePinThread,
771 handleSettleThread,799 handleSettleThread,
772 handleSnoozeThread,800 handleSnoozeThread,
801 handleUnpinThread,
773 handleUnsnoozeThread,802 handleUnsnoozeThread,
774 handleSwipeableClose,803 handleSwipeableClose,
775 handleSwipeableWillOpen,804 handleSwipeableWillOpen,
776 handleUnsettleThread,805 handleUnsettleThread,
806 pinningEnvironmentIds,
777 projectByKey,807 projectByKey,
778 projectCwdByKey,808 projectCwdByKey,
779 props.onArchiveThread,809 props.onArchiveThread,
apps/mobile/src/features/home/useThreadListActions.ts+69
...@@ -29,6 +29,13 @@ function environmentSupportsSnooze(environmentId: EnvironmentThreadShell["enviro...@@ -29,6 +29,13 @@ function environmentSupportsSnooze(environmentId: EnvironmentThreadShell["enviro
29 );29 );
30}30}
3131
32function environmentSupportsPinning(environmentId: EnvironmentThreadShell["environmentId"]) {
33 return (
34 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
35 .threadPinning === true
36 );
37}
38
32type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";39type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";
3340
34const ACTION_VERBS: Record<ThreadListAction, string> = {41const ACTION_VERBS: Record<ThreadListAction, string> = {
...@@ -202,10 +209,14 @@ export function useThreadListActions(): {...@@ -202,10 +209,14 @@ export function useThreadListActions(): {
202 readonly snoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => Promise<boolean>;209 readonly snoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => Promise<boolean>;
203 readonly unsnoozeThread: (thread: EnvironmentThreadShell) => Promise<boolean>;210 readonly unsnoozeThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
204 readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;211 readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
212 readonly pinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
213 readonly unpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
205} {214} {
206 const executeAction = useThreadActionExecutor();215 const executeAction = useThreadActionExecutor();
207 const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false });216 const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false });
208 const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false });217 const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false });
218 const pinMutation = useAtomCommand(threadEnvironment.pin, { reportFailure: false });
219 const unpinMutation = useAtomCommand(threadEnvironment.unpin, { reportFailure: false });
209 const snoozeInFlightThreadKeys = useRef(new Set<string>());220 const snoozeInFlightThreadKeys = useRef(new Set<string>());
210221
211 const archiveThread = useCallback(222 const archiveThread = useCallback(
...@@ -310,6 +321,62 @@ export function useThreadListActions(): {...@@ -310,6 +321,62 @@ export function useThreadListActions(): {
310 async (thread: EnvironmentThreadShell) => (await executeAction("unsettle", thread)) === true,321 async (thread: EnvironmentThreadShell) => (await executeAction("unsettle", thread)) === true,
311 [executeAction],322 [executeAction],
312 );323 );
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
314 const confirmDeleteThread = useConfirmDeleteThread(executeAction);381 const confirmDeleteThread = useConfirmDeleteThread(executeAction);
315382
...@@ -320,6 +387,8 @@ export function useThreadListActions(): {...@@ -320,6 +387,8 @@ export function useThreadListActions(): {
320 snoozeThread,387 snoozeThread,
321 unsnoozeThread,388 unsnoozeThread,
322 unsettleThread,389 unsettleThread,
390 pinThread,
391 unpinThread,
323 };392 };
324}393}
325394
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+19
...@@ -205,6 +205,8 @@ function ThreadNavigationSidebarPane(...@@ -205,6 +205,8 @@ function ThreadNavigationSidebarPane(
205 snoozeThread,205 snoozeThread,
206 unsnoozeThread,206 unsnoozeThread,
207 unsettleThread,207 unsettleThread,
208 pinThread,
209 unpinThread,
208 } = useThreadListActions();210 } = useThreadListActions();
209 const threadListV2Enabled = useThreadListV2Enabled();211 const threadListV2Enabled = useThreadListV2Enabled();
210 const pendingTasks = usePendingNewTasks();212 const pendingTasks = usePendingNewTasks();
...@@ -481,6 +483,15 @@ function ThreadNavigationSidebarPane(...@@ -481,6 +483,15 @@ function ThreadNavigationSidebarPane(
481 }483 }
482 return supported;484 return supported;
483 }, [serverConfigs]);485 }, [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]);
484 const threadListV2Layout = useMemo(() => {495 const threadListV2Layout = useMemo(() => {
485 if (!threadListV2Enabled)496 if (!threadListV2Enabled)
486 return {497 return {
...@@ -792,6 +803,7 @@ function ThreadNavigationSidebarPane(...@@ -792,6 +803,7 @@ function ThreadNavigationSidebarPane(
792 previous.item.thread === item.item.thread &&803 previous.item.thread === item.item.thread &&
793 previous.item.variant === item.item.variant &&804 previous.item.variant === item.item.variant &&
794 previous.item.snoozed === item.item.snoozed &&805 previous.item.snoozed === item.item.snoozed &&
806 previous.item.pinned === item.item.pinned &&
795 previous.snoozeWakeLabelText === item.snoozeWakeLabelText807 previous.snoozeWakeLabelText === item.snoozeWakeLabelText
796 );808 );
797 }809 }
...@@ -879,6 +891,7 @@ function ThreadNavigationSidebarPane(...@@ -879,6 +891,7 @@ function ThreadNavigationSidebarPane(
879 thread={thread}891 thread={thread}
880 variant={item.item.variant}892 variant={item.item.variant}
881 snoozed={item.item.snoozed}893 snoozed={item.item.snoozed}
894 pinned={item.item.pinned}
882 snoozePresetMinute={nowMinute}895 snoozePresetMinute={nowMinute}
883 snoozeWakeLabelText={item.snoozeWakeLabelText}896 snoozeWakeLabelText={item.snoozeWakeLabelText}
884 project={projectByKey.get(scopeKey) ?? null}897 project={projectByKey.get(scopeKey) ?? null}
...@@ -915,9 +928,12 @@ function ThreadNavigationSidebarPane(...@@ -915,9 +928,12 @@ function ThreadNavigationSidebarPane(
915 settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}928 settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
916 onSettleThread={settleThread}929 onSettleThread={settleThread}
917 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}930 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
931 pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
918 onSnoozeThread={snoozeThread}932 onSnoozeThread={snoozeThread}
919 onUnsnoozeThread={unsnoozeThread}933 onUnsnoozeThread={unsnoozeThread}
920 onUnsettleThread={unsettleThread}934 onUnsettleThread={unsettleThread}
935 onPinThread={pinThread}
936 onUnpinThread={unpinThread}
921 onChangeRequestState={handleChangeRequestState}937 onChangeRequestState={handleChangeRequestState}
922 projectCwd={projectCwdByKey.get(scopeKey) ?? null}938 projectCwd={projectCwdByKey.get(scopeKey) ?? null}
923 onSwipeableClose={handleSwipeableClose}939 onSwipeableClose={handleSwipeableClose}
...@@ -1045,6 +1061,8 @@ function ThreadNavigationSidebarPane(...@@ -1045,6 +1061,8 @@ function ThreadNavigationSidebarPane(
1045 handleSwipeableClose,1061 handleSwipeableClose,
1046 handleSwipeableWillOpen,1062 handleSwipeableWillOpen,
1047 openPendingTask,1063 openPendingTask,
1064 pinThread,
1065 pinningEnvironmentIds,
1048 projectByKey,1066 projectByKey,
1049 projectCwdByKey,1067 projectCwdByKey,
1050 projectTitleByProjectKey,1068 projectTitleByProjectKey,
...@@ -1064,6 +1082,7 @@ function ThreadNavigationSidebarPane(...@@ -1064,6 +1082,7 @@ function ThreadNavigationSidebarPane(
1064 nowMinute,1082 nowMinute,
1065 toggleSettledShelf,1083 toggleSettledShelf,
1066 toggleSnoozedShelf,1084 toggleSnoozedShelf,
1085 unpinThread,
1067 unsettleThread,1086 unsettleThread,
1068 unsnoozeThread,1087 unsnoozeThread,
1069 updateGroupDisplay,1088 updateGroupDisplay,
apps/mobile/src/features/threads/thread-list-v2-items.tsx+40-2
...@@ -305,6 +305,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -305,6 +305,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
305 readonly variant: "card" | "slim";305 readonly variant: "card" | "slim";
306 /** Snoozed-shelf row: shows its wake time and offers Wake. */306 /** Snoozed-shelf row: shows its wake time and offers Wake. */
307 readonly snoozed?: boolean;307 readonly snoozed?: boolean;
308 /** Pinned-block row: shows the pin glyph and offers Unpin. */
309 readonly pinned?: boolean;
308 /** Preformatted against the parent minute tick so this memoized row's310 /** Preformatted against the parent minute tick so this memoized row's
309 countdown keeps moving. */311 countdown keeps moving. */
310 readonly snoozeWakeLabelText?: string;312 readonly snoozeWakeLabelText?: string;
...@@ -337,11 +339,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -337,11 +339,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
337 readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void;339 readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void;
338 readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;340 readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
339 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;341 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
342 readonly onPinThread: (thread: EnvironmentThreadShell) => void;
343 readonly onUnpinThread: (thread: EnvironmentThreadShell) => void;
340 /** False on environments whose server predates thread.settle/unsettle:344 /** False on environments whose server predates thread.settle/unsettle:
341 swipe + menu fall back to Archive instead of failing on use. */345 swipe + menu fall back to Archive instead of failing on use. */
342 readonly settlementSupported: boolean;346 readonly settlementSupported: boolean;
343 /** False on servers that predate thread.snooze/unsnooze. */347 /** False on servers that predate thread.snooze/unsnooze. */
344 readonly snoozeSupported: boolean;348 readonly snoozeSupported: boolean;
349 /** False on servers that predate thread.pin/unpin. */
350 readonly pinningSupported: boolean;
345 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;351 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
346 readonly onSwipeableClose: (methods: SwipeableMethods) => void;352 readonly onSwipeableClose: (methods: SwipeableMethods) => void;
347 /** Reports this row's live PR state up so the partition can auto-settle353 /** 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: {...@@ -368,9 +374,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
368 onUnsnoozeThread,374 onUnsnoozeThread,
369 onUnsettleThread,375 onUnsettleThread,
370 onArchiveThread,376 onArchiveThread,
377 onPinThread,
378 onUnpinThread,
371 onChangeRequestState,379 onChangeRequestState,
372 } = props;380 } = props;
373 const snoozedRow = props.snoozed === true;381 const snoozedRow = props.snoozed === true;
382 const pinnedRow = props.pinned === true;
374383
375 const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null);384 const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null);
376 const prState = pr?.state ?? null;385 const prState = pr?.state ?? null;
...@@ -383,6 +392,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -383,6 +392,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
383 const drawerColor = useThemeColor("--color-drawer");392 const drawerColor = useThemeColor("--color-drawer");
384 const pressedBackgroundColor = useThemeColor("--color-subtle");393 const pressedBackgroundColor = useThemeColor("--color-subtle");
385 const selectedBackgroundColor = useThemeColor("--color-user-bubble");394 const selectedBackgroundColor = useThemeColor("--color-user-bubble");
395 const pinTintColor = useThemeColor("--color-foreground-muted");
386 const sidebarPane = props.pane === "sidebar";396 const sidebarPane = props.pane === "sidebar";
387 const selected = props.selected === true;397 const selected = props.selected === true;
388398
...@@ -398,6 +408,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -398,6 +408,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
398 );408 );
399 const handleUnsnooze = useCallback(() => onUnsnoozeThread(thread), [onUnsnoozeThread, thread]);409 const handleUnsnooze = useCallback(() => onUnsnoozeThread(thread), [onUnsnoozeThread, thread]);
400 const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]);410 const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]);
411 const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]);
412 const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]);
401 const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);413 const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);
402414
403 // Swipe: the v2 primary action is the lifecycle transition. Every settled415 // Swipe: the v2 primary action is the lifecycle transition. Every settled
...@@ -434,6 +446,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -434,6 +446,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
434 })),446 })),
435 [snoozePresets],447 [snoozePresets],
436 );448 );
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 );
437 const snoozableCardMenuActions = useMemo<MenuAction[]>(463 const snoozableCardMenuActions = useMemo<MenuAction[]>(
438 () => [464 () => [
439 { id: "settle", title: "Settle", image: "checkmark" },465 { id: "settle", title: "Settle", image: "checkmark" },
...@@ -443,15 +469,22 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -443,15 +469,22 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
443 image: "clock",469 image: "clock",
444 subactions: snoozePresetActions,470 subactions: snoozePresetActions,
445 },471 },
472 ...pinMenuItem,
446 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },473 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
447 ],474 ],
448 [snoozePresetActions],475 [pinMenuItem, snoozePresetActions],
476 );
477 const cardMenuActions = useMemo<MenuAction[]>(
478 () => [CARD_MENU_ACTIONS[0]!, ...pinMenuItem, ...CARD_MENU_ACTIONS.slice(1)],
479 [pinMenuItem],
449 );480 );
450 const handleMenuAction = useCallback(481 const handleMenuAction = useCallback(
451 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {482 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
452 if (nativeEvent.event === "settle") handleSettle();483 if (nativeEvent.event === "settle") handleSettle();
453 if (nativeEvent.event === "unsettle") handleUnsettle();484 if (nativeEvent.event === "unsettle") handleUnsettle();
454 if (nativeEvent.event === "unsnooze") handleUnsnooze();485 if (nativeEvent.event === "unsnooze") handleUnsnooze();
486 if (nativeEvent.event === "pin") handlePin();
487 if (nativeEvent.event === "unpin") handleUnpin();
455 if (nativeEvent.event === "archive") handleArchive();488 if (nativeEvent.event === "archive") handleArchive();
456 if (nativeEvent.event === "delete") handleDelete();489 if (nativeEvent.event === "delete") handleDelete();
457 const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({490 const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({
...@@ -468,8 +501,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -468,8 +501,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
468 [501 [
469 handleArchive,502 handleArchive,
470 handleDelete,503 handleDelete,
504 handlePin,
471 handleSettle,505 handleSettle,
472 handleSnooze,506 handleSnooze,
507 handleUnpin,
473 handleUnsettle,508 handleUnsettle,
474 handleUnsnooze,509 handleUnsnooze,
475 snoozePresets,510 snoozePresets,
...@@ -560,6 +595,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -560,6 +595,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
560 >595 >
561 {props.projectTitle ?? props.project?.title ?? ""}596 {props.projectTitle ?? props.project?.title ?? ""}
562 </Text>597 </Text>
598 {pinnedRow ? (
599 <SymbolView name="pin" size={11} tintColor={pinTintColor} type="monochrome" />
600 ) : null}
563 <Text601 <Text
564 className={cn(602 className={cn(
565 "text-xs tabular-nums",603 "text-xs tabular-nums",
...@@ -805,7 +843,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -805,7 +843,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
805 ? SLIM_MENU_ACTIONS843 ? SLIM_MENU_ACTIONS
806 : swipeActions.secondary === "snooze"844 : swipeActions.secondary === "snooze"
807 ? snoozableCardMenuActions845 ? snoozableCardMenuActions
808 : CARD_MENU_ACTIONS846 : cardMenuActions
809 }847 }
810 onPressAction={handleMenuAction}848 onPressAction={handleMenuAction}
811 shouldOpenOnLongPress849 shouldOpenOnLongPress
apps/mobile/src/features/threads/threadListV2.test.ts+51
...@@ -288,6 +288,57 @@ describe("buildThreadListV2Items", () => {...@@ -288,6 +288,57 @@ describe("buildThreadListV2Items", () => {
288 expect(layout.snoozedCount).toBe(1);288 expect(layout.snoozedCount).toBe(1);
289 });289 });
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
291 it("classifies snooze with the second-precise clock and reports the next wake", () => {342 it("classifies snooze with the second-precise clock and reports the next wake", () => {
292 const layout = buildThreadListV2Items({343 const layout = buildThreadListV2Items({
293 threads: [344 threads: [
apps/mobile/src/features/threads/threadListV2.ts+25-3
...@@ -180,6 +180,8 @@ export interface ThreadListV2Item {...@@ -180,6 +180,8 @@ export interface ThreadListV2Item {
180 readonly variant: "card" | "slim";180 readonly variant: "card" | "slim";
181 /** Snoozed-shelf row: shows the wake countdown and offers Wake. */181 /** Snoozed-shelf row: shows the wake countdown and offers Wake. */
182 readonly snoozed: boolean;182 readonly snoozed: boolean;
183 /** Pinned-block row: renders the pin glyph and offers Unpin. */
184 readonly pinned: boolean;
183 readonly isLast: boolean;185 readonly isLast: boolean;
184}186}
185187
...@@ -351,6 +353,7 @@ export function buildThreadListV2Items(input: {...@@ -351,6 +353,7 @@ export function buildThreadListV2Items(input: {
351 ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))353 ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
352 : null;354 : null;
353355
356 const pinned: EnvironmentThreadShell[] = [];
354 const active: EnvironmentThreadShell[] = [];357 const active: EnvironmentThreadShell[] = [];
355 const settled: EnvironmentThreadShell[] = [];358 const settled: EnvironmentThreadShell[] = [];
356 const snoozed: EnvironmentThreadShell[] = [];359 const snoozed: EnvironmentThreadShell[] = [];
...@@ -378,9 +381,10 @@ export function buildThreadListV2Items(input: {...@@ -378,9 +381,10 @@ export function buildThreadListV2Items(input: {
378 const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true;381 const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true;
379 const changeRequestState =382 const changeRequestState =
380 input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null;383 input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null;
381 // Visibility parity with web: a snoozed thread leaves the list until it384 // Visibility parity with web: snooze outranks everything, including a
382 // wakes (or raises its hand — effectiveSnoozed refuses blocked/failed385 // pin — a snoozed thread leaves the list until it wakes (or raises its
383 // work). Snooze outranks settled classification, same as web.386 // hand). The pin survives underneath, so a woken thread reappears at
387 // its original spot in the creation-ordered pinned block.
384 if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) {388 if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) {
385 snoozed.push(thread);389 snoozed.push(thread);
386 if (390 if (
...@@ -392,6 +396,12 @@ export function buildThreadListV2Items(input: {...@@ -392,6 +396,12 @@ export function buildThreadListV2Items(input: {
392 }396 }
393 continue;397 continue;
394 }398 }
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 }
395 if (405 if (
396 supportsSettlement &&406 supportsSettlement &&
397 effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })407 effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
...@@ -434,11 +444,21 @@ export function buildThreadListV2Items(input: {...@@ -434,11 +444,21 @@ export function buildThreadListV2Items(input: {
434 );444 );
435445
436 const items: ThreadListV2Item[] = [];446 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 }
437 for (const thread of orderedActive) {456 for (const thread of orderedActive) {
438 items.push({457 items.push({
439 thread,458 thread,
440 variant: "card",459 variant: "card",
441 snoozed: false,460 snoozed: false,
461 pinned: false,
442 isLast: false,462 isLast: false,
443 });463 });
444 }464 }
...@@ -448,6 +468,7 @@ export function buildThreadListV2Items(input: {...@@ -448,6 +468,7 @@ export function buildThreadListV2Items(input: {
448 thread,468 thread,
449 variant: "slim",469 variant: "slim",
450 snoozed: true,470 snoozed: true,
471 pinned: false,
451 isLast: false,472 isLast: false,
452 });473 });
453 }474 }
...@@ -457,6 +478,7 @@ export function buildThreadListV2Items(input: {...@@ -457,6 +478,7 @@ export function buildThreadListV2Items(input: {
457 thread,478 thread,
458 variant: "slim",479 variant: "slim",
459 snoozed: false,480 snoozed: false,
481 pinned: false,
460 isLast: false,482 isLast: false,
461 });483 });
462 }484 }
apps/server/src/environment/ServerEnvironment.ts+1
...@@ -145,6 +145,7 @@ export const make = Effect.gen(function* () {...@@ -145,6 +145,7 @@ export const make = Effect.gen(function* () {
145 connectionProbe: true,145 connectionProbe: true,
146 threadSettlement: true,146 threadSettlement: true,
147 threadSnooze: true,147 threadSnooze: true,
148 threadPinning: true,
148 threadTitleRegeneration: true,149 threadTitleRegeneration: true,
149 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),150 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
150 ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}),151 ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}),
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+31
...@@ -611,6 +611,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -611,6 +611,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
611 settledAt: null,611 settledAt: null,
612 snoozedUntil: null,612 snoozedUntil: null,
613 snoozedAt: null,613 snoozedAt: null,
614 pinnedAt: null,
614 titleRegenerationRequestId: null,615 titleRegenerationRequestId: null,
615 titleRegenerationStartedAt: null,616 titleRegenerationStartedAt: null,
616 latestUserMessageAt: null,617 latestUserMessageAt: null,
...@@ -717,6 +718,36 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -717,6 +718,36 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
717 return;718 return;
718 }719 }
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
720 case "thread.meta-updated": {751 case "thread.meta-updated": {
721 const existingRow = yield* projectionThreadRepository.getById({752 const existingRow = yield* projectionThreadRepository.getById({
722 threadId: event.payload.threadId,753 threadId: event.payload.threadId,
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+2
...@@ -312,6 +312,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -312,6 +312,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
312 settledAt: null,312 settledAt: null,
313 snoozedUntil: null,313 snoozedUntil: null,
314 snoozedAt: null,314 snoozedAt: null,
315 pinnedAt: null,
315 titleRegeneration: null,316 titleRegeneration: null,
316 deletedAt: null,317 deletedAt: null,
317 messages: [318 messages: [
...@@ -427,6 +428,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -427,6 +428,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
427 settledAt: null,428 settledAt: null,
428 snoozedUntil: null,429 snoozedUntil: null,
429 snoozedAt: null,430 snoozedAt: null,
431 pinnedAt: null,
430 titleRegeneration: null,432 titleRegeneration: null,
431 session: {433 session: {
432 threadId: ThreadId.make("thread-1"),434 threadId: ThreadId.make("thread-1"),
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+10
...@@ -384,6 +384,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -384,6 +384,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
384 settled_at AS "settledAt",384 settled_at AS "settledAt",
385 snoozed_until AS "snoozedUntil",385 snoozed_until AS "snoozedUntil",
386 snoozed_at AS "snoozedAt",386 snoozed_at AS "snoozedAt",
387 pinned_at AS "pinnedAt",
387 title_regeneration_request_id AS "titleRegenerationRequestId",388 title_regeneration_request_id AS "titleRegenerationRequestId",
388 title_regeneration_started_at AS "titleRegenerationStartedAt",389 title_regeneration_started_at AS "titleRegenerationStartedAt",
389 latest_user_message_at AS "latestUserMessageAt",390 latest_user_message_at AS "latestUserMessageAt",
...@@ -418,6 +419,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -418,6 +419,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
418 settled_at AS "settledAt",419 settled_at AS "settledAt",
419 snoozed_until AS "snoozedUntil",420 snoozed_until AS "snoozedUntil",
420 snoozed_at AS "snoozedAt",421 snoozed_at AS "snoozedAt",
422 pinned_at AS "pinnedAt",
421 title_regeneration_request_id AS "titleRegenerationRequestId",423 title_regeneration_request_id AS "titleRegenerationRequestId",
422 title_regeneration_started_at AS "titleRegenerationStartedAt",424 title_regeneration_started_at AS "titleRegenerationStartedAt",
423 latest_user_message_at AS "latestUserMessageAt",425 latest_user_message_at AS "latestUserMessageAt",
...@@ -454,6 +456,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -454,6 +456,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
454 settled_at AS "settledAt",456 settled_at AS "settledAt",
455 snoozed_until AS "snoozedUntil",457 snoozed_until AS "snoozedUntil",
456 snoozed_at AS "snoozedAt",458 snoozed_at AS "snoozedAt",
459 pinned_at AS "pinnedAt",
457 title_regeneration_request_id AS "titleRegenerationRequestId",460 title_regeneration_request_id AS "titleRegenerationRequestId",
458 title_regeneration_started_at AS "titleRegenerationStartedAt",461 title_regeneration_started_at AS "titleRegenerationStartedAt",
459 latest_user_message_at AS "latestUserMessageAt",462 latest_user_message_at AS "latestUserMessageAt",
...@@ -890,6 +893,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -890,6 +893,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
890 settled_at AS "settledAt",893 settled_at AS "settledAt",
891 snoozed_until AS "snoozedUntil",894 snoozed_until AS "snoozedUntil",
892 snoozed_at AS "snoozedAt",895 snoozed_at AS "snoozedAt",
896 pinned_at AS "pinnedAt",
893 title_regeneration_request_id AS "titleRegenerationRequestId",897 title_regeneration_request_id AS "titleRegenerationRequestId",
894 title_regeneration_started_at AS "titleRegenerationStartedAt",898 title_regeneration_started_at AS "titleRegenerationStartedAt",
895 latest_user_message_at AS "latestUserMessageAt",899 latest_user_message_at AS "latestUserMessageAt",
...@@ -1328,6 +1332,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1328,6 +1332,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1328 settledAt: row.settledAt,1332 settledAt: row.settledAt,
1329 snoozedUntil: row.snoozedUntil,1333 snoozedUntil: row.snoozedUntil,
1330 snoozedAt: row.snoozedAt,1334 snoozedAt: row.snoozedAt,
1335 pinnedAt: row.pinnedAt,
1331 titleRegeneration: mapTitleRegeneration(row),1336 titleRegeneration: mapTitleRegeneration(row),
1332 deletedAt: row.deletedAt,1337 deletedAt: row.deletedAt,
1333 messages: messagesByThread.get(row.threadId) ?? [],1338 messages: messagesByThread.get(row.threadId) ?? [],
...@@ -1531,6 +1536,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1531,6 +1536,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1531 settledAt: row.settledAt,1536 settledAt: row.settledAt,
1532 snoozedUntil: row.snoozedUntil,1537 snoozedUntil: row.snoozedUntil,
1533 snoozedAt: row.snoozedAt,1538 snoozedAt: row.snoozedAt,
1539 pinnedAt: row.pinnedAt,
1534 titleRegeneration: mapTitleRegeneration(row),1540 titleRegeneration: mapTitleRegeneration(row),
1535 deletedAt: row.deletedAt,1541 deletedAt: row.deletedAt,
1536 messages: [],1542 messages: [],
...@@ -1665,6 +1671,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1665,6 +1671,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1665 settledAt: row.settledAt,1671 settledAt: row.settledAt,
1666 snoozedUntil: row.snoozedUntil,1672 snoozedUntil: row.snoozedUntil,
1667 snoozedAt: row.snoozedAt,1673 snoozedAt: row.snoozedAt,
1674 pinnedAt: row.pinnedAt,
1668 titleRegeneration: mapTitleRegeneration(row),1675 titleRegeneration: mapTitleRegeneration(row),
1669 session: sessionByThread.get(row.threadId) ?? null,1676 session: sessionByThread.get(row.threadId) ?? null,
1670 latestUserMessageAt: row.latestUserMessageAt,1677 latestUserMessageAt: row.latestUserMessageAt,
...@@ -1804,6 +1811,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1804,6 +1811,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1804 settledAt: row.settledAt,1811 settledAt: row.settledAt,
1805 snoozedUntil: row.snoozedUntil,1812 snoozedUntil: row.snoozedUntil,
1806 snoozedAt: row.snoozedAt,1813 snoozedAt: row.snoozedAt,
1814 pinnedAt: row.pinnedAt,
1807 titleRegeneration: mapTitleRegeneration(row),1815 titleRegeneration: mapTitleRegeneration(row),
1808 session: sessionByThread.get(row.threadId) ?? null,1816 session: sessionByThread.get(row.threadId) ?? null,
1809 latestUserMessageAt: row.latestUserMessageAt,1817 latestUserMessageAt: row.latestUserMessageAt,
...@@ -2075,6 +2083,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2075,6 +2083,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2075 settledAt: threadRow.value.settledAt,2083 settledAt: threadRow.value.settledAt,
2076 snoozedUntil: threadRow.value.snoozedUntil,2084 snoozedUntil: threadRow.value.snoozedUntil,
2077 snoozedAt: threadRow.value.snoozedAt,2085 snoozedAt: threadRow.value.snoozedAt,
2086 pinnedAt: threadRow.value.pinnedAt,
2078 titleRegeneration: mapTitleRegeneration(threadRow.value),2087 titleRegeneration: mapTitleRegeneration(threadRow.value),
2079 session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null,2088 session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null,
2080 latestUserMessageAt: threadRow.value.latestUserMessageAt,2089 latestUserMessageAt: threadRow.value.latestUserMessageAt,
...@@ -2174,6 +2183,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2174,6 +2183,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2174 settledAt: threadRow.value.settledAt,2183 settledAt: threadRow.value.settledAt,
2175 snoozedUntil: threadRow.value.snoozedUntil,2184 snoozedUntil: threadRow.value.snoozedUntil,
2176 snoozedAt: threadRow.value.snoozedAt,2185 snoozedAt: threadRow.value.snoozedAt,
2186 pinnedAt: threadRow.value.pinnedAt,
2177 titleRegeneration: mapTitleRegeneration(threadRow.value),2187 titleRegeneration: mapTitleRegeneration(threadRow.value),
2178 deletedAt: null,2188 deletedAt: null,
2179 messages: messageRows.map((row) => {2189 messages: messageRows.map((row) => {
apps/server/src/orchestration/Schemas.ts+4
...@@ -13,6 +13,8 @@ import {...@@ -13,6 +13,8 @@ import {
13 ThreadUnsettledPayload as ContractsThreadUnsettledPayloadSchema,13 ThreadUnsettledPayload as ContractsThreadUnsettledPayloadSchema,
14 ThreadSnoozedPayload as ContractsThreadSnoozedPayloadSchema,14 ThreadSnoozedPayload as ContractsThreadSnoozedPayloadSchema,
15 ThreadUnsnoozedPayload as ContractsThreadUnsnoozedPayloadSchema,15 ThreadUnsnoozedPayload as ContractsThreadUnsnoozedPayloadSchema,
16 ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema,
17 ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema,
16 ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema,18 ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema,
17 ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema,19 ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema,
18 ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema,20 ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema,
...@@ -42,6 +44,8 @@ export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema;...@@ -42,6 +44,8 @@ export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema;
42export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema;44export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema;
43export const ThreadSnoozedPayload = ContractsThreadSnoozedPayloadSchema;45export const ThreadSnoozedPayload = ContractsThreadSnoozedPayloadSchema;
44export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema;46export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema;
47export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema;
48export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema;
4549
46export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;50export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;
47export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema;51export 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"...@@ -487,14 +487,14 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
487 // settledAt: the engine rejects zero-event commands, and bulk-settle /487 // settledAt: the engine rejects zero-event commands, and bulk-settle /
488 // double-click must stay silent no-ops rather than surface errors.488 // double-click must stay silent no-ops rather than surface errors.
489 const alreadySettled = thread.settledOverride === "settled" && thread.settledAt !== null;489 const alreadySettled = thread.settledOverride === "settled" && thread.settledAt !== null;
490 return {490 const settledEvent = {
491 ...(yield* withEventBase({491 ...(yield* withEventBase({
492 aggregateKind: "thread",492 aggregateKind: "thread",
493 aggregateId: command.threadId,493 aggregateId: command.threadId,
494 occurredAt,494 occurredAt,
495 commandId: command.commandId,495 commandId: command.commandId,
496 })),496 })),
497 type: "thread.settled",497 type: "thread.settled" as const,
498 payload: {498 payload: {
499 threadId: command.threadId,499 threadId: command.threadId,
500 settledAt: alreadySettled ? thread.settledAt : occurredAt,500 settledAt: alreadySettled ? thread.settledAt : occurredAt,
...@@ -504,6 +504,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -504,6 +504,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
504 updatedAt: alreadySettled ? thread.updatedAt : occurredAt,504 updatedAt: alreadySettled ? thread.updatedAt : occurredAt,
505 },505 },
506 };506 };
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 ];
507 }530 }
508531
509 case "thread.unsettle": {532 case "thread.unsettle": {
...@@ -630,6 +653,98 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -630,6 +653,98 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
630 };653 };
631 }654 }
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
633 case "thread.meta.update": {748 case "thread.meta.update": {
634 const thread = yield* requireThread({749 const thread = yield* requireThread({
635 readModel,750 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 {...@@ -23,7 +23,9 @@ import {
23 ThreadProposedPlanUpsertedPayload,23 ThreadProposedPlanUpsertedPayload,
24 ThreadRuntimeModeSetPayload,24 ThreadRuntimeModeSetPayload,
25 ThreadSettledPayload,25 ThreadSettledPayload,
26 ThreadPinnedPayload,
26 ThreadSnoozedPayload,27 ThreadSnoozedPayload,
28 ThreadUnpinnedPayload,
27 ThreadUnarchivedPayload,29 ThreadUnarchivedPayload,
28 ThreadUnsettledPayload,30 ThreadUnsettledPayload,
29 ThreadUnsnoozedPayload,31 ThreadUnsnoozedPayload,
...@@ -394,6 +396,28 @@ export function projectEvent(...@@ -394,6 +396,28 @@ export function projectEvent(
394 })),396 })),
395 );397 );
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
397 case "thread.meta-updated":421 case "thread.meta-updated":
398 return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe(422 return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe(
399 Effect.map((payload) => ({423 Effect.map((payload) => ({
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+5
...@@ -95,6 +95,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {...@@ -95,6 +95,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
95 settledAt: null,95 settledAt: null,
96 snoozedUntil: null,96 snoozedUntil: null,
97 snoozedAt: null,97 snoozedAt: null,
98 pinnedAt: null,
98 latestUserMessageAt: null,99 latestUserMessageAt: null,
99 pendingApprovalCount: 0,100 pendingApprovalCount: 0,
100 pendingUserInputCount: 0,101 pendingUserInputCount: 0,
...@@ -157,6 +158,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {...@@ -157,6 +158,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
157 settledAt: "2026-03-25T00:00:00.000Z",158 settledAt: "2026-03-25T00:00:00.000Z",
158 snoozedUntil: "2026-03-26T09:00:00.000Z",159 snoozedUntil: "2026-03-26T09:00:00.000Z",
159 snoozedAt: "2026-03-25T00:00:00.000Z",160 snoozedAt: "2026-03-25T00:00:00.000Z",
161 pinnedAt: "2026-03-25T00:00:00.000Z",
160 latestUserMessageAt: null,162 latestUserMessageAt: null,
161 pendingApprovalCount: 0,163 pendingApprovalCount: 0,
162 pendingUserInputCount: 0,164 pendingUserInputCount: 0,
...@@ -175,6 +177,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {...@@ -175,6 +177,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
175 assert.strictEqual(row.settledAt, "2026-03-25T00:00:00.000Z");177 assert.strictEqual(row.settledAt, "2026-03-25T00:00:00.000Z");
176 assert.strictEqual(row.snoozedUntil, "2026-03-26T09:00:00.000Z");178 assert.strictEqual(row.snoozedUntil, "2026-03-26T09:00:00.000Z");
177 assert.strictEqual(row.snoozedAt, "2026-03-25T00:00:00.000Z");179 assert.strictEqual(row.snoozedAt, "2026-03-25T00:00:00.000Z");
180 assert.strictEqual(row.pinnedAt, "2026-03-25T00:00:00.000Z");
178181
179 // Un-settle to the keep-active pin and wake the snooze; confirm the182 // Un-settle to the keep-active pin and wake the snooze; confirm the
180 // flips persist.183 // flips persist.
...@@ -184,6 +187,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {...@@ -184,6 +187,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
184 settledAt: null,187 settledAt: null,
185 snoozedUntil: null,188 snoozedUntil: null,
186 snoozedAt: null,189 snoozedAt: null,
190 pinnedAt: null,
187 });191 });
188 const repersisted = yield* threads.getById({192 const repersisted = yield* threads.getById({
189 threadId: ThreadId.make("thread-settled"),193 threadId: ThreadId.make("thread-settled"),
...@@ -193,6 +197,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {...@@ -193,6 +197,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
193 assert.strictEqual(updated?.settledAt, null);197 assert.strictEqual(updated?.settledAt, null);
194 assert.strictEqual(updated?.snoozedUntil, null);198 assert.strictEqual(updated?.snoozedUntil, null);
195 assert.strictEqual(updated?.snoozedAt, null);199 assert.strictEqual(updated?.snoozedAt, null);
200 assert.strictEqual(updated?.pinnedAt, null);
196 }),201 }),
197 );202 );
198});203});
apps/server/src/persistence/Layers/ProjectionThreads.ts+5
...@@ -47,6 +47,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -47,6 +47,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
47 settled_at,47 settled_at,
48 snoozed_until,48 snoozed_until,
49 snoozed_at,49 snoozed_at,
50 pinned_at,
50 title_regeneration_request_id,51 title_regeneration_request_id,
51 title_regeneration_started_at,52 title_regeneration_started_at,
52 latest_user_message_at,53 latest_user_message_at,
...@@ -72,6 +73,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -72,6 +73,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
72 ${row.settledAt},73 ${row.settledAt},
73 ${row.snoozedUntil},74 ${row.snoozedUntil},
74 ${row.snoozedAt},75 ${row.snoozedAt},
76 ${row.pinnedAt},
75 ${row.titleRegenerationRequestId ?? null},77 ${row.titleRegenerationRequestId ?? null},
76 ${row.titleRegenerationStartedAt ?? null},78 ${row.titleRegenerationStartedAt ?? null},
77 ${row.latestUserMessageAt},79 ${row.latestUserMessageAt},
...@@ -97,6 +99,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -97,6 +99,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
97 settled_at = excluded.settled_at,99 settled_at = excluded.settled_at,
98 snoozed_until = excluded.snoozed_until,100 snoozed_until = excluded.snoozed_until,
99 snoozed_at = excluded.snoozed_at,101 snoozed_at = excluded.snoozed_at,
102 pinned_at = excluded.pinned_at,
100 title_regeneration_request_id = excluded.title_regeneration_request_id,103 title_regeneration_request_id = excluded.title_regeneration_request_id,
101 title_regeneration_started_at = excluded.title_regeneration_started_at,104 title_regeneration_started_at = excluded.title_regeneration_started_at,
102 latest_user_message_at = excluded.latest_user_message_at,105 latest_user_message_at = excluded.latest_user_message_at,
...@@ -129,6 +132,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -129,6 +132,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
129 settled_at AS "settledAt",132 settled_at AS "settledAt",
130 snoozed_until AS "snoozedUntil",133 snoozed_until AS "snoozedUntil",
131 snoozed_at AS "snoozedAt",134 snoozed_at AS "snoozedAt",
135 pinned_at AS "pinnedAt",
132 title_regeneration_request_id AS "titleRegenerationRequestId",136 title_regeneration_request_id AS "titleRegenerationRequestId",
133 title_regeneration_started_at AS "titleRegenerationStartedAt",137 title_regeneration_started_at AS "titleRegenerationStartedAt",
134 latest_user_message_at AS "latestUserMessageAt",138 latest_user_message_at AS "latestUserMessageAt",
...@@ -163,6 +167,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -163,6 +167,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
163 settled_at AS "settledAt",167 settled_at AS "settledAt",
164 snoozed_until AS "snoozedUntil",168 snoozed_until AS "snoozedUntil",
165 snoozed_at AS "snoozedAt",169 snoozed_at AS "snoozedAt",
170 pinned_at AS "pinnedAt",
166 title_regeneration_request_id AS "titleRegenerationRequestId",171 title_regeneration_request_id AS "titleRegenerationRequestId",
167 title_regeneration_started_at AS "titleRegenerationStartedAt",172 title_regeneration_started_at AS "titleRegenerationStartedAt",
168 latest_user_message_at AS "latestUserMessageAt",173 latest_user_message_at AS "latestUserMessageAt",
apps/server/src/persistence/Migrations.ts+2
...@@ -48,6 +48,7 @@ import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts";...@@ -48,6 +48,7 @@ import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts";
48import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts";48import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts";
49import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts";49import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts";
50import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts";50import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts";
51import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts";
5152
52/**53/**
53 * Migration loader with all migrations defined inline.54 * Migration loader with all migrations defined inline.
...@@ -95,6 +96,7 @@ export const migrationEntries = [...@@ -95,6 +96,7 @@ export const migrationEntries = [
95 [33, "ProjectionThreadsSettled", Migration0033],96 [33, "ProjectionThreadsSettled", Migration0033],
96 [34, "ProjectionThreadsSnoozed", Migration0034],97 [34, "ProjectionThreadsSnoozed", Migration0034],
97 [35, "ProjectionThreadTitleRegeneration", Migration0035],98 [35, "ProjectionThreadTitleRegeneration", Migration0035],
99 [36, "ProjectionThreadsPinned", Migration0036],
98] as const;100] as const;
99101
100export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);102export 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({...@@ -41,6 +41,7 @@ export const ProjectionThread = Schema.Struct({
41 settledAt: Schema.NullOr(IsoDateTime),41 settledAt: Schema.NullOr(IsoDateTime),
42 snoozedUntil: Schema.NullOr(IsoDateTime),42 snoozedUntil: Schema.NullOr(IsoDateTime),
43 snoozedAt: Schema.NullOr(IsoDateTime),43 snoozedAt: Schema.NullOr(IsoDateTime),
44 pinnedAt: Schema.NullOr(IsoDateTime),
44 titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)),45 titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)),
45 titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)),46 titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
46 latestUserMessageAt: Schema.NullOr(IsoDateTime),47 latestUserMessageAt: Schema.NullOr(IsoDateTime),
apps/web/src/components/SidebarV2.tsx+187-73
...@@ -28,6 +28,7 @@ import {...@@ -28,6 +28,7 @@ import {
28 GitBranchIcon,28 GitBranchIcon,
29 EllipsisIcon,29 EllipsisIcon,
30 MessageSquareIcon,30 MessageSquareIcon,
31 PinIcon,
31 PlusIcon,32 PlusIcon,
32 SearchIcon,33 SearchIcon,
33 ServerIcon,34 ServerIcon,
...@@ -394,6 +395,11 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -394,6 +395,11 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
394 settlementSupported: boolean;395 settlementSupported: boolean;
395 // Same contract for thread.snooze/unsnooze.396 // Same contract for thread.snooze/unsnooze.
396 snoozeSupported: boolean;397 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;
397 // Compact wake countdown ("2h") for rows in the snoozed shelf.403 // Compact wake countdown ("2h") for rows in the snoozed shelf.
398 snoozeWakeLabelText: string | null;404 snoozeWakeLabelText: string | null;
399 // When a snooze ended (timer or early wake); drives the Woke pill until405 // When a snooze ended (timer or early wake); drives the Woke pill until
...@@ -939,6 +945,13 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -939,6 +945,13 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
939 ) : (945 ) : (
940 <span className="flex-1" />946 <span className="flex-1" />
941 )}947 )}
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}
942 {/* The visible state owns this slot's width: status at rest,955 {/* The visible state owns this slot's width: status at rest,
943 actions on hover/keyboard focus or while the popover is open. Keeping956 actions on hover/keyboard focus or while the popover is open. Keeping
944 the hidden state out of flow lets the project label reclaim957 the hidden state out of flow lets the project label reclaim
...@@ -1186,8 +1199,15 @@ export default function SidebarV2() {...@@ -1186,8 +1199,15 @@ export default function SidebarV2() {
1186 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);1199 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
1187 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);1200 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
1188 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);1201 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
1189 const { settleThread, unsettleThread, snoozeThread, unsnoozeThread, deleteThread } =1202 const {
1190 useThreadActions();1203 settleThread,
1204 unsettleThread,
1205 snoozeThread,
1206 unsnoozeThread,
1207 pinThread,
1208 unpinThread,
1209 deleteThread,
1210 } = useThreadActions();
1191 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {1211 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
1192 reportFailure: false,1212 reportFailure: false,
1193 });1213 });
...@@ -1564,76 +1584,91 @@ export default function SidebarV2() {...@@ -1564,76 +1584,91 @@ export default function SidebarV2() {
1564 // merging, no optimistic holds. Archived threads remain hidden here —1584 // merging, no optimistic holds. Archived threads remain hidden here —
1565 // archive keeps its original "remove from sidebar" meaning.1585 // archive keeps its original "remove from sidebar" meaning.
1566 const serverConfigs = useAtomValue(environmentServerConfigsAtom);1586 const serverConfigs = useAtomValue(environmentServerConfigsAtom);
1567 const { activeThreads, snoozedThreads, settledThreads, snoozeNow } = useMemo(() => {1587 const { pinnedThreads, activeThreads, snoozedThreads, settledThreads, snoozeNow } =
1568 const now = `${nowMinute}:00.000Z`;1588 useMemo(() => {
1569 // Snooze classification uses a REAL clock, not the quantized minute:1589 const now = `${nowMinute}:00.000Z`;
1570 // wake times are second-precise and a woken thread must not linger on1590 // Snooze classification uses a REAL clock, not the quantized minute:
1571 // the shelf for the rest of the minute. snoozeWakeTick re-runs this1591 // wake times are second-precise and a woken thread must not linger on
1572 // memo exactly at the next wake boundary.1592 // the shelf for the rest of the minute. snoozeWakeTick re-runs this
1573 void snoozeWakeTick;1593 // memo exactly at the next wake boundary.
1574 const preciseNow = new Date().toISOString();1594 void snoozeWakeTick;
1575 const visible = threads.filter(1595 const preciseNow = new Date().toISOString();
1576 (thread) =>1596 const visible = threads.filter(
1577 thread.archivedAt === null &&1597 (thread) =>
1578 (scopedProjectKeys === null ||1598 thread.archivedAt === null &&
1579 scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)),1599 (scopedProjectKeys === null ||
1580 );1600 scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)),
1581 const active: EnvironmentThreadShell[] = [];1601 );
1582 const snoozed: EnvironmentThreadShell[] = [];1602 const pinned: EnvironmentThreadShell[] = [];
1583 const settled: EnvironmentThreadShell[] = [];1603 const active: EnvironmentThreadShell[] = [];
1584 for (const thread of visible) {1604 const snoozed: EnvironmentThreadShell[] = [];
1585 // Threads on servers without the settlement capability (old server,1605 const settled: EnvironmentThreadShell[] = [];
1586 // or descriptor not loaded yet) never classify as settled: the user1606 for (const thread of visible) {
1587 // could neither un-settle nor pin them, so auto-settling them would1607 // Threads on servers without the settlement capability (old server,
1588 // strand rows in a tail with no working affordances.1608 // or descriptor not loaded yet) never classify as settled: the user
1589 const supportsSettlement =1609 // could neither un-settle nor pin them, so auto-settling them would
1590 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true;1610 // strand rows in a tail with no working affordances.
1591 const supportsSnooze =1611 const supportsSettlement =
1592 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;1612 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement ===
1593 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));1613 true;
1594 const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null;1614 const supportsSnooze =
1595 // Snooze outranks settled classification: an explicitly snoozed thread1615 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
1596 // belongs to the shelf even if it would also auto-settle (the shelf's1616 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
1597 // wake time is a stronger statement about when it matters again).1617 const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null;
1598 if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) {1618 // Snooze outranks everything, including a pin: "hide until Tuesday"
1599 snoozed.push(thread);1619 // temporarily suspends "keep on top". The pin survives underneath —
1600 } else if (1620 // pinned cards are creation-ordered, so on wake the thread reappears
1601 supportsSettlement &&1621 // at its original spot in the pinned block. (For unpinned threads
1602 effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })1622 // this is also the snooze-beats-auto-settle rule: the wake time is a
1603 ) {1623 // stronger statement about when the thread matters again.)
1604 settled.push(thread);1624 if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) {
1605 } else {1625 snoozed.push(thread);
1606 active.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 }
1607 }1640 }
1608 }1641 return {
1609 return {1642 // Same static creation order as the inbox: a pin freezes prominence,
1610 activeThreads: sortThreadsForSidebarV2(active),1643 // it does not introduce a new ordering scheme.
1611 // Soonest wake first: "what comes back next" is the shelf's question.1644 pinnedThreads: sortThreadsForSidebarV2(pinned),
1612 snoozedThreads: snoozed.toSorted(1645 activeThreads: sortThreadsForSidebarV2(active),
1613 (left, right) =>1646 // Soonest wake first: "what comes back next" is the shelf's question.
1614 firstValidTimestampMs(left.snoozedUntil ?? null) -1647 snoozedThreads: snoozed.toSorted(
1615 firstValidTimestampMs(right.snoozedUntil ?? null),1648 (left, right) =>
1616 ),1649 firstValidTimestampMs(left.snoozedUntil ?? null) -
1617 settledThreads: sortSettledThreadsForSidebarV2(settled),1650 firstValidTimestampMs(right.snoozedUntil ?? null),
1618 snoozeNow: preciseNow,1651 ),
1619 };1652 settledThreads: sortSettledThreadsForSidebarV2(settled),
1620 }, [1653 snoozeNow: preciseNow,
1621 autoSettleAfterDays,1654 };
1622 changeRequestStateByKey,1655 }, [
1623 nowMinute,1656 autoSettleAfterDays,
1624 scopedProjectKeys,1657 changeRequestStateByKey,
1625 serverConfigs,1658 nowMinute,
1626 snoozeWakeTick,1659 scopedProjectKeys,
1627 threads,1660 serverConfigs,
1628 ]);1661 snoozeWakeTick,
1662 threads,
1663 ]);
16291664
1630 const threadSearchInputRef = useRef<HTMLInputElement>(null);1665 const threadSearchInputRef = useRef<HTMLInputElement>(null);
1631 const [threadSearchQuery, setThreadSearchQuery] = useState("");1666 const [threadSearchQuery, setThreadSearchQuery] = useState("");
1632 const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0);1667 const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0);
1633 const isSearchingThreads = threadSearchQuery.trim().length > 0;1668 const isSearchingThreads = threadSearchQuery.trim().length > 0;
1634 const searchableThreads = useMemo(1669 const searchableThreads = useMemo(
1635 () => [...activeThreads, ...snoozedThreads, ...settledThreads],1670 () => [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads],
1636 [activeThreads, settledThreads, snoozedThreads],1671 [activeThreads, pinnedThreads, settledThreads, snoozedThreads],
1637 );1672 );
1638 const threadSearchResults = useMemo(1673 const threadSearchResults = useMemo(
1639 () => searchSidebarThreadsByTitle(searchableThreads, threadSearchQuery),1674 () => searchSidebarThreadsByTitle(searchableThreads, threadSearchQuery),
...@@ -1737,8 +1772,8 @@ export default function SidebarV2() {...@@ -1737,8 +1772,8 @@ export default function SidebarV2() {
1737 }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]);1772 }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]);
17381773
1739 const orderedThreads = useMemo(1774 const orderedThreads = useMemo(
1740 () => [...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads],1775 () => [...pinnedThreads, ...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads],
1741 [activeThreads, visibleSnoozedThreads, renderedSettledThreads],1776 [pinnedThreads, activeThreads, visibleSnoozedThreads, renderedSettledThreads],
1742 );1777 );
1743 const orderedThreadKeys = useMemo(1778 const orderedThreadKeys = useMemo(
1744 () =>1779 () =>
...@@ -2040,6 +2075,42 @@ export default function SidebarV2() {...@@ -2040,6 +2075,42 @@ export default function SidebarV2() {
2040 },2075 },
2041 [unsnoozeThread],2076 [unsnoozeThread],
2042 );2077 );
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 );
2043 // One snooze per thread at a time — same double-dispatch guard as settle.2114 // One snooze per thread at a time — same double-dispatch guard as settle.
2044 const snoozingThreadKeysRef = useRef(new Set<string>());2115 const snoozingThreadKeysRef = useRef(new Set<string>());
2045 const attemptSnooze = useCallback(2116 const attemptSnooze = useCallback(
...@@ -2203,7 +2274,8 @@ export default function SidebarV2() {...@@ -2203,7 +2274,8 @@ export default function SidebarV2() {
2203 // Post-settle navigation must skip threads settling in this same2274 // Post-settle navigation must skip threads settling in this same
2204 // batch — they are all leaving the card block together. Rows that2275 // batch — they are all leaving the card block together. Rows that
2205 // are already explicitly settled are skipped: nothing to do on a2276 // 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.
2207 const coSettlingKeys = new Set(threadKeys);2279 const coSettlingKeys = new Set(threadKeys);
2208 for (const threadKey of threadKeys) {2280 for (const threadKey of threadKeys) {
2209 const thread = threadByKeyRef.current.get(threadKey);2281 const thread = threadByKeyRef.current.get(threadKey);
...@@ -2300,12 +2372,15 @@ export default function SidebarV2() {...@@ -2300,12 +2372,15 @@ export default function SidebarV2() {
2300 true;2372 true;
2301 const supportsSnooze =2373 const supportsSnooze =
2302 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;2374 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
2375 const supportsPinning =
2376 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinning === true;
2303 const supportsTitleRegeneration =2377 const supportsTitleRegeneration =
2304 serverConfigs.get(thread.environmentId)?.environment.capabilities2378 serverConfigs.get(thread.environmentId)?.environment.capabilities
2305 .threadTitleRegeneration === true;2379 .threadTitleRegeneration === true;
2306 const isRegeneratingTitle = thread.titleRegeneration != null;2380 const isRegeneratingTitle = thread.titleRegeneration != null;
2307 const isSettled = settledThreadKeysRef.current.has(threadKey);2381 const isSettled = settledThreadKeysRef.current.has(threadKey);
2308 const isSnoozed = snoozedThreadKeysRef.current.has(threadKey);2382 const isSnoozed = snoozedThreadKeysRef.current.has(threadKey);
2383 const isPinned = thread.pinnedAt != null;
2309 // Presets resolve at menu-open time (same as the popover).2384 // Presets resolve at menu-open time (same as the popover).
2310 const snoozePresets = resolveSnoozePresets(new Date());2385 const snoozePresets = resolveSnoozePresets(new Date());
2311 const clicked = await settlePromise(() =>2386 const clicked = await settlePromise(() =>
...@@ -2319,6 +2394,16 @@ export default function SidebarV2() {...@@ -2319,6 +2394,16 @@ export default function SidebarV2() {
2319 },2394 },
2320 ]2395 ]
2321 : []),2396 : []),
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.
2322 ...(supportsSettlement2407 ...(supportsSettlement
2323 ? [2408 ? [
2324 isSettled2409 isSettled
...@@ -2400,6 +2485,12 @@ export default function SidebarV2() {...@@ -2400,6 +2485,12 @@ export default function SidebarV2() {
2400 case "unsnooze":2485 case "unsnooze":
2401 attemptUnsnooze(threadRef);2486 attemptUnsnooze(threadRef);
2402 return;2487 return;
2488 case "pin":
2489 attemptPin(threadRef);
2490 return;
2491 case "unpin":
2492 attemptUnpin(threadRef);
2493 return;
2403 case "rename":2494 case "rename":
2404 startThreadRename(threadRef, thread.title);2495 startThreadRename(threadRef, thread.title);
2405 return;2496 return;
...@@ -2474,8 +2565,10 @@ export default function SidebarV2() {...@@ -2474,8 +2565,10 @@ export default function SidebarV2() {
2474 })();2565 })();
2475 },2566 },
2476 [2567 [
2568 attemptPin,
2477 attemptSettle,2569 attemptSettle,
2478 attemptSnooze,2570 attemptSnooze,
2571 attemptUnpin,
2479 attemptUnsettle,2572 attemptUnsettle,
2480 attemptUnsnooze,2573 attemptUnsnooze,
2481 confirmThreadDelete,2574 confirmThreadDelete,
...@@ -2830,7 +2923,7 @@ export default function SidebarV2() {...@@ -2830,7 +2923,7 @@ export default function SidebarV2() {
2830 {(() => {2923 {(() => {
2831 const renderThreadRow = (2924 const renderThreadRow = (
2832 thread: EnvironmentThreadShell,2925 thread: EnvironmentThreadShell,
2833 section: "active" | "snoozed" | "settled",2926 section: "pinned" | "active" | "snoozed" | "settled",
2834 ) => {2927 ) => {
2835 const threadKey = scopedThreadKey(2928 const threadKey = scopedThreadKey(
2836 scopeThreadRef(thread.environmentId, thread.id),2929 scopeThreadRef(thread.environmentId, thread.id),
...@@ -2839,7 +2932,7 @@ export default function SidebarV2() {...@@ -2839,7 +2932,7 @@ export default function SidebarV2() {
2839 // row: every other thread is a full card. Density comes2932 // row: every other thread is a full card. Density comes
2840 // from users (or the auto rules) actually parking work,2933 // from users (or the auto rules) actually parking work,
2841 // not from the sidebar second-guessing what still matters.2934 // not from the sidebar second-guessing what still matters.
2842 const isCard = section === "active";2935 const isCard = section === "active" || section === "pinned";
2843 const rowVariant = isCard ? "card" : "slim";2936 const rowVariant = isCard ? "card" : "slim";
2844 return (2937 return (
2845 <SidebarV2Row2938 <SidebarV2Row
...@@ -2870,6 +2963,7 @@ export default function SidebarV2() {...@@ -2870,6 +2963,7 @@ export default function SidebarV2() {
2870 serverConfigs.get(thread.environmentId)?.environment.capabilities2963 serverConfigs.get(thread.environmentId)?.environment.capabilities
2871 .threadSnooze === true2964 .threadSnooze === true
2872 }2965 }
2966 isPinned={section === "pinned"}
2873 snoozeWakeLabelText={2967 snoozeWakeLabelText={
2874 section === "snoozed" && thread.snoozedUntil != null2968 section === "snoozed" && thread.snoozedUntil != null
2875 ? snoozeWakeLabel(thread.snoozedUntil, {2969 ? snoozeWakeLabel(thread.snoozedUntil, {
...@@ -2912,9 +3006,25 @@ export default function SidebarV2() {...@@ -2912,9 +3006,25 @@ export default function SidebarV2() {
2912 />3006 />
2913 );3007 );
2914 };3008 };
2915 const items: ReactNode[] = activeThreads.map((thread) =>3009 // Pinned block: full cards above the inbox, closed by a
2916 renderThreadRow(thread, "active"),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"),
2917 );3014 );
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 }
2918 // Snoozed shelf: between the inbox and Settled — out of the3028 // Snoozed shelf: between the inbox and Settled — out of the
2919 // way, never gone. The header always renders while anything3029 // way, never gone. The header always renders while anything
2920 // is snoozed (the count is the whole footprint when3030 // is snoozed (the count is the whole footprint when
...@@ -3006,7 +3116,11 @@ export default function SidebarV2() {...@@ -3006,7 +3116,11 @@ export default function SidebarV2() {
3006 </TooltipProvider>3116 </TooltipProvider>
3007 ) : null}3117 ) : null}
3008 {!isSearchingThreads &&3118 {!isSearchingThreads &&
3009 activeThreads.length + snoozedThreads.length + settledThreads.length === 0 ? (3119 pinnedThreads.length +
3120 activeThreads.length +
3121 snoozedThreads.length +
3122 settledThreads.length ===
3123 0 ? (
3010 <div className="flex flex-col items-center gap-2 px-2 py-6 text-center text-xs text-muted-foreground/60">3124 <div className="flex flex-col items-center gap-2 px-2 py-6 text-center text-xs text-muted-foreground/60">
3011 {projects.length === 0 ? (3125 {projects.length === 0 ? (
3012 <>3126 <>
apps/web/src/hooks/useThreadActions.ts+64
...@@ -21,6 +21,7 @@ import { useNewThreadHandler } from "./useHandleNewThread";...@@ -21,6 +21,7 @@ import { useNewThreadHandler } from "./useHandleNewThread";
21import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState";21import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState";
22import { readLocalApi } from "../localApi";22import { readLocalApi } from "../localApi";
23import {23import {
24 readEnvironmentSupportsPinning,
24 readEnvironmentSupportsSettlement,25 readEnvironmentSupportsSettlement,
25 readEnvironmentSupportsSnooze,26 readEnvironmentSupportsSnooze,
26 readEnvironmentThreadRefs,27 readEnvironmentThreadRefs,
...@@ -94,6 +95,18 @@ export class ThreadSnoozeBlockedError extends Schema.TaggedErrorClass<ThreadSnoo...@@ -94,6 +95,18 @@ export class ThreadSnoozeBlockedError extends Schema.TaggedErrorClass<ThreadSnoo
94 }95 }
95}96}
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
97export function useThreadActions() {110export function useThreadActions() {
98 const closeTerminal = useAtomCommand(terminalEnvironment.close);111 const closeTerminal = useAtomCommand(terminalEnvironment.close);
99 const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, {112 const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, {
...@@ -111,6 +124,12 @@ export function useThreadActions() {...@@ -111,6 +124,12 @@ export function useThreadActions() {
111 const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, {124 const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, {
112 reportFailure: false,125 reportFailure: false,
113 });126 });
127 const pinThreadMutation = useAtomCommand(threadEnvironment.pin, {
128 reportFailure: false,
129 });
130 const unpinThreadMutation = useAtomCommand(threadEnvironment.unpin, {
131 reportFailure: false,
132 });
114 const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, {133 const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, {
115 reportFailure: false,134 reportFailure: false,
116 });135 });
...@@ -472,6 +491,47 @@ export function useThreadActions() {...@@ -472,6 +491,47 @@ export function useThreadActions() {
472 [unsettleThreadMutation],491 [unsettleThreadMutation],
473 );492 );
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
475 const snoozeThread = useCallback(535 const snoozeThread = useCallback(
476 async (target: ScopedThreadRef, snoozedUntil: string) => {536 async (target: ScopedThreadRef, snoozedUntil: string) => {
477 // Version skew: never send the command to a server that predates it.537 // Version skew: never send the command to a server that predates it.
...@@ -565,14 +625,18 @@ export function useThreadActions() {...@@ -565,14 +625,18 @@ export function useThreadActions() {
565 unsettleThread,625 unsettleThread,
566 snoozeThread,626 snoozeThread,
567 unsnoozeThread,627 unsnoozeThread,
628 pinThread,
629 unpinThread,
568 }),630 }),
569 [631 [
570 archiveThread,632 archiveThread,
571 confirmAndDeleteThread,633 confirmAndDeleteThread,
572 deleteThread,634 deleteThread,
635 pinThread,
573 settleThread,636 settleThread,
574 snoozeThread,637 snoozeThread,
575 unarchiveThread,638 unarchiveThread,
639 unpinThread,
576 unsettleThread,640 unsettleThread,
577 unsnoozeThread,641 unsnoozeThread,
578 ],642 ],
apps/web/src/state/entities.ts+9
...@@ -241,6 +241,15 @@ export function readEnvironmentSupportsSnooze(environmentId: EnvironmentId): boo...@@ -241,6 +241,15 @@ export function readEnvironmentSupportsSnooze(environmentId: EnvironmentId): boo
241 );241 );
242}242}
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
244export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null {253export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null {
245 return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref));254 return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref));
246}255}
packages/client-runtime/src/operations/commands.ts+22
...@@ -39,6 +39,8 @@ export type SettleThreadInput = CommandInput<"thread.settle">;...@@ -39,6 +39,8 @@ export type SettleThreadInput = CommandInput<"thread.settle">;
39export type UnsettleThreadInput = CommandInput<"thread.unsettle">;39export type UnsettleThreadInput = CommandInput<"thread.unsettle">;
40export type SnoozeThreadInput = CommandInput<"thread.snooze">;40export type SnoozeThreadInput = CommandInput<"thread.snooze">;
41export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">;41export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">;
42export type PinThreadInput = CommandInput<"thread.pin">;
43export type UnpinThreadInput = CommandInput<"thread.unpin">;
42export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">;44export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">;
43export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">;45export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">;
44export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">;46export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">;
...@@ -197,6 +199,26 @@ export const unsnoozeThread: (input: UnsnoozeThreadInput) => CommandEffect = Eff...@@ -197,6 +199,26 @@ export const unsnoozeThread: (input: UnsnoozeThreadInput) => CommandEffect = Eff
197 });199 });
198});200});
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
200export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn(222export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn(
201 "EnvironmentCommands.updateThreadMetadata",223 "EnvironmentCommands.updateThreadMetadata",
202)(function* (input) {224)(function* (input) {
packages/client-runtime/src/state/threadCommands.ts+18
...@@ -12,11 +12,13 @@ import {...@@ -12,11 +12,13 @@ import {
12 type RevertThreadCheckpointInput,12 type RevertThreadCheckpointInput,
13 type SetThreadInteractionModeInput,13 type SetThreadInteractionModeInput,
14 type SetThreadRuntimeModeInput,14 type SetThreadRuntimeModeInput,
15 type PinThreadInput,
15 type SettleThreadInput,16 type SettleThreadInput,
16 type SnoozeThreadInput,17 type SnoozeThreadInput,
17 type StartThreadTurnInput,18 type StartThreadTurnInput,
18 type StopThreadSessionInput,19 type StopThreadSessionInput,
19 type UnarchiveThreadInput,20 type UnarchiveThreadInput,
21 type UnpinThreadInput,
20 type UnsettleThreadInput,22 type UnsettleThreadInput,
21 type UnsnoozeThreadInput,23 type UnsnoozeThreadInput,
22 type UpdateThreadMetadataInput,24 type UpdateThreadMetadataInput,
...@@ -29,11 +31,13 @@ import {...@@ -29,11 +31,13 @@ import {
29 revertThreadCheckpoint,31 revertThreadCheckpoint,
30 setThreadInteractionMode,32 setThreadInteractionMode,
31 setThreadRuntimeMode,33 setThreadRuntimeMode,
34 pinThread,
32 settleThread,35 settleThread,
33 snoozeThread,36 snoozeThread,
34 startThreadTurn,37 startThreadTurn,
35 stopThreadSession,38 stopThreadSession,
36 unarchiveThread,39 unarchiveThread,
40 unpinThread,
37 unsettleThread,41 unsettleThread,
38 unsnoozeThread,42 unsnoozeThread,
39 updateThreadMetadata,43 updateThreadMetadata,
...@@ -50,11 +54,13 @@ export type {...@@ -50,11 +54,13 @@ export type {
50 RevertThreadCheckpointInput,54 RevertThreadCheckpointInput,
51 SetThreadInteractionModeInput,55 SetThreadInteractionModeInput,
52 SetThreadRuntimeModeInput,56 SetThreadRuntimeModeInput,
57 PinThreadInput,
53 SettleThreadInput,58 SettleThreadInput,
54 SnoozeThreadInput,59 SnoozeThreadInput,
55 StartThreadTurnInput,60 StartThreadTurnInput,
56 StopThreadSessionInput,61 StopThreadSessionInput,
57 UnarchiveThreadInput,62 UnarchiveThreadInput,
63 UnpinThreadInput,
58 UnsettleThreadInput,64 UnsettleThreadInput,
59 UnsnoozeThreadInput,65 UnsnoozeThreadInput,
60 UpdateThreadMetadataInput,66 UpdateThreadMetadataInput,
...@@ -118,6 +124,18 @@ export function createThreadEnvironmentAtoms<R, E>(...@@ -118,6 +124,18 @@ export function createThreadEnvironmentAtoms<R, E>(
118 scheduler,124 scheduler,
119 concurrency,125 concurrency,
120 }),126 }),
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 }),
121 updateMetadata: createEnvironmentCommand(runtime, {139 updateMetadata: createEnvironmentCommand(runtime, {
122 label: "environment-data:commands:thread:update-metadata",140 label: "environment-data:commands:thread:update-metadata",
123 execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input),141 execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input),
packages/client-runtime/src/state/threadDetail.ts+1
...@@ -61,6 +61,7 @@ export function mergeEnvironmentThread(...@@ -61,6 +61,7 @@ export function mergeEnvironmentThread(
61 settledAt: shell.settledAt,61 settledAt: shell.settledAt,
62 snoozedUntil: shell.snoozedUntil,62 snoozedUntil: shell.snoozedUntil,
63 snoozedAt: shell.snoozedAt,63 snoozedAt: shell.snoozedAt,
64 pinnedAt: shell.pinnedAt,
64 session: shell.session,65 session: shell.session,
65 };66 };
66}67}
packages/client-runtime/src/state/threadReducer.test.ts+49
...@@ -231,6 +231,55 @@ describe("applyThreadDetailEvent", () => {...@@ -231,6 +231,55 @@ describe("applyThreadDetailEvent", () => {
231 });231 });
232 });232 });
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
234 describe("thread.meta-updated", () => {283 describe("thread.meta-updated", () => {
235 it("patches title and branch", () => {284 it("patches title and branch", () => {
236 const result = applyThreadDetailEvent(baseThread, {285 const result = applyThreadDetailEvent(baseThread, {
packages/client-runtime/src/state/threadReducer.ts+20
...@@ -167,6 +167,26 @@ export function applyThreadDetailEvent(...@@ -167,6 +167,26 @@ export function applyThreadDetailEvent(
167 },167 },
168 };168 };
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
170 // ── Thread metadata ─────────────────────────────────────────────190 // ── Thread metadata ─────────────────────────────────────────────
171 case "thread.meta-updated":191 case "thread.meta-updated":
172 return {192 return {
packages/contracts/src/environment.ts+3
...@@ -47,6 +47,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({...@@ -47,6 +47,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
47 /** Server understands thread.snooze / thread.unsnooze commands. Same47 /** Server understands thread.snooze / thread.unsnooze commands. Same
48 version-skew contract as threadSettlement. */48 version-skew contract as threadSettlement. */
49 threadSnooze: Schema.optionalKey(Schema.Boolean),49 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),
50 /** Server understands regenerateTitle on thread.meta.update. Absent on53 /** Server understands regenerateTitle on thread.meta.update. Absent on
51 older servers, so clients hide the action instead of sending it. */54 older servers, so clients hide the action instead of sending it. */
52 threadTitleRegeneration: Schema.optionalKey(Schema.Boolean),55 threadTitleRegeneration: Schema.optionalKey(Schema.Boolean),
packages/contracts/src/orchestration.ts+44
...@@ -373,6 +373,10 @@ export const OrchestrationThread = Schema.Struct({...@@ -373,6 +373,10 @@ export const OrchestrationThread = Schema.Struct({
373 // Optional so payloads from pre-snooze servers still decode.373 // Optional so payloads from pre-snooze servers still decode.
374 snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)),374 snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)),
375 snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),375 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)),
376 // Pending-only state. Optional so older servers remain compatible.380 // Pending-only state. Optional so older servers remain compatible.
377 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),381 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),
378 deletedAt: Schema.NullOr(IsoDateTime),382 deletedAt: Schema.NullOr(IsoDateTime),
...@@ -427,6 +431,7 @@ export const OrchestrationThreadShell = Schema.Struct({...@@ -427,6 +431,7 @@ export const OrchestrationThreadShell = Schema.Struct({
427 settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))),431 settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))),
428 snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)),432 snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)),
429 snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),433 snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
434 pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
430 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),435 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),
431 session: Schema.NullOr(OrchestrationSession),436 session: Schema.NullOr(OrchestrationSession),
432 latestUserMessageAt: Schema.NullOr(IsoDateTime),437 latestUserMessageAt: Schema.NullOr(IsoDateTime),
...@@ -621,6 +626,18 @@ const ThreadUnsnoozeCommand = Schema.Struct({...@@ -621,6 +626,18 @@ const ThreadUnsnoozeCommand = Schema.Struct({
621 reason: Schema.Literal("user"),626 reason: Schema.Literal("user"),
622});627});
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
624const ThreadMetaUpdateCommand = Schema.Struct({641const ThreadMetaUpdateCommand = Schema.Struct({
625 type: Schema.Literal("thread.meta.update"),642 type: Schema.Literal("thread.meta.update"),
626 commandId: CommandId,643 commandId: CommandId,
...@@ -774,6 +791,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([...@@ -774,6 +791,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([
774 ThreadUnsettleCommand,791 ThreadUnsettleCommand,
775 ThreadSnoozeCommand,792 ThreadSnoozeCommand,
776 ThreadUnsnoozeCommand,793 ThreadUnsnoozeCommand,
794 ThreadPinCommand,
795 ThreadUnpinCommand,
777 ThreadMetaUpdateCommand,796 ThreadMetaUpdateCommand,
778 ThreadRuntimeModeSetCommand,797 ThreadRuntimeModeSetCommand,
779 ThreadInteractionModeSetCommand,798 ThreadInteractionModeSetCommand,
...@@ -799,6 +818,8 @@ export const ClientOrchestrationCommand = Schema.Union([...@@ -799,6 +818,8 @@ export const ClientOrchestrationCommand = Schema.Union([
799 ThreadUnsettleCommand,818 ThreadUnsettleCommand,
800 ThreadSnoozeCommand,819 ThreadSnoozeCommand,
801 ThreadUnsnoozeCommand,820 ThreadUnsnoozeCommand,
821 ThreadPinCommand,
822 ThreadUnpinCommand,
802 ThreadMetaUpdateCommand,823 ThreadMetaUpdateCommand,
803 ThreadRuntimeModeSetCommand,824 ThreadRuntimeModeSetCommand,
804 ThreadInteractionModeSetCommand,825 ThreadInteractionModeSetCommand,
...@@ -914,6 +935,8 @@ export const OrchestrationEventType = Schema.Literals([...@@ -914,6 +935,8 @@ export const OrchestrationEventType = Schema.Literals([
914 "thread.unsettled",935 "thread.unsettled",
915 "thread.snoozed",936 "thread.snoozed",
916 "thread.unsnoozed",937 "thread.unsnoozed",
938 "thread.pinned",
939 "thread.unpinned",
917 "thread.meta-updated",940 "thread.meta-updated",
918 "thread.runtime-mode-set",941 "thread.runtime-mode-set",
919 "thread.interaction-mode-set",942 "thread.interaction-mode-set",
...@@ -1022,6 +1045,17 @@ export const ThreadUnsnoozedPayload = Schema.Struct({...@@ -1022,6 +1045,17 @@ export const ThreadUnsnoozedPayload = Schema.Struct({
1022 updatedAt: IsoDateTime,1045 updatedAt: IsoDateTime,
1023});1046});
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
1025export const ThreadMetaUpdatedPayload = Schema.Struct({1059export const ThreadMetaUpdatedPayload = Schema.Struct({
1026 threadId: ThreadId,1060 threadId: ThreadId,
1027 title: Schema.optional(TrimmedNonEmptyString),1061 title: Schema.optional(TrimmedNonEmptyString),
...@@ -1216,6 +1250,16 @@ export const OrchestrationEvent = Schema.Union([...@@ -1216,6 +1250,16 @@ export const OrchestrationEvent = Schema.Union([
1216 type: Schema.Literal("thread.unsnoozed"),1250 type: Schema.Literal("thread.unsnoozed"),
1217 payload: ThreadUnsnoozedPayload,1251 payload: ThreadUnsnoozedPayload,
1218 }),1252 }),
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 }),
1219 Schema.Struct({1263 Schema.Struct({
1220 ...EventBaseFields,1264 ...EventBaseFields,
1221 type: Schema.Literal("thread.meta-updated"),1265 type: Schema.Literal("thread.meta-updated"),