diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index bf8d32f1daf3..fa391a601328 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,4 +1,9 @@ -import { findRecordedWorktreeSetup, resolveVisibleWorktreeSetup } from "./ChatView.logic"; +import { + composerBackgroundTasks, + findRecordedWorktreeSetup, + resolveVisibleWorktreeSetup, +} from "./ChatView.logic"; +import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { recallCheckoutIsRepo, rememberCheckoutIsRepo, @@ -2180,3 +2185,54 @@ describe("waitForRevertedMessage", () => { vi.useRealTimers(); }); }); + +describe("composerBackgroundTasks", () => { + const tasks = derivePendingBackgroundWork({ + latestRun: { id: RunId.make("run-1"), ordinal: 1, status: "completed" }, + providerThreads: [ + { + id: "provider-thread-1" as never, + pendingBackgroundTasks: [ + { taskId: "agent-1", kind: "subagent", description: "Explore" }, + { taskId: "tail-logs", kind: "monitor", description: "Tail logs" }, + ], + }, + ], + turnItems: [], + pullRequests: [ + { + host: "github.com", + repository: "acme/app", + number: 7, + url: "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/acme/app/pull/7", + source: "agent", + watch: { + startedAt: "2026-10-05T00:00:00.000Z", + headSha: null, + failedChecks: [], + passed: false, + passedChecks: [], + remarksThrough: "2026-10-05T00:00:00.000Z", + remarkIds: [], + conflicting: false, + wakes: 0, + }, + }, + ], + }); + + it("hides only pull request watches while the thread waits on its pull request", () => { + expect(composerBackgroundTasks(tasks, true).map((task) => task.taskId)).toEqual([ + "agent-1", + "tail-logs", + ]); + }); + + it("keeps every task for awake and timed-snoozed threads", () => { + expect(composerBackgroundTasks(tasks, false).map((task) => task.taskId)).toEqual([ + "agent-1", + "tail-logs", + "pull-request-watch:github.com/acme/app#7", + ]); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index f36e54d334fe..3c6db4a6c6a6 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -34,6 +34,10 @@ import { type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { videoMimeType } from "@t3tools/shared/video"; +import { + isPullRequestWatchTask, + type PendingBackgroundWorkTask, +} from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { appendCodexArtifactTemplateUsePrompt, codexArtifactTemplateUsePrompt, @@ -1345,3 +1349,15 @@ export function restorePlanFollowUpComposer(input: { detectTrigger: true, }); } + +/** + * Background work for the composer banner. An attention-snoozed thread's + * status line is its visible state, so its pull request watches stay in the + * thread details card instead of the banner. + */ +export function composerBackgroundTasks( + tasks: ReadonlyArray, + snoozedUntilAttention: boolean, +): ReadonlyArray { + return snoozedUntilAttention ? tasks.filter((task) => !isPullRequestWatchTask(task)) : tasks; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3b08fa7e5597..2de47ec4c203 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8,7 +8,7 @@ import { resolveWorktreeSetupProgress, } from "./ChatView.logic"; import * as DateTime from "effect/DateTime"; -import { restorePlanFollowUpComposer } from "./ChatView.logic"; +import { composerBackgroundTasks, restorePlanFollowUpComposer } from "./ChatView.logic"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; import { prepareQueuedEditAttachments, recoverQueuedMessageEdit } from "./chat/queuedMessageEdit"; import { @@ -7091,6 +7091,8 @@ export default function ChatView(props: ChatViewProps) { activeThreadShell !== null && supportsSnooze && effectiveSnoozed(activeThreadShell, { now: new Date().toISOString() }); + const activeThreadSnoozedUntilAttention = + activeThreadSnoozed && activeThreadShell?.snoozedUntilNeedsAttention === true; const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); useEffect(() => { void snoozeWakeTick; @@ -7341,7 +7343,14 @@ export default function ChatView(props: ChatViewProps) { // the turn; once it settles, the composer stop button is gone, so this // banner is the only visible stop affordance. The interrupt path also // accepts a completed run while its provider still has background work. - const activeBackgroundTasks = !isWorking && activeThread ? pendingBackgroundTasks : []; + const hasActiveThread = activeThread != null; + const activeBackgroundTasks = useMemo( + () => + !isWorking && hasActiveThread + ? composerBackgroundTasks(pendingBackgroundTasks, activeThreadSnoozedUntilAttention) + : [], + [activeThreadSnoozedUntilAttention, hasActiveThread, isWorking, pendingBackgroundTasks], + ); const [stoppingBackgroundWorkKey, setStoppingBackgroundWorkKey] = useState(null); const isStoppingBackgroundWork = stoppingBackgroundWorkKey === `${environmentId}:${activeThreadId}`; @@ -7577,9 +7586,11 @@ export default function ChatView(props: ChatViewProps) { } label={ - activeThreadShell?.snoozedUntil - ? `Snoozed, ${formatRelativeTimeUntilLabel(activeThreadShell.snoozedUntil)}` - : "Snoozed" + activeThreadSnoozedUntilAttention + ? "Snoozed until its pull request needs attention" + : activeThreadShell?.snoozedUntil + ? `Snoozed, ${formatRelativeTimeUntilLabel(activeThreadShell.snoozedUntil)}` + : "Snoozed" } actionLabel={isUnsnoozing ? "Waking..." : "Wake now"} actionDisabled={!canOperateThread || isUnsnoozing} @@ -7611,6 +7622,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadShell?.settledAt, activeThreadShell?.snoozedUntil, activeThreadSnoozed, + activeThreadSnoozedUntilAttention, activeThreadWokeVisible, canOperateThread, handleUnsettleActiveThread, diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 2bd7c118c69f..0ee3daeeadcd 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -14,6 +14,7 @@ import { filterCommandPaletteGroups, findHighlightedCommandPaletteItem, reduceCommandPaletteUiState, + resolveActiveThreadSnoozeAction, type CommandPaletteActionItem, type CommandPaletteGroup, } from "./CommandPalette.logic"; @@ -894,3 +895,64 @@ describe("virtualized command palette rows", () => { expect(findHighlightedCommandPaletteItem(groups, null)).toBeNull(); }); }); + +describe("resolveActiveThreadSnoozeAction", () => { + const NOW = "2026-10-06T22:30:00.000Z"; + const thread = (snooze: { snoozedUntil?: string; snoozedUntilNeedsAttention?: boolean } = {}) => + makeThreadFixture({ + snoozedAt: snooze.snoozedUntil || snooze.snoozedUntilNeedsAttention ? NOW : null, + snoozedUntil: snooze.snoozedUntil ?? null, + snoozedUntilNeedsAttention: snooze.snoozedUntilNeedsAttention ?? false, + pullRequests: [ + { + host: "github.com", + repository: "acme/app", + number: 1, + url: "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/acme/app/pull/1", + source: "agent", + linkedAt: NOW, + snapshot: null, + stack: null, + }, + ], + }); + const currentServer = { threadSnooze: true, threadSnoozeUntilAttention: true }; + const timedSnoozeServer = { threadSnooze: true }; + + it("offers snoozing until it needs attention only on servers that advertise it", () => { + expect( + resolveActiveThreadSnoozeAction({ thread: thread(), capabilities: currentServer, now: NOW }), + ).toEqual({ kind: "snooze-until-attention", disabled: false }); + expect( + resolveActiveThreadSnoozeAction({ + thread: thread(), + capabilities: timedSnoozeServer, + now: NOW, + }), + ).toBeNull(); + }); + + it("offers waking a snoozed thread, timed or until it needs attention", () => { + expect( + resolveActiveThreadSnoozeAction({ + thread: thread({ snoozedUntilNeedsAttention: true }), + capabilities: currentServer, + now: NOW, + }), + ).toEqual({ kind: "wake" }); + expect( + resolveActiveThreadSnoozeAction({ + thread: thread({ snoozedUntil: "2026-10-07T09:00:00.000Z" }), + capabilities: timedSnoozeServer, + now: NOW, + }), + ).toEqual({ kind: "wake" }); + expect( + resolveActiveThreadSnoozeAction({ + thread: thread({ snoozedUntilNeedsAttention: true }), + capabilities: {}, + now: NOW, + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 49e963f68313..d0d86d9b2f6e 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -7,6 +7,11 @@ import { THREAD_JUMP_KEYBINDING_COMMANDS, } from "@t3tools/contracts"; import { filterFilesystemBrowseEntries } from "@t3tools/client-runtime/state/filesystem"; +import { + canSnooze, + effectiveSnoozed, + type ThreadSnoozeShell, +} from "@t3tools/client-runtime/state/thread-settled"; import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; @@ -14,6 +19,7 @@ import { type ReactNode } from "react"; 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; @@ -39,6 +45,24 @@ export function buildLinkedThreadActionItems( })); } +/** The snooze action the palette offers for the current thread: wake it when snoozed, otherwise snooze it until it needs attention. */ +export function resolveActiveThreadSnoozeAction(input: { + readonly thread: ThreadSnoozeShell & Parameters[0]; + readonly capabilities: + | { readonly threadSnooze?: boolean; readonly threadSnoozeUntilAttention?: boolean } + | undefined; + readonly now: string; +}): + | { readonly kind: "wake" } + | { readonly kind: "snooze-until-attention"; readonly disabled: boolean } + | null { + const { thread, capabilities, now } = input; + if (capabilities?.threadSnooze !== true) return null; + if (effectiveSnoozed(thread, { now })) return { kind: "wake" }; + if (!offersSnoozeUntilAttention(thread, capabilities)) return null; + return { kind: "snooze-until-attention", disabled: !canSnooze(thread, { now }) }; +} + export function browseInputEndPaddingClass(input: { readonly willCreateProjectPath: boolean; readonly hasHighlightedBrowseItem: boolean; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 023eafa9397a..3ed077538373 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -25,6 +25,7 @@ import { getFilesystemBrowsePath, } from "@t3tools/client-runtime/state/filesystem"; import { + type AtomCommandResult, isAtomCommandInterrupted, settlePromise, squashAtomCommandFailure, @@ -47,10 +48,12 @@ import { import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; import * as Option from "effect/Option"; import { + AlarmClockOffIcon, ArrowLeftIcon, ChartNoAxesColumnIcon, CheckIcon, ChevronRightIcon, + ClockIcon, CornerLeftUpIcon, FileSearchIcon, FolderGit2Icon, @@ -86,6 +89,7 @@ import { useAtomValue } from "@effect/atom-react"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useThreadActions } from "../hooks/useThreadActions"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { useClientSettings } from "../hooks/useSettings"; @@ -177,6 +181,7 @@ import { ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, reduceCommandPaletteUiState, + resolveActiveThreadSnoozeAction, type SearchOverlayMode, } from "./CommandPalette.logic"; import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; @@ -738,6 +743,7 @@ function OpenCommandPaletteDialog(props: { const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, }); + const { snoozeThread, unsnoozeThread } = useThreadActions(); const { environments } = useEnvironments(); const desktopLocalBootstraps = useDesktopLocalBootstraps(); const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -759,6 +765,10 @@ function OpenCommandPaletteDialog(props: { const activeThreadServerConfig = useServerConfigs().get( activeThread?.environmentId ?? ("" as EnvironmentId), ); + const canOperateActiveThread = useEnvironmentScope( + activeThread?.environmentId ?? null, + AuthOrchestrationOperateScope, + ); const activeThreadReferenceCopyTarget = referenceThreadRef === null || (pathname === "/pull-requests" && !openPanelPullRequestUrl) ? null @@ -1973,6 +1983,55 @@ function OpenCommandPaletteDialog(props: { } } + const activeThreadSnoozeAction = + activeThread === null + ? null + : resolveActiveThreadSnoozeAction({ + thread: activeThread, + capabilities: activeThreadServerConfig?.environment.capabilities, + now: new Date().toISOString(), + }); + if (activeThread !== null && activeThreadSnoozeAction !== null) { + const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); + const reportFailure = (title: string, result: AtomCommandResult) => { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: errorMessage(squashAtomCommandFailure(result)), + }), + ); + } + }; + actionItems.push( + activeThreadSnoozeAction.kind === "wake" + ? { + kind: "action", + value: "action:wake-thread", + searchTerms: ["wake", "unsnooze", "snooze"], + title: "Wake thread", + disabled: !canOperateActiveThread, + icon: , + run: async () => { + reportFailure("Failed to wake thread", await unsnoozeThread(threadRef)); + }, + } + : { + kind: "action", + value: "action:snooze-until-attention", + searchTerms: ["snooze", "pull request", "pr", "review", "attention", "wait"], + title: "Snooze until it needs attention", + description: "Wakes when its pull request needs attention", + disabled: !canOperateActiveThread || activeThreadSnoozeAction.disabled, + icon: , + run: async () => { + reportFailure("Failed to snooze thread", await snoozeThread(threadRef, null)); + }, + }, + ); + } + if (activeThread !== null) { const thread = activeThread; actionItems.push({ diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index bf2a0c0cbd6f..30dc74223c37 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -7,6 +7,7 @@ import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { animateSidebarLayoutChanges, + applySidebarThreadDrop, archiveSelectedThreadEntries, buildBulkTitleRegenerationContextMenuItem, buildBulkUnpinContextMenuItem, @@ -52,8 +53,10 @@ import { sortProjectsForSidebar, sortScopedProjectsForSidebar, sortSidebarV2ProjectGroups, + sortSnoozedThreads, shouldCreateNewThreadInCurrentProject, shouldNavigateAfterThreadPark, + threadHoldsSnooze, THREAD_JUMP_HINT_SHOW_DELAY_MS, type SidebarListItem, type SidebarListMarker, @@ -2072,6 +2075,64 @@ describe("navigation after parking a thread", () => { ); }); +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, + snoozedUntil: null, + 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( + false, + ); + }); + + it.each(["pinned", "active", "settled"] as const)("wakes when dropped into %s", (section) => { + const projected = applySidebarThreadDrop( + { + ...waiting("thread", "2026-09-12T09:00:00.000Z"), + pinnedAt: null, + pinOrderKey: null, + activeOrderKey: null, + settledAt: null, + settledOverride: null, + unsettledAt: null, + }, + section, + "2026-09-12T10:00:00.000Z", + ); + expect(projected).toMatchObject({ + snoozedAt: null, + snoozedUntil: null, + snoozedUntilNeedsAttention: false, + }); + }); +}); + describe("unseen completion with background work", () => { it.each([ { kind: "command", status: "ready", topStatus: "done", receded: false, pill: "Completed" }, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index a8743024832f..53cfc268f34a 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -430,13 +430,19 @@ export function applySidebarThreadDrop< | "activeOrderKey" | "snoozedAt" | "snoozedUntil" + | "snoozedUntilNeedsAttention" | "settledAt" | "settledOverride" | "unsettledAt" >, >(thread: T, section: "pinned" | "active" | "settled", now: string, orderKey?: string): T { const wasSettled = thread.settledOverride === "settled"; - const awake = { ...thread, snoozedAt: null, snoozedUntil: null }; + const awake = { + ...thread, + snoozedAt: null, + snoozedUntil: null, + snoozedUntilNeedsAttention: false, + }; if (section === "settled") { return { ...awake, @@ -1038,6 +1044,31 @@ export function resolveSidebarV2TopStatus(input: { return input.isUnread ? "done" : null; } +/** Whether the server still holds a snooze on the thread, timed or until a pull request needs attention. */ +export function threadHoldsSnooze( + thread: Pick, +): boolean { + 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, +>(threads: ReadonlyArray): 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"; } diff --git a/apps/web/src/components/Sidebar.snooze.test.ts b/apps/web/src/components/Sidebar.snooze.test.ts index 16e17e4217eb..c11c5d9e2379 100644 --- a/apps/web/src/components/Sidebar.snooze.test.ts +++ b/apps/web/src/components/Sidebar.snooze.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveSnoozePresets, snoozeWakeDescription } from "./Sidebar.snooze"; +import { + offersSnoozeUntilAttention, + resolveSnoozePresets, + snoozeWakeDescription, +} 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 { @@ -94,3 +98,38 @@ describe("snoozeWakeDescription", () => { ); }); }); + +describe("offersSnoozeUntilAttention", () => { + const thread = { + pullRequests: [ + { + host: "github.com", + repository: "acme/app", + number: 1, + url: "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/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); + }); +}); diff --git a/apps/web/src/components/Sidebar.snooze.ts b/apps/web/src/components/Sidebar.snooze.ts index e7b980279a4b..b67a6663e7ad 100644 --- a/apps/web/src/components/Sidebar.snooze.ts +++ b/apps/web/src/components/Sidebar.snooze.ts @@ -1,5 +1,6 @@ import type { TimestampFormat } from "@t3tools/contracts/settings"; import { + canSnoozeUntilAttention, resolveSnoozePresets as resolveSharedSnoozePresets, snoozeWakeLabel, type SnoozePreset, @@ -9,6 +10,22 @@ import { formatShortTimestamp, parseTimestampDate } from "../timestampFormat"; export { snoozeWakeLabel, type SnoozePreset }; +/** A snooze menu choice: a wake time, or null to snooze until a pull request needs attention. */ +export interface SnoozeChoice { + readonly snoozedUntil: string | null; +} + +/** 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[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 { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 43d1f8dacffb..61b82ff9b27b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -176,6 +176,8 @@ import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; import { buildDraftActionMenuItems, buildThreadActionMenuItems, + SNOOZE_UNTIL_ATTENTION_LABEL, + SNOOZE_UNTIL_ATTENTION_MENU_ID, threadActionRequiresOperate, } from "./threadActionMenu.logic"; import { @@ -187,7 +189,6 @@ import { deleteSelectedThreadEntries, filterSidebarProjectScopeItems, formatWorkingDurationLabel, - firstValidTimestampMs, hasUnseenCompletion, isSidebarNestedLinkClick, isSidebarThreadWorking, @@ -215,8 +216,10 @@ import { sortInboxThreadsByReturn, sortPinnedThreadsForSidebar, sortSidebarV2ProjectGroups, + sortSnoozedThreads, sortThreadsForSidebar, sortWorkingThreadsBySend, + threadHoldsSnooze, useThreadJumpHintVisibility, useRetainedValue, useSidebarRowSubscriptionLease, @@ -246,7 +249,14 @@ import { type TerminalStatusIndicator, useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; -import { resolveSnoozePresets, snoozeWakeLabel, type SnoozePreset } from "./Sidebar.snooze"; +import { + offersSnoozeUntilAttention, + resolveSnoozePresets, + SNOOZE_UNTIL_ATTENTION, + snoozeWakeLabel, + type SnoozeChoice, +} from "./Sidebar.snooze"; +import { SnoozedUntilAttentionIndicator } from "./SnoozedUntilAttentionIndicator"; import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; @@ -572,10 +582,11 @@ function SidebarThreadTooltip({ function SnoozeMenuButton(props: { open: boolean; onOpenChange: (open: boolean) => void; - onSnooze: (preset: Pick) => void; + onSnooze: (choice: SnoozeChoice) => void; + canSnoozeUntilAttention: boolean; timestampFormat: TimestampFormat; }) { - const { open, onOpenChange, onSnooze, timestampFormat } = props; + const { open, onOpenChange, onSnooze, canSnoozeUntilAttention, timestampFormat } = props; // Presets resolve at open time so "In 1 hour" is relative to the click, // not to when the row mounted. const presets = useMemo( @@ -617,6 +628,19 @@ function SnoozeMenuButton(props: { {preset.whenLabel} ))} + {canSnoozeUntilAttention ? ( + <> + + { + event.stopPropagation(); + onSnooze(SNOOZE_UNTIL_ATTENTION); + }} + > + {SNOOZE_UNTIL_ATTENTION_LABEL} + + + ) : null} { @@ -1110,6 +1134,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { settlementSupported: boolean; // Same contract for thread.snooze/unsnooze. snoozeSupported: boolean; + // Offers "Until it needs attention" (offersSnoozeUntilAttention). + canSnoozeUntilAttention: boolean; // Renders the pin glyph. Pinned cards keep the full settle/snooze quick // actions: settling clears the pin server-side, and snoozing hides the // card until wake with the pin intact underneath. The glyph is also the @@ -1161,7 +1187,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { event: PointerEvent, ) => void; onUnsettle: (threadRef: ScopedThreadRef) => void; - onSnooze: (threadRef: ScopedThreadRef, preset: Pick) => void; + onSnooze: (threadRef: ScopedThreadRef, choice: SnoozeChoice) => void; onUnsnooze: (threadRef: ScopedThreadRef) => void; onUnpin: (threadRef: ScopedThreadRef) => void; onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; @@ -1525,8 +1551,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { [onUnpin, threadRef], ); const handleSnoozePreset = useCallback( - (preset: Pick) => { - onSnooze(threadRef, preset); + (choice: SnoozeChoice) => { + onSnooze(threadRef, choice); }, [onSnooze, threadRef], ); @@ -1862,6 +1888,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {props.snoozeWakeLabelText} + ) : variantAction === "unsnooze" && thread.snoozedUntilNeedsAttention === true ? ( + ) : isWoke ? ( // A wake can land straight in the settled tail (e.g. PR // merged while snoozed); the signal must survive the trip. @@ -2114,6 +2142,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { open={snoozeMenuOpen} onOpenChange={setSnoozeMenuOpen} onSnooze={handleSnoozePreset} + canSnoozeUntilAttention={props.canSnoozeUntilAttention} timestampFormat={props.timestampFormat} /> ) : null} @@ -2795,7 +2824,12 @@ export default function Sidebar() { ).push( optimisticDrop.clearsSnooze ? projected - : { ...projected, snoozedAt: thread.snoozedAt, snoozedUntil: thread.snoozedUntil }, + : { + ...projected, + snoozedAt: thread.snoozedAt, + snoozedUntil: thread.snoozedUntil, + snoozedUntilNeedsAttention: thread.snoozedUntilNeedsAttention === true, + }, ); } else { const section = resolveSidebarThreadSection({ @@ -2843,12 +2877,7 @@ export default function Sidebar() { }), // Newest send first; finishing and waking again do not move a row. workingThreads: sortWorkingThreadsBySend(working), - // Soonest wake first: "what comes back next" is the shelf's question. - snoozedThreads: snoozed.toSorted( - (left, right) => - firstValidTimestampMs(left.snoozedUntil ?? null) - - firstValidTimestampMs(right.snoozedUntil ?? null), - ), + snoozedThreads: sortSnoozedThreads(snoozed), settledThreads: sortSettledThreads(settled), snoozeNow: preciseNow, }; @@ -2910,7 +2939,8 @@ export default function Sidebar() { // Arm a timeout for the earliest upcoming wake so the shelf empties the // moment a snooze expires instead of on the next minute tick. Sorted - // soonest-first, so entry 0 is the boundary. + // soonest-first, so entry 0 is the boundary; a snooze waiting on a pull + // request has no wake time and sorts after every timed one. useEffect(() => { const nextWakeAtMs = snoozedThreads.length > 0 && snoozedThreads[0]?.snoozedUntil != null @@ -3641,14 +3671,14 @@ export default function Sidebar() { if ( canonicalSection === optimisticDrop.section && thread.pinnedAt == null && - (!optimisticDrop.clearsSnooze || thread.snoozedUntil == null) + (!optimisticDrop.clearsSnooze || !threadHoldsSnooze(thread)) ) { setOptimisticDrop(null); } return; } if (canonicalSection !== optimisticDrop.section) return; - if (optimisticDrop.clearsSnooze && thread.snoozedUntil != null) return; + if (optimisticDrop.clearsSnooze && threadHoldsSnooze(thread)) return; const destinationKeys = optimisticDrop.section === "pinned" ? pinnedKeys : activeKeys; const canonicalDestination = destinationKeys.flatMap((key) => { const canonical = canonicalByKey.get(key); @@ -4147,7 +4177,7 @@ export default function Sidebar() { const performSnooze = useCallback( async ( threadRef: ScopedThreadRef, - preset: Pick, + choice: SnoozeChoice, opts: { coSnoozingKeys?: ReadonlySet } = {}, ) => { const threadKey = scopedThreadKey(threadRef); @@ -4159,7 +4189,7 @@ export default function Sidebar() { // Snoozing the open thread moves you forward, same as settle — // both park the thread you're done with for now. const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); - const result = await snoozeThread(threadRef, preset.snoozedUntil); + const result = await snoozeThread(threadRef, choice.snoozedUntil); if (result._tag === "Failure") { // Never navigate away from a thread that did not snooze. return isAtomCommandInterrupted(result) @@ -4189,11 +4219,11 @@ export default function Sidebar() { const attemptSnooze = useCallback( ( threadRef: ScopedThreadRef, - preset: Pick, + choice: SnoozeChoice, opts: { coSnoozingKeys?: ReadonlySet } = {}, ) => { void (async () => { - const outcome = await performSnooze(threadRef, preset, opts); + const outcome = await performSnooze(threadRef, choice, opts); if (outcome.status === "failure") { toastManager.add( stackedThreadToast({ @@ -4240,6 +4270,12 @@ export default function Sidebar() { serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && canSnooze(thread, { now: selectionNow.toISOString() }), ); + const canSnoozeSelectionUntilAttention = selectedThreads.every((thread) => + offersSnoozeUntilAttention( + thread, + serverConfigs.get(thread.environmentId)?.environment.capabilities, + ), + ); const titleRegenerationThreads = selectedThreads.filter( (thread) => serverConfigs.get(thread.environmentId)?.environment.capabilities @@ -4287,6 +4323,16 @@ export default function Sidebar() { label: `${preset.label} (${preset.whenLabel})`, disabled: !canOperateThreads(selectedThreads), })), + ...(canSnoozeSelectionUntilAttention + ? [ + { + id: SNOOZE_UNTIL_ATTENTION_MENU_ID, + label: SNOOZE_UNTIL_ATTENTION_LABEL, + separatorBefore: true, + disabled: !canOperateThreads(selectedThreads), + }, + ] + : []), { id: "snooze:custom", label: "Custom…", @@ -4338,7 +4384,9 @@ export default function Sidebar() { const preset = clicked.value === "snooze:custom" ? await requestCustomSnooze() - : snoozePresets.find((candidate) => `snooze:${candidate.id}` === clicked.value); + : clicked.value === SNOOZE_UNTIL_ATTENTION_MENU_ID + ? SNOOZE_UNTIL_ATTENTION + : snoozePresets.find((candidate) => `snooze:${candidate.id}` === clicked.value); if (preset) { // Post-snooze navigation must skip threads snoozing in this same // batch — they are all leaving the card block together. @@ -4601,6 +4649,10 @@ export default function Sidebar() { autoSettleEnabled: thread.autoSettleDisabledAt == null, isSnoozed, canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), + canSnoozeUntilAttention: offersSnoozeUntilAttention( + thread, + serverConfigs.get(thread.environmentId)?.environment.capabilities, + ), isRegeneratingTitle, isRunning: !threadRuntimeCanArchive(thread.runtime), supports: { @@ -4621,7 +4673,9 @@ export default function Sidebar() { const preset = clicked.value === "snooze:custom" ? await requestCustomSnooze() - : snoozePresets.find((candidate) => `snooze:${candidate.id}` === clicked.value); + : clicked.value === SNOOZE_UNTIL_ATTENTION_MENU_ID + ? SNOOZE_UNTIL_ATTENTION + : snoozePresets.find((candidate) => `snooze:${candidate.id}` === clicked.value); if (preset) attemptSnooze(threadRef, preset); return; } @@ -5258,6 +5312,10 @@ export default function Sidebar() { serverConfigs.get(thread.environmentId)?.environment.capabilities .threadSnooze === true } + canSnoozeUntilAttention={offersSnoozeUntilAttention( + thread, + serverConfigs.get(thread.environmentId)?.environment.capabilities, + )} pinningSupported={ serverConfigs.get(thread.environmentId)?.environment.capabilities .threadPinning === true diff --git a/apps/web/src/components/SnoozedUntilAttentionIndicator.tsx b/apps/web/src/components/SnoozedUntilAttentionIndicator.tsx new file mode 100644 index 000000000000..562162b1874e --- /dev/null +++ b/apps/web/src/components/SnoozedUntilAttentionIndicator.tsx @@ -0,0 +1,14 @@ +import { EyeIcon } from "lucide-react"; + +/** The mark a snoozed row shows in place of its wake countdown when it waits on a pull request. */ +export function SnoozedUntilAttentionIndicator() { + return ( + + + + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.restore.test.tsx b/apps/web/src/components/settings/SettingsPanels.restore.test.tsx index 88c9cb1776e3..21f951af6c32 100644 --- a/apps/web/src/components/settings/SettingsPanels.restore.test.tsx +++ b/apps/web/src/components/settings/SettingsPanels.restore.test.tsx @@ -65,6 +65,7 @@ describe("restoring V2 settings", () => { ["persistComposerContextStrip", "Composer context"], ["autoResumeLimitedThreads", "Auto-resume limited threads"], ["snoozeLimitedThreads", "Snooze limited threads"], + ["snoozePullRequestsAwaitingReview", "Snooze pull requests awaiting review"], ] as const)("restores %s when it is the only changed setting", async (key, label) => { state.settings = { ...DEFAULT_UNIFIED_SETTINGS, [key]: true }; hooks.beginRender(); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 5e52b4827891..623ebe9c198c 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -588,6 +588,10 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.snoozeLimitedThreads !== DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads ? ["Snooze limited threads"] : []), + ...(settings.snoozePullRequestsAwaitingReview !== + DEFAULT_UNIFIED_SETTINGS.snoozePullRequestsAwaitingReview + ? ["Snooze pull requests awaiting review"] + : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), ...(settings.persistComposerContextStrip !== DEFAULT_UNIFIED_SETTINGS.persistComposerContextStrip @@ -708,6 +712,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.sidebarAutoSettleOnMerge, settings.autoResumeLimitedThreads, settings.snoozeLimitedThreads, + settings.snoozePullRequestsAwaitingReview, settings.sidebarProjectGroupingMode, settings.sidebarProjectSortOrder, settings.sidebarWorkingShelfEnabled, @@ -815,6 +820,7 @@ export function useSettingsRestore(onRestored?: () => void) { sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, autoResumeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.autoResumeLimitedThreads, snoozeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads, + snoozePullRequestsAwaitingReview: DEFAULT_UNIFIED_SETTINGS.snoozePullRequestsAwaitingReview, responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate, @@ -2204,6 +2210,11 @@ export function GeneralSettingsPanel() { connectedEnvironments.every( (target) => target.serverConfig?.environment.capabilities.threadRestartContinuation === true, ); + const supportsSnoozeUntilAttention = + connectedEnvironments.length > 0 && + connectedEnvironments.every( + (target) => target.serverConfig?.environment.capabilities.threadSnoozeUntilAttention === true, + ); const textGenerationProviders = serverProviders.filter( (provider) => provider.supportsTextGeneration !== false, @@ -2369,6 +2380,28 @@ export function GeneralSettingsPanel() { /> } /> + + updateSettings({ snoozePullRequestsAwaitingReview: Boolean(checked) }) + } + aria-label="Snooze pull requests awaiting review" + /> + } + /> { ).not.toContain("auto-settle"); }); + it("offers snoozing until it needs attention only for threads with an open pull request", () => { + const children = (state: ThreadActionMenuState) => + buildThreadActionMenuItems(state) + .find((item) => item.id === "snooze") + ?.children?.map((child) => child.id); + expect(children(baseState)).toEqual(["snooze:hour", "snooze:custom"]); + expect(children({ ...baseState, canSnoozeUntilAttention: true })).toEqual([ + "snooze:hour", + "snooze:needs-attention", + "snooze:custom", + ]); + }); + it("disables snooze when the thread cannot snooze, keeping presets visible", () => { const snooze = buildThreadActionMenuItems({ ...baseState, canSnoozeNow: false }).find( (item) => item.id === "snooze", diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index f51328266cab..c74519a90796 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -30,6 +30,10 @@ export type ThreadActionMenuId = | "archive" | "delete"; +/** Snooze menu entry that waits on the thread's pull requests instead of a wake time. */ +export const SNOOZE_UNTIL_ATTENTION_MENU_ID = "snooze:needs-attention" as const; +export const SNOOZE_UNTIL_ATTENTION_LABEL = "Until it needs attention"; + export type DraftActionMenuId = | "copy" | "copy-path" @@ -87,6 +91,8 @@ export interface ThreadActionMenuState { readonly autoSettleEnabled: boolean; readonly isSnoozed: boolean; readonly canSnoozeNow: boolean; + /** The thread links an open pull request, so it can snooze until that needs attention. */ + readonly canSnoozeUntilAttention: boolean; readonly isRegeneratingTitle: boolean; /** Archive rejects a thread with an attached provider, so disable it here rather than let the action fail. */ readonly isRunning: boolean; @@ -163,6 +169,15 @@ export function buildThreadActionMenuItems( id: `snooze:${preset.id}` as const, label: `${preset.label} (${preset.whenLabel})`, })), + ...(state.canSnoozeUntilAttention + ? [ + { + id: SNOOZE_UNTIL_ATTENTION_MENU_ID, + label: SNOOZE_UNTIL_ATTENTION_LABEL, + separatorBefore: true, + }, + ] + : []), { id: "snooze:custom" as const, label: "Custom…", separatorBefore: true }, ], }, diff --git a/apps/web/src/hooks/useThreadActionMenu.test.ts b/apps/web/src/hooks/useThreadActionMenu.test.ts index 4b8054c986fa..0afa77b7ac8a 100644 --- a/apps/web/src/hooks/useThreadActionMenu.test.ts +++ b/apps/web/src/hooks/useThreadActionMenu.test.ts @@ -51,6 +51,7 @@ vi.mock("../state/entities", () => ({ readEnvironmentSupportsPinning: () => true, readEnvironmentSupportsSettlement: () => true, readEnvironmentSupportsSnooze: () => true, + readEnvironmentSupportsSnoozeUntilAttention: () => true, readEnvironmentSupportsTitleRegeneration: () => true, readThreadShell: () => ({ id: "thread", diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 2fda09240b6b..fd31114b69c2 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -15,9 +15,14 @@ import { import { useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; -import { resolveSnoozePresets } from "../components/Sidebar.snooze"; +import { + offersSnoozeUntilAttention, + resolveSnoozePresets, + SNOOZE_UNTIL_ATTENTION, +} from "../components/Sidebar.snooze"; import { buildThreadActionMenuItems, + SNOOZE_UNTIL_ATTENTION_MENU_ID, threadActionRequiresOperate, type ThreadActionMenuId, } from "../components/threadActionMenu.logic"; @@ -30,6 +35,7 @@ import { readEnvironmentSupportsPinning, readEnvironmentSupportsSettlement, readEnvironmentSupportsSnooze, + readEnvironmentSupportsSnoozeUntilAttention, readEnvironmentSupportsTitleRegeneration, readThreadShell, useProjects, @@ -156,6 +162,11 @@ export function useThreadActionMenu(input: { autoSettleEnabled: thread.autoSettleDisabledAt == null, isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), canSnoozeNow: canSnooze(thread, { now: now.toISOString() }), + canSnoozeUntilAttention: offersSnoozeUntilAttention(thread, { + threadSnoozeUntilAttention: readEnvironmentSupportsSnoozeUntilAttention( + threadRef.environmentId, + ), + }), isRegeneratingTitle, isRunning: !threadRuntimeCanArchive(thread.runtime), supports, @@ -178,7 +189,9 @@ export function useThreadActionMenu(input: { const preset = action === "snooze:custom" ? await requestCustomSnooze() - : snoozePresets.find((candidate) => `snooze:${candidate.id}` === action); + : action === SNOOZE_UNTIL_ATTENTION_MENU_ID + ? SNOOZE_UNTIL_ATTENTION + : snoozePresets.find((candidate) => `snooze:${candidate.id}` === action); if (!preset) return; const result = await snoozeThread(threadRef, preset.snoozedUntil); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { diff --git a/apps/web/src/hooks/useThreadActions.permissions.test.ts b/apps/web/src/hooks/useThreadActions.permissions.test.ts index 3170af2c1ead..149046442e80 100644 --- a/apps/web/src/hooks/useThreadActions.permissions.test.ts +++ b/apps/web/src/hooks/useThreadActions.permissions.test.ts @@ -87,6 +87,7 @@ vi.mock("../state/threads", () => ({ "unpin", "reorderPin", "snooze", + "snoozeUntilAttention", "unsnooze", "stopSession", ].map((action) => [action, action]), @@ -100,6 +101,7 @@ vi.mock("../state/entities", () => ({ readEnvironmentSupportsPinReorder: () => true, readEnvironmentSupportsSettlement: () => true, readEnvironmentSupportsSnooze: () => true, + readEnvironmentSupportsSnoozeUntilAttention: () => true, readThreadShell: (ref: ScopedThreadRef) => state.threads.find( (thread) => thread.environmentId === ref.environmentId && thread.id === ref.threadId, @@ -193,6 +195,10 @@ const operations = [ run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.snoozeThread(ref, "2099-01-01T00:00:00Z"), }, + { + name: "snoozeUntilAttention", + run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.snoozeThread(ref, null), + }, { name: "unsnooze", run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.unsnoozeThread(ref), diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index cab4dde3dd20..515db887689e 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -43,6 +43,7 @@ import { readEnvironmentSupportsActiveReorder, readEnvironmentSupportsSettlement, readEnvironmentSupportsSnooze, + readEnvironmentSupportsSnoozeUntilAttention, readEnvironmentSupportsVisitedTracking, readEnvironmentThreadRefs, readProject, @@ -97,6 +98,18 @@ export class ThreadSnoozeUnsupportedError extends Schema.TaggedError()( + "ThreadSnoozeUntilAttentionUnsupportedError", + { + environmentId: EnvironmentId, + threadId: ThreadId, + }, +) { + override get message(): string { + return "This environment's server does not support snoozing until a pull request needs attention yet. Update the server to use it."; + } +} + export class ThreadSnoozeBlockedError extends Schema.TaggedError()( "ThreadSnoozeBlockedError", { @@ -304,6 +317,10 @@ export function useThreadActions() { const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, { reportFailure: false, }); + const snoozeThreadUntilAttentionMutation = useOrchestrationCommand( + threadEnvironment.snoozeUntilAttention, + { reportFailure: false }, + ); const markThreadUnread = useMarkThreadUnread(); const stopThreadSession = useOrchestrationCommand(threadEnvironment.stopSession); const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { @@ -787,6 +804,7 @@ export function useThreadActions() { const pinOrderKey = resolved?.thread.pinnedAt != null ? resolved.thread.pinOrderKey : null; const wasPinned = resolved?.thread.pinnedAt != null; const snoozedUntil = resolved?.thread.snoozedUntil ?? null; + const snoozedUntilNeedsAttention = resolved?.thread.snoozedUntilNeedsAttention === true; // An older unpin/snooze Undo would re-pin or re-snooze, and the server // treats either as a promotion that un-settles; settling supersedes them. ThreadUndo.invalidate("pin", scopedThreadKey(target)); @@ -822,6 +840,12 @@ export function useThreadActions() { input: { threadId: target.threadId, snoozedUntil }, }); } + if (snoozedUntilNeedsAttention) { + return snoozeThreadUntilAttentionMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }); + } return unsettled; }, failureTitle: "Failed to undo settle", @@ -834,6 +858,7 @@ export function useThreadActions() { resolveThreadTarget, settleThreadMutation, snoozeThreadMutation, + snoozeThreadUntilAttentionMutation, unsettleThread, ], ); @@ -925,8 +950,9 @@ export function useThreadActions() { [unsnoozeThreadMutation], ); + /** Snoozes until `snoozedUntil`, or until a linked pull request needs attention when null. */ const snoozeThread = useCallback( - async (target: ScopedThreadRef, snoozedUntil: string) => { + async (target: ScopedThreadRef, snoozedUntil: string | null) => { // Version skew: never send the command to a server that predates it. if (!readEnvironmentSupportsSnooze(target.environmentId)) { return AsyncResult.failure( @@ -938,6 +964,19 @@ export function useThreadActions() { ), ); } + if ( + snoozedUntil === null && + !readEnvironmentSupportsSnoozeUntilAttention(target.environmentId) + ) { + return AsyncResult.failure( + Cause.fail( + new ThreadSnoozeUntilAttentionUnsupportedError({ + environmentId: target.environmentId, + threadId: target.threadId, + }), + ), + ); + } const resolved = resolveThreadTarget(target); // Blocked-on-you work and queued turns can't be snoozed away — // client-side twin of the server invariants so the UI rejects before @@ -953,10 +992,16 @@ export function useThreadActions() { ); } const action = ThreadUndo.begin("snooze", scopedThreadKey(target)); - const result = await snoozeThreadMutation({ - environmentId: target.environmentId, - input: { threadId: target.threadId, snoozedUntil }, - }); + const result = + snoozedUntil === null + ? await snoozeThreadUntilAttentionMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }) + : await snoozeThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId, snoozedUntil }, + }); if (result._tag !== "Success") { action.finish(); return result; @@ -970,7 +1015,7 @@ export function useThreadActions() { }); return result; }, - [resolveThreadTarget, snoozeThreadMutation, unsnoozeThread], + [resolveThreadTarget, snoozeThreadMutation, snoozeThreadUntilAttentionMutation, unsnoozeThread], ); const confirmAndDeleteThread = useCallback( diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index aa9b650fb5b5..ecac4887746a 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -278,6 +278,14 @@ export function readEnvironmentSupportsSnooze(environmentId: EnvironmentId): boo ); } +/** Whether the environment's server understands thread.snooze-until-attention. */ +export function readEnvironmentSupportsSnoozeUntilAttention(environmentId: EnvironmentId): boolean { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadSnoozeUntilAttention === true + ); +} + /** Whether the environment's server understands thread.visit/mark-unread and projects lastVisitedAt on thread shells. Same version-skew contract as settlement: against older servers, clients keep the browser-local visited diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index f08bfcef572a..27d1f2e5252c 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -1,4 +1,6 @@ // @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels. +import type { OrchestrationV2AppThreadLineage, ThreadPullRequestLink } from "@t3tools/contracts"; +import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import * as DateTime from "effect/DateTime"; interface SettlementRunLike { @@ -157,6 +159,25 @@ export function canSnooze( return true; } +/** + * Whether "Snooze until it needs attention" applies: the thread is not settled, is not a subagent + * (subagents cannot watch pull requests), and links an open pull request to watch. A link not + * synced yet counts as open. Client-side twin of the server's checks; the server still reads each + * pull request and can refuse. + */ +export function canSnoozeUntilAttention(shell: { + readonly pullRequests: ReadonlyArray; + readonly settledOverride: "settled" | "active" | null; + readonly settledAt: string | null; + readonly lineage: Pick; +}): boolean { + if (shell.settledOverride === "settled" || shell.settledAt !== null) return false; + if (shell.lineage.relationshipToParent === "subagent") return false; + return visibleThreadPullRequests(shell.pullRequests).some( + (link) => link.snapshot === null || link.snapshot.state === "open", + ); +} + /** * Snoozed resolution: hidden from the inbox while the wake time is in the * future, or until a pull request needs attention, and the thread has not diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index d45b4f94edad..cee73432c7ee 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -1,10 +1,11 @@ // @effect-diagnostics globalDate:off -- Tests exercise local calendar snooze boundaries. -import { ThreadId } from "@t3tools/contracts"; +import { ThreadId, type ThreadPullRequestLink } from "@t3tools/contracts"; import { TurnId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { canSnooze, + canSnoozeUntilAttention, effectiveSnoozed, hasQueuedTurnStart, resolveSnoozePresets, @@ -182,6 +183,61 @@ describe("snoozed until a pull request needs attention", () => { }); }); +describe("canSnoozeUntilAttention", () => { + const link = ( + state: "open" | "closed" | "merged" | null, + source: ThreadPullRequestLink["source"] = "agent", + ): ThreadPullRequestLink => ({ + host: "github.com", + repository: "acme/app", + number: 1, + url: "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/acme/app/pull/1", + source, + linkedAt: "2026-04-10T09:00:00.000Z", + snapshot: + state === null + ? null + : { + state, + title: "Fix", + headBranch: "fix", + baseBranch: "main", + isDraft: false, + updatedAt: null, + syncedAt: "2026-04-10T09:00:00.000Z", + }, + stack: null, + }); + const thread = ( + pullRequests: ReadonlyArray, + settledOverride: "settled" | "active" | null = null, + relationshipToParent: "fork" | "subagent" | null = null, + ) => ({ + pullRequests, + settledOverride, + settledAt: settledOverride === "settled" ? NOW : null, + lineage: { relationshipToParent }, + }); + + it("needs an open or not yet synced linked pull request", () => { + expect(canSnoozeUntilAttention(thread([link("open")]))).toBe(true); + expect(canSnoozeUntilAttention(thread([link(null)]))).toBe(true); + expect(canSnoozeUntilAttention(thread([link("merged"), link("open")]))).toBe(true); + expect(canSnoozeUntilAttention(thread([]))).toBe(false); + expect(canSnoozeUntilAttention(thread([link("merged"), link("closed")]))).toBe(false); + expect(canSnoozeUntilAttention(thread([link("open", "stack-dismissed")]))).toBe(false); + }); + + it("is unavailable on a settled thread", () => { + expect(canSnoozeUntilAttention(thread([link("open")], "settled"))).toBe(false); + }); + + it("is unavailable on a subagent, which cannot watch pull requests", () => { + expect(canSnoozeUntilAttention(thread([link("open")], null, "subagent"))).toBe(false); + expect(canSnoozeUntilAttention(thread([link("open")], null, "fork"))).toBe(true); + }); +}); + describe("threadRaisedHandWhileSnoozed", () => { it("is false for a quiet snoozed thread", () => { expect(threadRaisedHandWhileSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE }))).toBe(false); diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts index 5a17bad321ed..d5323bf92514 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts @@ -286,6 +286,13 @@ export function derivePendingBackgroundWork(input: { return Array.from(byTaskId.values()); } +const PULL_REQUEST_WATCH_TASK_ID_PREFIX = "pull-request-watch:"; + +/** Whether a pending task is a thread pull request watch rather than provider work. */ +export function isPullRequestWatchTask(task: Pick): boolean { + return task.taskId.startsWith(PULL_REQUEST_WATCH_TASK_ID_PREFIX); +} + function pullRequestWatchTasks( pullRequests: ReadonlyArray | undefined, ): Array { @@ -294,7 +301,7 @@ function pullRequestWatchTasks( ? [] : [ { - taskId: `pull-request-watch:${threadPullRequestKeyOf(link)}`, + taskId: `${PULL_REQUEST_WATCH_TASK_ID_PREFIX}${threadPullRequestKeyOf(link)}`, description: `Watching pull request #${link.number}`, kind: "monitor" as const, },