# Files changed · PR \#8 · flambo/t3code

[View on GitCafe](https://git.cafe/flambo/t3code/pull/8/diffs)

Repository: [flambo/t3code](https://git.cafe/flambo/t3code)

Visibility: public

[feat\(mobile\): snooze a thread until its pull request needs attention](https://git.cafe/flambo/t3code/pull/8.md)

Version: 1

Head commit: cc524de24b9d73870691f22bed9e3fff32f07138

Base commit: 12caa30eb8cfc90b782f9ed7ac0cf7c290238d7d

Comparison base: 12caa30eb8cfc90b782f9ed7ac0cf7c290238d7d

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/mobile/src/features/home/HomeScreen.tsx

Status: modified; +7; −2

```diff
@@ -97,7 +97,7 @@
   readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
   readonly onSnoozeThread: (
     thread: EnvironmentThreadShell,
-    snoozedUntil: string,
+    snoozedUntil: string | null,
   ) => Promise<boolean>;
   readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
   readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
@@ -402,7 +402,7 @@
   // optimistic holds.
   const handleSettleThread = props.onSettleThread;
   const handleSnoozeThread = useCallback(
+    (thread: EnvironmentThreadShell, snoozedUntil: string | null) => {
-    (thread: EnvironmentThreadShell, snoozedUntil: string) => {
       void props.onSnoozeThread(thread, snoozedUntil);
     },
     [props.onSnoozeThread],
@@ -496,6 +496,7 @@
     machineByEnvironmentId,
     settlementEnvironmentIds,
     snoozeEnvironmentIds,
+    snoozeUntilAttentionEnvironmentIds,
     pinningEnvironmentIds,
     autoSettleOptOutEnvironmentIds,
     pinReorderEnvironmentIds,
@@ -755,6 +756,9 @@
           settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
           onSettleThread={handleSettleThread}
           snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
+          snoozeUntilAttentionSupported={snoozeUntilAttentionEnvironmentIds.has(
+            thread.environmentId,
+          )}
           pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
           autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)}
           reorderSupported={
@@ -808,6 +812,7 @@
       providersByEnvironmentId,
       settlementEnvironmentIds,
       snoozeEnvironmentIds,
+      snoozeUntilAttentionEnvironmentIds,
       threadSearchMatchByKey,
       titleRegenerationEnvironmentIds,
       toggleSettledShelf,
```

## apps/mobile/src/features/home/useThreadListActions.ts

Status: modified; +41; −10

```diff
@@ -48,6 +48,15 @@
   );
 }
 
+function environmentSupportsSnoozeUntilAttention(
+  environmentId: EnvironmentThreadShell["environmentId"],
+) {
+  return (
+    appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
+      .threadSnoozeUntilAttention === true
+  );
+}
+
 function environmentSupportsPinning(environmentId: EnvironmentThreadShell["environmentId"]) {
   return (
     appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
@@ -248,7 +257,11 @@
   readonly archiveThread: (thread: EnvironmentThreadShell) => void;
   readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void;
   readonly settleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
-  readonly snoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => Promise<boolean>;
+  /** Snoozes until `snoozedUntil`, or until a linked pull request needs attention when null. */
+  readonly snoozeThread: (
+    thread: EnvironmentThreadShell,
+    snoozedUntil: string | null,
+  ) => Promise<boolean>;
   readonly unsnoozeThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
   readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
   readonly pinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
@@ -268,6 +281,9 @@
   const executeAction = useThreadActionExecutor();
   const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false });
   const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false });
+  const snoozeUntilAttentionMutation = useAtomCommand(threadEnvironment.snoozeUntilAttention, {
+    reportFailure: false,
+  });
   const pinMutation = useAtomCommand(threadEnvironment.pin, { reportFailure: false });
   const unpinMutation = useAtomCommand(threadEnvironment.unpin, { reportFailure: false });
   const setAutoSettleMutation = useAtomCommand(threadEnvironment.setAutoSettle, {
@@ -290,7 +306,7 @@
     [executeAction],
   );
   const snoozeThread = useCallback(
+    async (thread: EnvironmentThreadShell, snoozedUntil: string | null) => {
-    async (thread: EnvironmentThreadShell, snoozedUntil: string) => {
       if (!checkThreadOperationPermission(thread, "Could not snooze thread")) return false;
       const key = scopedThreadKey(thread.environmentId, thread.id);
       if (snoozeInFlightThreadKeys.current.has(key)) {
@@ -305,6 +321,16 @@
           );
           return false;
         }
+        if (
+          snoozedUntil === null &&
+          !environmentSupportsSnoozeUntilAttention(thread.environmentId)
+        ) {
+          Alert.alert(
+            "Could not snooze thread",
+            "This environment's server does not support snoozing until a pull request needs attention yet. Update the server to use it.",
+          );
+          return false;
+        }
         if (!canSnooze(thread, { now: new Date().toISOString() })) {
           Alert.alert(
             "Could not snooze thread",
@@ -319,13 +345,18 @@
         const result = await withThreadDismissal(
           key,
           () =>
+            snoozedUntil === null
+              ? snoozeUntilAttentionMutation({
+                  environmentId: thread.environmentId,
+                  input: { threadId: thread.id },
+                })
+              : snoozeMutation({
+                  environmentId: thread.environmentId,
+                  input: {
+                    threadId: thread.id,
-            snoozeMutation({
-              environmentId: thread.environmentId,
-              input: {
-                threadId: thread.id,
-                snoozedUntil,
-              },
+                    snoozedUntil,
+                  },
-            }),
+                }),
           (result) => result._tag === "Success",
         );
         if (result._tag === "Failure") {
@@ -343,7 +374,7 @@
         snoozeInFlightThreadKeys.current.delete(key);
       }
     },
+    [snoozeMutation, snoozeUntilAttentionMutation],
-    [snoozeMutation],
   );
   const unsnoozeThread = useCallback(
     async (thread: EnvironmentThreadShell) => {
```

## apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx

Status: modified; +22; −0

```diff
@@ -25,6 +25,7 @@
 import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync";
 import { useSettingsEnvironmentFilter } from "./settings-environment-filter";
 import {
+  everyMobileSettingsTargetSupports,
   planMobileScopedSettingsClear,
   planMobileScopedSettingsPatch,
   resolveMobileSettingsTargets,
@@ -96,6 +97,7 @@
     patch: Partial<AutoSettleSettings> & {
       autoResumeLimitedThreads?: boolean;
       snoozeLimitedThreads?: boolean;
+      snoozePullRequestsAwaitingReview?: boolean;
     },
   ) => {
     if (
@@ -141,6 +143,10 @@
   );
   const disabled =
     !canWriteSettings || pendingWrites > 0 || (projectSelected && !supportsProjectOverrides);
+  const supportsSnoozeUntilAttention = everyMobileSettingsTargetSupports(
+    selectedTargets,
+    "threadSnoozeUntilAttention",
+  );
   const hasProjectOverrides =
     projectSelected &&
     syncTargets.some(
@@ -207,6 +213,22 @@
           />
         </SettingsSection>
       ) : null}
+      {!projectSelected ? (
+        <SettingsSection title="Pull requests">
+          <SettingsSwitchRow
+            icon="eye"
+            label="Snooze pull requests awaiting review"
+            subtitle={
+              supportsSnoozeUntilAttention
+                ? "Let agents snooze threads whose pull requests only wait on someone else's review. They wake when the pull request needs attention."
+                : "Update older servers to let agents snooze pull requests awaiting review."
+            }
+            value={uniformMobileSetting(displayTargets, "snoozePullRequestsAwaitingReview")}
+            disabled={disabled || !supportsSnoozeUntilAttention}
+            onValueChange={(value) => writeToAll({ snoozePullRequestsAwaitingReview: value })}
+          />
+        </SettingsSection>
+      ) : null}
       <SettingsSection title="Auto-settle">
         <SettingsSwitchRow
           icon="arrow.triangle.branch"
```

## apps/mobile/src/features/settings/settings-scoped-server.test.ts

Status: modified; +32; −2

```diff
@@ -4,10 +4,12 @@
   type ProjectId,
   type ServerSettings,
 } from "@t3tools/contracts";
+import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings";
 import { describe, expect, it } from "vite-plus/test";
 
 import type { SettingsTarget } from "./settings-environment-filter";
 import {
+  everyMobileSettingsTargetSupports,
   planMobileScopedSettingsClear,
   planMobileScopedSettingsPatch,
   resolveMobileSettingsTargets,
@@ -19,8 +21,12 @@
 const firstProject = "first-project" as ProjectId;
 const secondProject = "second-project" as ProjectId;
 
+describe("mobile thread behavior switches across environments", () => {
+  it.each([
+    "autoResumeLimitedThreads",
-describe("mobile usage-limit settings across environments", () => {
+    "snoozeLimitedThreads",
+    "snoozePullRequestsAwaitingReview",
+  ] as const)(
-  it.each(["autoResumeLimitedThreads", "snoozeLimitedThreads"] as const)(
     "shows %s as mixed and can enable it everywhere without changing other settings",
     (key) => {
       const targets = resolveMobileSettingsTargets(
@@ -55,6 +61,30 @@
   );
 });
 
+describe("mobile settings capability across selected environments", () => {
+  it("counts an older server that other rows skip for lacking auto-settlement", () => {
+    const current = {
+      connection: { phase: "connected" },
+      serverConfig: {
+        environment: {
+          capabilities: { threadAutoSettlement: true, threadSnoozeUntilAttention: true },
+        },
+      },
+    } as SettingsTarget;
+    const older = {
+      connection: { phase: "connected" },
+      serverConfig: { environment: { capabilities: {} } },
+    } as SettingsTarget;
+
+    expect([current, older].filter(supportsSharedSettingsSync)).toEqual([current]);
+
+    expect(everyMobileSettingsTargetSupports([current], "threadSnoozeUntilAttention")).toBe(true);
+    expect(everyMobileSettingsTargetSupports([current, older], "threadSnoozeUntilAttention")).toBe(
+      false,
+    );
+  });
+});
+
 function environment(environmentId: EnvironmentId, settings: ServerSettings): SettingsTarget {
   return {
     environmentId,
```

## apps/mobile/src/features/settings/settings-scoped-server.ts

Status: modified; +10; −0

```diff
@@ -1,5 +1,6 @@
 import {
   isNullableProjectSettingsOverride,
+  type ExecutionEnvironmentCapabilities,
   PROJECT_SCOPED_SERVER_SETTING_KEYS,
   type EnvironmentId,
   type ProjectId,
@@ -46,6 +47,15 @@
   });
 }
 
+export function everyMobileSettingsTargetSupports(
+  environments: readonly Pick<SettingsTarget, "serverConfig">[],
+  capability: keyof ExecutionEnvironmentCapabilities,
+) {
+  return environments.every(
+    (environment) => environment.serverConfig.environment.capabilities[capability] === true,
+  );
+}
+
 export function planMobileScopedSettingsPatch(
   targets: readonly ScopedMobileSettingsTarget[],
   projectSelected: boolean,
```

## apps/mobile/src/features/threads/ThreadDetailScreen.tsx

Status: modified; +14; −0

```diff
@@ -36,6 +36,7 @@
   presentProviderGoal,
 } from "@t3tools/client-runtime/state/thread-execution";
 import { resolveSubagentPillSegment } from "@t3tools/client-runtime/state/thread-subagents";
+import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
 import {
   formatModelSelectionEffort,
   type ProviderSubagentStatus,
@@ -138,6 +139,7 @@
   ThreadComposer,
 } from "./ThreadComposer";
 import { ThreadFeed, type ThreadFeedHistoryControls } from "./ThreadFeed";
+import { useThreadListActions } from "../home/useThreadListActions";
 import { useThreadTurnSubagents } from "./ThreadAgentsSheet";
 import { ComposerQueuedEditBanner } from "./ComposerQueuedEdit";
 import { useThreadQueuedCount } from "./ThreadQueueControl";
@@ -470,6 +472,11 @@
   const pendingBackgroundWork = presentPendingBackgroundWork(
     props.selectedThread.pendingBackgroundTasks,
   );
+  const { unsnoozeThread } = useThreadListActions();
+  const snoozedUntilAttention =
+    props.selectedThread.snoozedUntilNeedsAttention === true &&
+    props.serverConfig?.environment.capabilities.threadSnooze === true &&
+    effectiveSnoozed(props.selectedThread, { now: new Date().toISOString() });
   const floatingStatus = ((): FloatingWorkingStatus | null => {
     const connectionStatus = connectionFloatingStatus({
       connectionError: props.connectionError,
@@ -503,6 +510,13 @@
     if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") {
       return { kind: "working", startedAt: props.activeWorkStartedAt };
     }
+    if (snoozedUntilAttention && contentPresentationKind === "ready") {
+      return {
+        kind: "snoozed",
+        label: "Snoozed until its pull request needs attention",
+        onWake: () => void unsnoozeThread(props.selectedThread),
+      };
+    }
     if (pendingBackgroundWork !== null && contentPresentationKind === "ready") {
       return {
         kind: "background",
```

## apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx

Status: modified; +5; −0

```diff
@@ -319,6 +319,7 @@
     machineByEnvironmentId,
     settlementEnvironmentIds,
     snoozeEnvironmentIds,
+    snoozeUntilAttentionEnvironmentIds,
     pinningEnvironmentIds,
     autoSettleOptOutEnvironmentIds,
     pinReorderEnvironmentIds,
@@ -730,6 +731,9 @@
               settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
               onSettleThread={settleThread}
               snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
+              snoozeUntilAttentionSupported={snoozeUntilAttentionEnvironmentIds.has(
+                thread.environmentId,
+              )}
               pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
               autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)}
               reorderSupported={
@@ -828,6 +832,7 @@
       sidebarScrollGesture,
       snoozeEnvironmentIds,
       snoozeThread,
+      snoozeUntilAttentionEnvironmentIds,
       toggleSettledShelf,
       toggleSnoozedShelf,
       toggleWorkingShelf,
```

## apps/mobile/src/features/threads/floating-working-control.tsx

Status: modified; +22; −3

```diff
@@ -159,15 +159,15 @@
     hasAgents ||
     hasQueue ||
     (props.devicePreview !== null && props.browserPreview !== null);
-  // The queue, agents, and reconnect labels have separate tap targets.
-  const statusInteractive = props.status?.kind === "connection";
+  // The queue, agents, reconnect, and wake labels have separate tap targets.
+  const statusInteractive = props.status?.kind === "connection" || props.status?.kind === "snoozed";
   const capsuleInteractive = statusInteractive || hasQueue || hasAgents || hasPreview;
   // The host stays centered on the capsule, but its measurement constraint
   // comes from the overlay, independent of the capsule's current width.
   const statusContent =
     props.status !== null ? (
       <View
-        pointerEvents={props.status.kind === "connection" ? "box-none" : "none"}
+        pointerEvents={statusInteractive ? "box-none" : "none"}
         className="h-11 items-center justify-center"
       >
         <Animated.View className="h-11" style={capsuleSizerStyle} />
@@ -417,6 +417,25 @@
       </StatusLabelRow>
     );
   }
+  if (props.status.kind === "snoozed") {
+    return (
+      <StatusLabelRow
+        key="snoozed"
+        accessibilityLabel={`${props.status.label}. Wake`}
+        accessibilityRole="button"
+        className="gap-2"
+        onLayout={props.onLayout}
+        onPress={props.status.onWake}
+      >
+        <SymbolView name="eye" size={13} tintColorClassName="foreground" type="monochrome" />
+        <Text className="shrink font-t3-medium text-xs text-foreground" numberOfLines={1}>
+          {props.status.label}
+        </Text>
+        <View className="h-4 w-px bg-border" />
+        <Text className="font-t3-medium text-xs text-foreground-muted">Wake</Text>
+      </StatusLabelRow>
+    );
+  }
   if (props.status.kind === "goal") {
     return (
       <StatusLabelRow
```

## apps/mobile/src/features/threads/floating-working-status.ts

Status: modified; +3; −0

```diff
@@ -17,6 +17,9 @@
       readonly accessibilityLabel: string;
       readonly waiting: boolean;
     }
+  // Snoozed until its pull request needs attention. The watch holding it would
+  // otherwise read as background work, so this leads it; tapping wakes the thread.
+  | { readonly kind: "snoozed"; readonly label: string; readonly onWake: () => void }
   // A native /goal on an idle thread: paused, blocked, complete, or set.
   | { readonly kind: "goal"; readonly label: string; readonly accessibilityLabel: string }
   // A task whose thread the server has not created yet: the worktree may
```

## apps/mobile/src/features/threads/thread-list-v2-items.tsx

Status: modified; +58; −19

```diff
@@ -18,7 +18,11 @@
 } from "@t3tools/client-runtime/state/shell";
 import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
 import { AuthOrchestrationOperateScope, type EnvironmentMachineKind } from "@t3tools/contracts";
+import {
+  canSnooze,
+  offersSnoozeUntilAttention,
-import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
+  resolveSnoozePresets,
+} from "@t3tools/client-runtime/state/thread-settled";
 import type { MenuAction } from "@react-native-menu/menu";
 import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
 import { Alert, Pressable, useWindowDimensions, View } from "react-native";
@@ -42,6 +46,8 @@
 import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
 import {
   THREAD_LIST_V2_SETTLED_PAGE_COUNT,
+  THREAD_LIST_V2_SNOOZE_UNTIL_ATTENTION_ACTION,
+  resolveThreadListV2SnoozeAccessibilityValue,
   resolveThreadListV2SnoozeGateExpiryMs,
   resolveThreadListV2SnoozeMenuSelection,
   threadHasUnseenCompletion,
@@ -504,7 +510,8 @@
   readonly onRenameThread: (thread: EnvironmentThreadShell) => void;
   readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void;
   readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
-  readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void;
+  /** `snoozedUntil` is null to snooze until a linked pull request needs attention. */
+  readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string | null) => void;
   readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void;
   readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
   readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
@@ -516,6 +523,8 @@
   readonly settlementSupported: boolean;
   /** False on servers that predate thread.snooze/unsnooze. */
   readonly snoozeSupported: boolean;
+  /** False on servers that predate thread.snooze-until-attention. */
+  readonly snoozeUntilAttentionSupported: boolean;
   /** False on servers that predate thread.pin/unpin. */
   readonly pinningSupported: boolean;
   /** False on servers that predate thread.auto-settle.set. */
@@ -560,6 +569,12 @@
     onMoveThread,
   } = props;
   const snoozedRow = props.snoozed === true;
+  const snoozeAccessibilityValue = snoozedRow
+    ? resolveThreadListV2SnoozeAccessibilityValue({
+        snoozedUntilNeedsAttention: thread.snoozedUntilNeedsAttention === true,
+        snoozeWakeLabelText: props.snoozeWakeLabelText,
+      })
+    : undefined;
   const pinnedRow = props.pinned === true;
   const dormant = useSwipeRowDormant(props.activationKey);
 
@@ -622,7 +637,7 @@
     setCustomSnoozeOpen(false);
   }
   const handleSnooze = useCallback(
+    (snoozedUntil: string | null) => onSnoozeThread(thread, snoozedUntil),
-    (snoozedUntil: string) => onSnoozeThread(thread, snoozedUntil),
     [onSnoozeThread, thread],
   );
   const handleUnsnooze = useCallback(() => onUnsnoozeThread(thread), [onUnsnoozeThread, thread]);
@@ -661,6 +676,9 @@
     () => (swipeActions.secondary === "snooze" ? resolveSnoozePresets(new Date()) : ([] as const)),
     [props.snoozePresetMinute, swipeActions.secondary],
   );
+  const offersUntilAttention = offersSnoozeUntilAttention(thread, {
+    threadSnoozeUntilAttention: props.snoozeUntilAttentionSupported,
+  });
   const snoozePresetActions = useMemo<MenuAction[]>(
     () => [
       ...snoozePresets.map((preset) => ({
@@ -668,9 +686,10 @@
         title: preset.label,
         subtitle: preset.whenLabel,
       })),
+      ...(offersUntilAttention ? [THREAD_LIST_V2_SNOOZE_UNTIL_ATTENTION_ACTION] : []),
       { id: "snooze:custom", title: "Custom…" },
     ],
-    [snoozePresets],
+    [offersUntilAttention, snoozePresets],
   );
   // Pinned cards keep the full lifecycle menu; only the pin item flips to
   // Unpin. (Settling a pinned thread clears the pin server-side; snoozing
@@ -837,6 +856,8 @@
       });
       if (snoozeSelection._tag === "selected") {
         handleSnooze(snoozeSelection.preset.snoozedUntil);
+      } else if (snoozeSelection._tag === "until-attention") {
+        handleSnooze(null);
       } else if (snoozeSelection._tag === "expired") {
         Alert.alert("Could not snooze thread", "That snooze time has passed. Choose another time.");
       }
@@ -1160,6 +1181,9 @@
         }
         accessibilityRole="button"
         accessibilityState={{ selected }}
+        accessibilityValue={
+          snoozeAccessibilityValue === undefined ? undefined : { text: snoozeAccessibilityValue }
+        }
         className={rowAppearance.className}
         onPress={() => {
           close();
@@ -1208,21 +1232,36 @@
             ) : null}
           </View>
           {props.hasQueuedMessages ? <QueuedMessageIcon selected={selected} /> : null}
+          {snoozedRow && thread.snoozedUntilNeedsAttention === true ? (
+            <View accessibilityElementsHidden importantForAccessibility="no-hide-descendants">
+              <SymbolView
+                name="eye"
+                size={14}
+                tintColorClassName={
+                  selected
-          <Text
-            className={cn(
-              "text-sm tabular-nums",
-              selected
-                ? selectedThreadRowColors.mutedForegroundClassName
+                    ? selectedThreadRowColors.mutedIconTintClassName
+                    : rowAppearance.mutedIconTintClassName
+                }
-                : snoozedRow
+                type="monochrome"
-                  ? rowAppearance.mutedForegroundClassName
-                  : rowAppearance.tertiaryForegroundClassName,
-            )}
-            style={{ fontFamily: MONO_FONT }}
-          >
-            {snoozedRow && props.snoozeWakeLabelText !== undefined
-              ? props.snoozeWakeLabelText
-              : timeLabel}
-          </Text>
+              />
+            </View>
+          ) : (
+            <Text
+              className={cn(
+                "text-sm tabular-nums",
+                selected
+                  ? selectedThreadRowColors.mutedForegroundClassName
+                  : snoozedRow
+                    ? rowAppearance.mutedForegroundClassName
+                    : rowAppearance.tertiaryForegroundClassName,
+              )}
+              style={{ fontFamily: MONO_FONT }}
+            >
+              {snoozedRow && props.snoozeWakeLabelText !== undefined
+                ? props.snoozeWakeLabelText
+                : timeLabel}
+            </Text>
+          )}
         </View>
       </RowPressable>
     );
```

## apps/mobile/src/features/threads/threadListV2.test.ts

Status: modified; +89; −0

```diff
@@ -32,6 +32,7 @@
   buildThreadListV2ListItems,
   getThreadListV2OrderedSection,
   isThreadListV2ListItem,
+  resolveThreadListV2SnoozeAccessibilityValue,
   resolveThreadListV2SnoozeMenuSelection,
   resolveThreadListV2SnoozeGateExpiryMs,
   resolveThreadListV2Status,
@@ -110,6 +111,47 @@
   });
 });
 
+describe("snoozing until a pull request needs attention from the menu", () => {
+  it("selects the attention snooze instead of treating it as an expired preset", () => {
+    expect(
+      resolveThreadListV2SnoozeMenuSelection({
+        event: "snooze:needs-attention",
+        displayedPresets: [],
+        now: new Date(NOW),
+      }),
+    ).toEqual({ _tag: "until-attention" });
+  });
+});
+
+describe("resolveThreadListV2SnoozeAccessibilityValue", () => {
+  it("tells screen readers how a snoozed row will wake", () => {
+    expect(
+      resolveThreadListV2SnoozeAccessibilityValue({
+        snoozedUntilNeedsAttention: true,
+        snoozeWakeLabelText: undefined,
+      }),
+    ).toBe("Snoozed until its pull request needs attention");
+    expect(
+      resolveThreadListV2SnoozeAccessibilityValue({
+        snoozedUntilNeedsAttention: false,
+        snoozeWakeLabelText: "2h",
+      }),
+    ).toBe("Snoozed, wakes in 2h");
+    expect(
+      resolveThreadListV2SnoozeAccessibilityValue({
+        snoozedUntilNeedsAttention: false,
+        snoozeWakeLabelText: "now",
+      }),
+    ).toBe("Snoozed, waking now");
+    expect(
+      resolveThreadListV2SnoozeAccessibilityValue({
+        snoozedUntilNeedsAttention: false,
+        snoozeWakeLabelText: undefined,
+      }),
+    ).toBeUndefined();
+  });
+});
+
 describe("resolveThreadListV2Status", () => {
   it("distinguishes usage limits from ordinary failures and clears the label after recovery", () => {
     const thread = makeThread({
@@ -658,6 +700,53 @@
     expect(layout.snoozedCount).toBe(2);
   });
 
+  it("lists threads waiting on a pull request after timed snoozes, without a countdown", () => {
+    const waiting = (id: string, snoozedAt: string) =>
+      makeThread({
+        id: ThreadId.make(id),
+        title: id,
+        snoozedUntil: null,
+        snoozedAt,
+        snoozedUntilNeedsAttention: true,
+      });
+    const layout = buildThreadListV2Items({
+      threads: [
+        waiting("newer-wait", "2026-06-01T13:00:00.000Z"),
+        makeThread({
+          id: ThreadId.make("timed"),
+          title: "Timed",
+          snoozedUntil: "2026-06-03T09:00:00.000Z",
+          snoozedAt: "2026-06-01T12:00:00.000Z",
+        }),
+        waiting("older-wait", "2026-06-01T11:00:00.000Z"),
+      ],
+      environmentId: null,
+      searchQuery: "",
+      now: NOW,
+      snoozedShelfExpanded: true,
+    });
+
+    expect(layout.items.map((item) => item.thread.id)).toEqual([
+      "timed",
+      "older-wait",
+      "newer-wait",
+    ]);
+    expect(layout.nextSnoozeWakeAt).toBe("2026-06-03T09:00:00.000Z");
+    const rows = buildThreadListV2ListItems({
+      items: layout.items,
+      pendingTasks: [],
+      snoozedCount: layout.snoozedCount,
+      snoozedShelfExpanded: true,
+      snoozedShelfHeaderIndex: layout.snoozedShelfHeaderIndex,
+      snoozeLabelNow: NOW,
+    }).flatMap((item) => (item.type === "v2-thread" ? [item] : []));
+    expect(rows.map((row) => [row.snoozeWakeLabelText !== undefined, row.timeLabel])).toEqual([
+      [true, ""],
+      [false, ""],
+      [false, ""],
+    ]);
+  });
+
   it("collapses to a header-only shelf", () => {
     const layout = buildThreadListV2Items({
       threads: [
```

## apps/mobile/src/features/threads/threadListV2.ts

Status: modified; +28; −6

```diff
@@ -22,6 +22,7 @@
   resolveSettledThreadTimestamp,
   sortPinnedThreadsByOrderKey,
   sortSettledThreads,
+  sortSnoozedThreads,
 } from "@t3tools/client-runtime/state/thread-sort";
 import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
 
@@ -86,15 +87,25 @@
   | "ready";
 export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze";
 
+/** Snooze menu entry that waits on the thread's pull requests instead of a wake time. */
+export const THREAD_LIST_V2_SNOOZE_UNTIL_ATTENTION_ACTION = {
+  id: "snooze:needs-attention",
+  title: "Until it needs attention",
+} as const;
+
 export function resolveThreadListV2SnoozeMenuSelection(input: {
   readonly event: string;
   readonly displayedPresets: ReadonlyArray<SnoozePreset>;
   readonly now: Date;
 }):
   | { readonly _tag: "selected"; readonly preset: SnoozePreset }
+  | { readonly _tag: "until-attention" }
   | { readonly _tag: "expired" }
   | { readonly _tag: "not-snooze" } {
   if (!input.event.startsWith("snooze:")) return { _tag: "not-snooze" };
+  if (input.event === THREAD_LIST_V2_SNOOZE_UNTIL_ATTENTION_ACTION.id) {
+    return { _tag: "until-attention" };
+  }
 
   const currentPreset = resolveSnoozePresets(input.now).find(
     (candidate) => input.event === `snooze:${candidate.id}`,
@@ -110,6 +121,18 @@
   return { _tag: "expired" };
 }
 
+/** Spoken snooze state for a snoozed-shelf row, whose eye and countdown are visual only. */
+export function resolveThreadListV2SnoozeAccessibilityValue(input: {
+  readonly snoozedUntilNeedsAttention: boolean;
+  readonly snoozeWakeLabelText: string | undefined;
+}): string | undefined {
+  if (input.snoozedUntilNeedsAttention) return "Snoozed until its pull request needs attention";
+  if (input.snoozeWakeLabelText === undefined) return undefined;
+  return input.snoozeWakeLabelText === "now"
+    ? "Snoozed, waking now"
+    : `Snoozed, wakes in ${input.snoozeWakeLabelText}`;
+}
+
 export function resolveThreadListV2SwipeActions(input: {
   readonly variant: "card" | "slim";
   readonly settlementSupported: boolean;
@@ -453,14 +476,16 @@
 
 /** The timestamp a row renders when it shows no status label: the settle
     stamp on settled slim rows, otherwise the latest activity. Blank for
+    status-labelled cards and snoozed rows with a wake countdown or the
+    attention eye — those never draw a time, so their minute tick must not
+    invalidate the cell. */
-    status-labelled cards and snoozed rows with a wake countdown — those
-    never draw a time, so their minute tick must not invalidate the cell. */
 function resolveThreadListV2ItemTimeLabel(
   item: ThreadListV2Item,
   showSnoozeWakeLabel: boolean,
 ): string {
   const { thread, variant, snoozed } = item;
   if (showSnoozeWakeLabel) return "";
+  if (snoozed && thread.snoozedUntilNeedsAttention === true) return "";
   if (
     variant === "card" &&
     (resolveThreadListV2Status(thread) !== "ready" || threadHasUnseenCompletion(thread))
@@ -731,10 +756,7 @@
     : applyPendingThreadOrder(sortThreadsForListV2(active), "active", pending);
   // Newest send first; finishing and waking again do not move a row.
   const orderedWorking = sortWorkingThreadsBySend(working);
-  const orderedSnoozed = [...snoozed].sort(
-    (left, right) =>
-      parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""),
-  );
+  const orderedSnoozed = sortSnoozedThreads(snoozed);
   const selectedThreadKey = input.selectedThreadKey ?? null;
   const visibleWorking =
     input.workingShelfExpanded === true
```

## apps/mobile/src/state/thread-list-environments.ts

Status: modified; +6; −0

```diff
@@ -15,6 +15,7 @@
 const capabilityKeys = [
   "threadSettlement",
   "threadSnooze",
+  "threadSnoozeUntilAttention",
   "threadPinning",
   "threadAutoSettleOptOut",
   "threadPinReorder",
@@ -64,6 +65,7 @@
   const machineByEnvironmentId = new Map<EnvironmentId, EnvironmentMachineKind>();
   const settlementEnvironmentIds = new Set<EnvironmentId>();
   const snoozeEnvironmentIds = new Set<EnvironmentId>();
+  const snoozeUntilAttentionEnvironmentIds = new Set<EnvironmentId>();
   const pinningEnvironmentIds = new Set<EnvironmentId>();
   const autoSettleOptOutEnvironmentIds = new Set<EnvironmentId>();
   const pinReorderEnvironmentIds = new Set<EnvironmentId>();
@@ -74,6 +76,9 @@
     machineByEnvironmentId.set(id, machineKind);
     if (capabilities.threadSettlement === true) settlementEnvironmentIds.add(id);
     if (capabilities.threadSnooze === true) snoozeEnvironmentIds.add(id);
+    if (capabilities.threadSnoozeUntilAttention === true) {
+      snoozeUntilAttentionEnvironmentIds.add(id);
+    }
     if (capabilities.threadAutoSettleOptOut === true) autoSettleOptOutEnvironmentIds.add(id);
     if (capabilities.threadPinning === true) pinningEnvironmentIds.add(id);
     if (capabilities.threadPinReorder === true) pinReorderEnvironmentIds.add(id);
@@ -85,6 +90,7 @@
     machineByEnvironmentId,
     settlementEnvironmentIds,
     snoozeEnvironmentIds,
+    snoozeUntilAttentionEnvironmentIds,
     pinningEnvironmentIds,
     autoSettleOptOutEnvironmentIds,
     pinReorderEnvironmentIds,
```

## apps/mobile/src/state/use-thread-selection.ts

Status: modified; +1; −0

```diff
@@ -114,6 +114,7 @@
     pinOrderKey: thread.pinOrderKey,
     snoozedUntil: thread.snoozedUntil ?? null,
     snoozedAt: thread.snoozedAt ?? null,
+    snoozedUntilNeedsAttention: thread.snoozedUntilNeedsAttention === true,
     deletedAt: thread.deletedAt,
   });
 }
```

## apps/web/src/components/CommandPalette.logic.ts

Status: modified; +1; −1

```diff
@@ -10,6 +10,7 @@
 import {
   canSnooze,
   effectiveSnoozed,
+  offersSnoozeUntilAttention,
   type ThreadSnoozeShell,
 } from "@t3tools/client-runtime/state/thread-settled";
 import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings";
@@ -19,7 +20,6 @@
 import { getThreadSortTimestamp, sortThreads } from "../lib/threadSort";
 import { normalizeSearchText } from "../lib/utils";
 import { formatRelativeTimeLabel } from "../timestampFormat";
-import { offersSnoozeUntilAttention } from "./Sidebar.snooze";
 import { type Project, type SidebarThreadSummary, type Thread } from "../types";
 
 export const RECENT_THREAD_LIMIT = 12;
```

## apps/web/src/components/Sidebar.logic.test.ts

Status: modified; +0; −22

```diff
@@ -53,7 +53,6 @@
   sortProjectsForSidebar,
   sortScopedProjectsForSidebar,
   sortSidebarV2ProjectGroups,
-  sortSnoozedThreads,
   shouldCreateNewThreadInCurrentProject,
   shouldNavigateAfterThreadPark,
   threadHoldsSnooze,
@@ -2076,12 +2075,6 @@
 });
 
 describe("snoozed until a pull request needs attention", () => {
-  const timed = (id: string, snoozedUntil: string) => ({
-    id,
-    snoozedAt: "2026-09-12T08:00:00.000Z",
-    snoozedUntil,
-    snoozedUntilNeedsAttention: false,
-  });
   const waiting = (id: string, snoozedAt: string) => ({
     id,
     snoozedAt,
@@ -2089,21 +2082,6 @@
     snoozedUntilNeedsAttention: true,
   });
 
-  it("sorts after timed snoozes, oldest snooze first", () => {
-    const sorted = sortSnoozedThreads([
-      waiting("newer-wait", "2026-09-12T09:00:00.000Z"),
-      timed("later", "2026-09-14T09:00:00.000Z"),
-      waiting("older-wait", "2026-09-11T09:00:00.000Z"),
-      timed("sooner", "2026-09-13T09:00:00.000Z"),
-    ]);
-    expect(sorted.map((thread) => thread.id)).toEqual([
-      "sooner",
-      "later",
-      "older-wait",
-      "newer-wait",
-    ]);
-  });
-
   it("is held until the server clears the flag", () => {
     expect(threadHoldsSnooze(waiting("thread", "2026-09-12T09:00:00.000Z"))).toBe(true);
     expect(threadHoldsSnooze({ snoozedUntil: null, snoozedUntilNeedsAttention: false })).toBe(
```

## apps/web/src/components/Sidebar.logic.ts

Status: modified; +0; −18

```diff
@@ -1051,24 +1051,6 @@
   return thread.snoozedUntil != null || thread.snoozedUntilNeedsAttention === true;
 }
 
-/**
- * Snoozed shelf order. Soonest wake first, since "what comes back next" is the shelf's question.
- * Threads waiting on a pull request have no wake time, so they follow, oldest snooze first: a new
- * one joins the end instead of reshuffling the rows above it.
- */
-export function sortSnoozedThreads<
-  T extends Pick<SidebarThreadSummary, "snoozedAt" | "snoozedUntil" | "snoozedUntilNeedsAttention">,
->(threads: ReadonlyArray<T>): T[] {
-  return threads.toSorted((left, right) => {
-    const leftWaits = left.snoozedUntil == null && left.snoozedUntilNeedsAttention === true;
-    const rightWaits = right.snoozedUntil == null && right.snoozedUntilNeedsAttention === true;
-    if (leftWaits !== rightWaits) return leftWaits ? 1 : -1;
-    return leftWaits
-      ? firstValidTimestampMs(left.snoozedAt) - firstValidTimestampMs(right.snoozedAt)
-      : firstValidTimestampMs(left.snoozedUntil) - firstValidTimestampMs(right.snoozedUntil);
-  });
-}
-
 export function shouldShowSidebarV2Duration(status: SidebarThreadStatus): boolean {
   return status === "working";
 }
```

## apps/web/src/components/Sidebar.snooze.test.ts

Status: modified; +1; −40

```diff
@@ -1,10 +1,6 @@
 import { describe, expect, it } from "vite-plus/test";
 
-import {
-  offersSnoozeUntilAttention,
-  resolveSnoozePresets,
-  snoozeWakeDescription,
+import { resolveSnoozePresets, snoozeWakeDescription } from "./Sidebar.snooze";
-} from "./Sidebar.snooze";
 
 // Local-time constructor so preset math is timezone-stable in tests.
 function localDate(year: number, month: number, day: number, hour: number, minute = 0): Date {
@@ -98,38 +94,3 @@
     );
   });
 });
-
-describe("offersSnoozeUntilAttention", () => {
-  const thread = {
-    pullRequests: [
-      {
-        host: "github.com",
-        repository: "acme/app",
-        number: 1,
-        url: "https://github.com/acme/app/pull/1",
-        source: "agent" as const,
-        linkedAt: "2026-10-06T22:00:00.000Z",
-        snapshot: null,
-        stack: null,
-      },
-    ],
-    settledOverride: null,
-    settledAt: null,
-    lineage: { relationshipToParent: null },
-  };
-
-  it("needs a server that advertises it, not just timed snooze", () => {
-    expect(offersSnoozeUntilAttention(thread, { threadSnoozeUntilAttention: true })).toBe(true);
-    expect(offersSnoozeUntilAttention(thread, {})).toBe(false);
-    expect(offersSnoozeUntilAttention(thread, undefined)).toBe(false);
-  });
-
-  it("needs an open linked pull request", () => {
-    expect(
-      offersSnoozeUntilAttention(
-        { ...thread, pullRequests: [] },
-        { threadSnoozeUntilAttention: true },
-      ),
-    ).toBe(false);
-  });
-});
```

## apps/web/src/components/Sidebar.snooze.ts

Status: modified; +0; −9

```diff
@@ -1,6 +1,5 @@
 import type { TimestampFormat } from "@t3tools/contracts/settings";
 import {
-  canSnoozeUntilAttention,
   resolveSnoozePresets as resolveSharedSnoozePresets,
   snoozeWakeLabel,
   type SnoozePreset,
@@ -18,14 +17,6 @@
 /** The choice behind "Until it needs attention". */
 export const SNOOZE_UNTIL_ATTENTION: SnoozeChoice = { snoozedUntil: null };
 
-/** Whether snooze menus offer "Until it needs attention": the server understands it and the thread links an open pull request. */
-export function offersSnoozeUntilAttention(
-  thread: Parameters<typeof canSnoozeUntilAttention>[0],
-  capabilities: { readonly threadSnoozeUntilAttention?: boolean } | undefined,
-): boolean {
-  return capabilities?.threadSnoozeUntilAttention === true && canSnoozeUntilAttention(thread);
-}
-
 const DAY_MS = 24 * 60 * 60 * 1_000;
 
 function timeOfDayLabel(date: Date, timestampFormat: TimestampFormat): string {
```

## apps/web/src/components/Sidebar.tsx

Status: modified; +2; −2

```diff
@@ -30,12 +30,14 @@
 import {
   canSnooze,
   effectiveSnoozed,
+  offersSnoozeUntilAttention,
   threadWokeAt,
 } from "@t3tools/client-runtime/state/thread-settled";
 import { createInboxReturnTracker } from "@t3tools/client-runtime/state/thread-inbox";
 import {
   resolveSettledThreadTimestamp,
   sortSettledThreads,
+  sortSnoozedThreads,
 } from "@t3tools/client-runtime/state/thread-sort";
 import {
   threadSearchMatchKey,
@@ -216,7 +218,6 @@
   sortInboxThreadsByReturn,
   sortPinnedThreadsForSidebar,
   sortSidebarV2ProjectGroups,
-  sortSnoozedThreads,
   sortThreadsForSidebar,
   sortWorkingThreadsBySend,
   threadHoldsSnooze,
@@ -250,7 +251,6 @@
   useLinkedThreadPullRequest,
 } from "./ThreadStatusIndicators";
 import {
-  offersSnoozeUntilAttention,
   resolveSnoozePresets,
   SNOOZE_UNTIL_ATTENTION,
   snoozeWakeLabel,
```

[Next page](https://git.cafe/flambo/t3code/pull/8/diffs?after=ImFwcHMvd2ViL3NyYy9jb21wb25lbnRzL1NpZGViYXIudHN4Ig&expectedVersion=1&format=markdown)
