From 9217f220289503ae2be0436631dc538f83d92594 Mon Sep 17 00:00:00 2001 From: Alex Ozerov Date: Wed, 7 Oct 2026 13:43:06 +0300 Subject: [PATCH 1/4] feat: mute a thread's notifications on every device Users who run noisy threads, such as a scheduled review bot, had no way to silence one thread without turning notifications off everywhere. Mute is a server-side mark on the thread (mutedAt, set by thread.mute.set) so it applies on every device. Web and desktop skip system notifications, sounds and toasts for muted threads, and the relay publisher withdraws a muted thread like an archived one so mobile gets no push or Live Activity. Muting is not activity, so it keeps updatedAt. The toggle is in the sidebar and chat header menus, the mobile thread list menu, and t3_thread_organize (mute/unmute). Clients gate it on the new threadMute capability. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/features/home/HomeRouteScreen.tsx | 2 + apps/mobile/src/features/home/HomeScreen.tsx | 12 +++++ .../src/features/home/useThreadListActions.ts | 24 +++++++++ .../threads/ThreadNavigationSidebar.tsx | 6 +++ .../features/threads/thread-list-v2-items.tsx | 54 +++++++++++++------ .../src/state/thread-list-environments.ts | 4 ++ apps/mobile/src/state/use-thread-selection.ts | 1 + .../src/environment/ServerEnvironment.ts | 1 + .../src/mcp/toolkits/thread/handlers.ts | 4 ++ apps/server/src/mcp/toolkits/thread/tools.ts | 4 +- .../Orchestrator.control-reads.test.ts | 42 +++++++++++++++ .../src/orchestration-v2/Orchestrator.ts | 11 ++++ .../orchestration-v2/ProjectionMaintenance.ts | 1 + .../src/orchestration-v2/ProjectionStore.ts | 9 +++- .../testkit/OrchestratorScenario.ts | 1 + .../src/relay/AgentAwarenessRelay.test.ts | 20 +++++++ apps/server/src/relay/AgentAwarenessRelay.ts | 8 ++- apps/web/src/components/Sidebar.tsx | 21 ++++++++ .../ThreadNotificationCoordinator.tsx | 3 +- .../components/threadActionMenu.logic.test.ts | 15 ++++++ .../src/components/threadActionMenu.logic.ts | 12 +++++ apps/web/src/contextMenuFallback.ts | 18 +++++++ apps/web/src/hooks/useThreadActionMenu.ts | 11 ++++ apps/web/src/hooks/useThreadActions.ts | 14 +++++ apps/web/src/state/entities.ts | 8 +++ docs/user/thread-sidebar.md | 8 +++ .../client-runtime/src/operations/commands.ts | 14 +++++ packages/client-runtime/src/state/models.ts | 2 + .../src/state/orchestrationV2Projection.ts | 3 +- .../src/state/threadCommands.test.ts | 2 + .../src/state/threadCommands.ts | 13 +++++ packages/contracts/src/environment.ts | 4 ++ packages/contracts/src/orchestrationV2.ts | 13 +++++ 33 files changed, 343 insertions(+), 22 deletions(-) diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 7a660ef1aadf..925610464cb1 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -66,6 +66,7 @@ export function HomeRouteScreen() { pinThread, unpinThread, setThreadAutoSettle, + setThreadMuted, moveThread, renameThread, regenerateThreadTitle, @@ -224,6 +225,7 @@ export function HomeRouteScreen() { onPinThread={pinThread} onUnpinThread={unpinThread} onSetThreadAutoSettle={setThreadAutoSettle} + onSetThreadMuted={setThreadMuted} onMoveThread={moveThread} onRenameThread={renameThread} onRegenerateThreadTitle={regenerateThreadTitle} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 6538697da297..2fde0d2b9280 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -107,6 +107,7 @@ interface HomeScreenProps { thread: EnvironmentThreadShell, enabled: boolean, ) => Promise; + readonly onSetThreadMuted: (thread: EnvironmentThreadShell, muted: boolean) => Promise; readonly onMoveThread: ( thread: EnvironmentThreadShell, direction: ThreadMoveDestination, @@ -437,6 +438,12 @@ export function HomeScreen(props: HomeScreenProps) { }, [props.onSetThreadAutoSettle], ); + const handleSetThreadMuted = useCallback( + (thread: EnvironmentThreadShell, muted: boolean) => { + void props.onSetThreadMuted(thread, muted); + }, + [props.onSetThreadMuted], + ); const handleRegenerateThreadTitle = useCallback( (thread: EnvironmentThreadShell) => { void props.onRegenerateThreadTitle(thread); @@ -498,6 +505,7 @@ export function HomeScreen(props: HomeScreenProps) { snoozeEnvironmentIds, pinningEnvironmentIds, autoSettleOptOutEnvironmentIds, + muteEnvironmentIds, pinReorderEnvironmentIds, activeReorderEnvironmentIds, titleRegenerationEnvironmentIds, @@ -757,6 +765,7 @@ export function HomeScreen(props: HomeScreenProps) { snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)} + muteSupported={muteEnvironmentIds.has(thread.environmentId)} reorderSupported={ item.item.pinned ? pinReorderEnvironmentIds.has(thread.environmentId) @@ -770,6 +779,7 @@ export function HomeScreen(props: HomeScreenProps) { onPinThread={handlePinThread} onUnpinThread={handleUnpinThread} onSetThreadAutoSettle={handleSetThreadAutoSettle} + onSetThreadMuted={handleSetThreadMuted} onMoveThread={handleMoveThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} @@ -792,7 +802,9 @@ export function HomeScreen(props: HomeScreenProps) { handleSwipeableWillOpen, handleUnsettleThread, handleSetThreadAutoSettle, + handleSetThreadMuted, autoSettleOptOutEnvironmentIds, + muteEnvironmentIds, pinningEnvironmentIds, autoSettleOptOutEnvironmentIds, machineByEnvironmentId, diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 964c12a7189f..0bb23769e4fa 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -258,6 +258,7 @@ export function useThreadListActions(): { thread: EnvironmentThreadShell, enabled: boolean, ) => Promise; + readonly setThreadMuted: (thread: EnvironmentThreadShell, muted: boolean) => Promise; readonly moveThread: ( thread: EnvironmentThreadShell, direction: ThreadMoveDestination, @@ -273,6 +274,7 @@ export function useThreadListActions(): { const setAutoSettleMutation = useAtomCommand(threadEnvironment.setAutoSettle, { reportFailure: false, }); + const setMutedMutation = useAtomCommand(threadEnvironment.setMuted, { reportFailure: false }); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); @@ -491,6 +493,27 @@ export function useThreadListActions(): { }, [setAutoSettleMutation], ); + const setThreadMuted = useCallback( + async (thread: EnvironmentThreadShell, muted: boolean) => { + selectionHaptic(); + const result = await setMutedMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, muted }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not update notifications", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The notification setting could not be changed.", + ); + return false; + } + return true; + }, + [setMutedMutation], + ); const regenerateThreadTitle = useCallback( async (thread: EnvironmentThreadShell) => { if (!checkThreadOperationPermission(thread, "Could not regenerate title")) return false; @@ -763,6 +786,7 @@ export function useThreadListActions(): { pinThread, unpinThread, setThreadAutoSettle, + setThreadMuted, moveThread, renameThread, regenerateThreadTitle, diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 27d23b0a0b15..3ec710a71952 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -154,6 +154,7 @@ function ThreadNavigationSidebarPane( pinThread, unpinThread, setThreadAutoSettle, + setThreadMuted, moveThread, renameThread, regenerateThreadTitle, @@ -321,6 +322,7 @@ function ThreadNavigationSidebarPane( snoozeEnvironmentIds, pinningEnvironmentIds, autoSettleOptOutEnvironmentIds, + muteEnvironmentIds, pinReorderEnvironmentIds, activeReorderEnvironmentIds, titleRegenerationEnvironmentIds, @@ -732,6 +734,7 @@ function ThreadNavigationSidebarPane( snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)} + muteSupported={muteEnvironmentIds.has(thread.environmentId)} reorderSupported={ item.item.pinned ? pinReorderEnvironmentIds.has(thread.environmentId) @@ -745,6 +748,7 @@ function ThreadNavigationSidebarPane( onPinThread={pinThread} onUnpinThread={unpinThread} onSetThreadAutoSettle={setThreadAutoSettle} + onSetThreadMuted={setThreadMuted} onMoveThread={moveThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} @@ -809,6 +813,8 @@ function ThreadNavigationSidebarPane( autoSettleOptOutEnvironmentIds, autoSettleOptOutEnvironmentIds, setThreadAutoSettle, + muteEnvironmentIds, + setThreadMuted, projectByKey, projectTitleByProjectKey, regenerateThreadTitle, diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 4bc4328a5784..6ca40b1379a1 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -511,6 +511,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly onPinThread: (thread: EnvironmentThreadShell) => void; readonly onUnpinThread: (thread: EnvironmentThreadShell) => void; readonly onSetThreadAutoSettle: (thread: EnvironmentThreadShell, enabled: boolean) => void; + readonly onSetThreadMuted: (thread: EnvironmentThreadShell, muted: boolean) => void; /** False on environments whose server predates thread.settle/unsettle: swipe + menu fall back to Archive instead of failing on use. */ readonly settlementSupported: boolean; @@ -520,6 +521,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly pinningSupported: boolean; /** False on servers that predate thread.auto-settle.set. */ readonly autoSettleOptOutSupported: boolean; + /** False on servers that predate thread.mute.set. */ + readonly muteSupported: boolean; /** False on servers that predate thread title regeneration. */ readonly titleRegenerationSupported: boolean; /** Server supports reordering this card's section. */ @@ -557,6 +560,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onPinThread, onUnpinThread, onSetThreadAutoSettle, + onSetThreadMuted, onMoveThread, } = props; const snoozedRow = props.snoozed === true; @@ -633,6 +637,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { (enabled: boolean) => onSetThreadAutoSettle(thread, enabled), [onSetThreadAutoSettle, thread], ); + const handleSetMuted = useCallback( + (muted: boolean) => onSetThreadMuted(thread, muted), + [onSetThreadMuted, thread], + ); const handleMoveUp = useCallback(() => onMoveThread?.(thread, "up"), [onMoveThread, thread]); const handleMoveDown = useCallback(() => onMoveThread?.(thread, "down"), [onMoveThread, thread]); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); @@ -711,11 +719,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { variant, ], ); - // A submenu with the current option checked, matching web. This is a - // per-thread setting, not a lifecycle verb. - const autoSettleMenuItems = useMemo( - () => - props.autoSettleOptOutSupported + // Per-thread settings, not lifecycle verbs. Auto-settle is a submenu with + // the current option checked, matching web. + const threadSettingMenuItems = useMemo( + () => [ + ...(props.autoSettleOptOutSupported ? [ { id: "auto-settle", @@ -735,8 +743,21 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ], } satisfies MenuAction, ] - : [], - [props.autoSettleOptOutSupported, thread.autoSettleDisabledAt], + : []), + ...(props.muteSupported + ? [ + thread.mutedAt != null + ? { id: "unmute", title: "Unmute notifications", image: "bell" } + : { id: "mute", title: "Mute notifications", image: "bell.slash" }, + ] + : []), + ], + [ + props.autoSettleOptOutSupported, + props.muteSupported, + thread.autoSettleDisabledAt, + thread.mutedAt, + ], ); const titleMenuItems = useMemo( () => [ @@ -759,20 +780,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { }, ...arrangementMenuItems, ...titleMenuItems, - ...autoSettleMenuItems, + ...threadSettingMenuItems, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ], - [arrangementMenuItems, autoSettleMenuItems, snoozePresetActions, titleMenuItems], + [arrangementMenuItems, threadSettingMenuItems, snoozePresetActions, titleMenuItems], ); const cardMenuActions = useMemo( () => [ CARD_MENU_ACTIONS[0]!, ...arrangementMenuItems, ...titleMenuItems, - ...autoSettleMenuItems, + ...threadSettingMenuItems, ...CARD_MENU_ACTIONS.slice(1), ], - [arrangementMenuItems, autoSettleMenuItems, titleMenuItems], + [arrangementMenuItems, threadSettingMenuItems, titleMenuItems], ); // Settled and snoozed rows keep the setting too, matching web where every // row shares one menu builder. @@ -783,19 +804,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { (action) => action.id !== "move-up" && action.id !== "move-down", ), ...titleMenuItems, - ...autoSettleMenuItems, + ...threadSettingMenuItems, SLIM_MENU_ACTIONS[1]!, ], - [arrangementMenuItems, autoSettleMenuItems, titleMenuItems], + [arrangementMenuItems, threadSettingMenuItems, titleMenuItems], ); const snoozedMenuActions = useMemo( () => [ SNOOZED_MENU_ACTIONS[0]!, ...titleMenuItems, - ...autoSettleMenuItems, + ...threadSettingMenuItems, SNOOZED_MENU_ACTIONS[1]!, ], - [autoSettleMenuItems, titleMenuItems], + [threadSettingMenuItems, titleMenuItems], ); const legacyMenuActions = useMemo( () => [ @@ -816,6 +837,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { if (nativeEvent.event === "unpin") handleUnpin(); if (nativeEvent.event === "auto-settle:enabled") handleSetAutoSettle(true); if (nativeEvent.event === "auto-settle:disabled") handleSetAutoSettle(false); + if (nativeEvent.event === "mute") handleSetMuted(true); + if (nativeEvent.event === "unmute") handleSetMuted(false); if (nativeEvent.event === "arrange") appAtomRegistry.set(threadArrangementOpenAtom, true); if (nativeEvent.event === "move-up") handleMoveUp(); if (nativeEvent.event === "move-down") handleMoveDown(); @@ -854,6 +877,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { handleSettle, handleSnooze, handleSetAutoSettle, + handleSetMuted, handleUnpin, handleUnsettle, handleUnsnooze, diff --git a/apps/mobile/src/state/thread-list-environments.ts b/apps/mobile/src/state/thread-list-environments.ts index 9df8a12ac502..f79ea5baccd5 100644 --- a/apps/mobile/src/state/thread-list-environments.ts +++ b/apps/mobile/src/state/thread-list-environments.ts @@ -17,6 +17,7 @@ const capabilityKeys = [ "threadSnooze", "threadPinning", "threadAutoSettleOptOut", + "threadMute", "threadPinReorder", "threadActiveReorder", "threadTitleRegeneration", @@ -66,6 +67,7 @@ function collectEnvironments(environments: ReadonlyMap(); const pinningEnvironmentIds = new Set(); const autoSettleOptOutEnvironmentIds = new Set(); + const muteEnvironmentIds = new Set(); const pinReorderEnvironmentIds = new Set(); const activeReorderEnvironmentIds = new Set(); const titleRegenerationEnvironmentIds = new Set(); @@ -75,6 +77,7 @@ function collectEnvironments(environments: ReadonlyMap + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("thread:mute"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-mute"), + threadId, + projectId: ProjectId.make("project:mute"), + title: "Review bot", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + const created = yield* projections.getThreadShell(threadId); + assert.ok(created); + let mutedAt: DateTime.Utc | null = null; + for (const [index, muted] of [true, true, false].entries()) { + yield* orchestrator.dispatch({ + type: "thread.mute.set", + commandId: CommandId.make(`mute-${index}`), + threadId, + muted, + }); + const shell = yield* projections.getThreadShell(threadId); + assert.ok(shell); + assert.equal(shell.mutedAt != null, muted); + // Muting again keeps the original timestamp. + if (index === 0) mutedAt = shell.mutedAt ?? null; + if (index === 1) assert.deepEqual(shell.mutedAt, mutedAt); + assert.deepEqual(shell.updatedAt, created.updatedAt); + const projection = yield* projections.getThreadProjection(threadId); + assert.equal(projection.thread.mutedAt != null, muted); + } + }).pipe(Effect.provide(layerTest)), +); + it.effect("implements a proposed plan that the command projection leaves out", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 5d4b1cfda15f..1b777eca56a1 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -413,6 +413,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "thread.snooze": case "thread.unsnooze": case "thread.auto-settle.set": + case "thread.mute.set": case "thread.pin": case "thread.unpin": case "thread.pin.reorder": @@ -2353,6 +2354,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | "thread.snooze" | "thread.unsnooze" | "thread.auto-settle.set" + | "thread.mute.set" | "thread.pin" | "thread.unpin" | "thread.pin.reorder" @@ -2819,6 +2821,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio updatedAt: unchanged ? thread.updatedAt : now, }; } + // Muting changes delivery, not activity, so it keeps updatedAt. + case "thread.mute.set": + return { + ...thread, + mutedAt: command.muted ? (thread.mutedAt ?? now) : null, + }; case "thread.pin": { // Pinning is a promotion: it clears the parked states rather than // silently outranking them — an explicit settle is un-settled and a @@ -3172,6 +3180,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return "thread.unsnoozed" as const; case "thread.auto-settle.set": return "thread.auto-settle-set" as const; + case "thread.mute.set": + return "thread.mute-set" as const; case "thread.pin": return "thread.pinned" as const; case "thread.unpin": @@ -10199,6 +10209,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.snooze": case "thread.unsnooze": case "thread.auto-settle.set": + case "thread.mute.set": case "thread.pin": case "thread.unpin": case "thread.pin.reorder": diff --git a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts index 8c10de314202..5628d0b22500 100644 --- a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts +++ b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts @@ -227,6 +227,7 @@ export const layer: Layer.Layer< "thread.provider-switched", "thread.visited", "thread.marked-unread", + "thread.mute-set", ]; const supersedableThreadEventTypes = new Set(SUPERSEDABLE_THREAD_EVENT_TYPES); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index ec75937a9a62..8908f5695790 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -672,10 +672,11 @@ export function applyToProjection( ...base, thread: event.payload, }; - // Visited tracking is read state, not activity: skip the updatedAt bump so - // viewing a thread does not surface it as recently active. + // Visited tracking and muting are not activity: skip the updatedAt bump so + // they do not surface the thread as recently active. case "thread.visited": case "thread.marked-unread": + case "thread.mute-set": return { ...projection, thread: event.payload, @@ -1468,6 +1469,7 @@ export function threadShellFromProjection( pinnedAt: projection.thread.pinnedAt ?? null, autoSettleDisabledAt: projection.thread.autoSettleDisabledAt ?? null, + mutedAt: projection.thread.mutedAt ?? null, pinOrderKey: projection.thread.pinOrderKey ?? null, lastVisitedAt: projection.thread.lastVisitedAt, titleRegeneration: projection.thread.titleRegeneration ?? null, @@ -1723,6 +1725,7 @@ function shellFromState(input: { pinnedAt: input.state.thread.pinnedAt ?? null, autoSettleDisabledAt: input.state.thread.autoSettleDisabledAt ?? null, + mutedAt: input.state.thread.mutedAt ?? null, pinOrderKey: input.state.thread.pinOrderKey ?? null, lastVisitedAt: input.state.thread.lastVisitedAt, titleRegeneration: input.state.thread.titleRegeneration ?? null, @@ -1768,6 +1771,7 @@ export const layer: Layer.Layer = case "thread.active-reordered": case "thread.visited": case "thread.marked-unread": + case "thread.mute-set": case "thread.metadata-updated": case "thread.pull-request-synced": case "thread.runtime-mode-updated": @@ -2599,6 +2603,7 @@ export const layer: Layer.Layer = event.type !== "thread.pin-reordered" && event.type !== "thread.visited" && event.type !== "thread.marked-unread" && + event.type !== "thread.mute-set" && event.type !== "thread.metadata-updated" && event.type !== "thread.runtime-mode-updated" && event.type !== "thread.interaction-mode-updated" && diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index 0bb2d5053b4b..21390a89cd3f 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -143,6 +143,7 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray { }), shell({ id: ThreadId.make("idle"), status: "idle" }), shell({ id: ThreadId.make("missing-project"), projectId: ProjectId.make("missing") }), + shell({ id: ThreadId.make("muted"), mutedAt: DateTime.makeUnsafe(NOW) }), ], }); assert.deepStrictEqual(ids, [THREAD_ID, newCompleted, newFailed]); @@ -302,6 +303,7 @@ describe("AgentAwarenessRelay", () => { "thread.archived", "thread.unarchived", "thread.deleted", + "thread.mute-set", ] as const) { assert.isTrue(AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ type })); } @@ -663,6 +665,24 @@ describe("AgentAwarenessRelay", () => { }), ); + it.effect("withdraws a muted thread and restores it when unmuted", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* relay.publishThread(THREAD_ID); + assert.equal(publications.length, 1); + yield* Ref.set(currentShell, shell({ mutedAt: yield* DateTime.now })); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.equal(publications.length, 2); + assert.equal(publications[1]?.state, null); + yield* Ref.set(currentShell, shell()); + yield* relay.publishThread(THREAD_ID); + assert.equal(publications.length, 3); + assert.notEqual(publications[2]?.state, null); + }), + ); + it.effect.each([ { label: "live", archived: false }, { label: "archived", archived: true }, diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index f4d3e7b57723..c6d4cf716cf6 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -100,6 +100,8 @@ export function shouldPublishAgentAwarenessEvent( case "thread.pull-request-synced": case "thread.model-selection-updated": case "thread.provider-switched": + // Muting withdraws the thread from push; unmuting restores it. + case "thread.mute-set": case "run.created": case "run.updated": case "runtime-request.updated": @@ -346,7 +348,7 @@ export function resolveAgentAwarenessRelayActiveThreadIds(input: { return input.threads .filter((thread) => { const project = projectById.get(thread.projectId); - if (!project) { + if (!project || thread.mutedAt != null) { return false; } const state = projectThreadAwarenessV2({ @@ -534,8 +536,10 @@ export const make = Effect.gen(function* () { // filter so archiving one stays quiet too. return; } + // A muted thread publishes like an archived one, so the relay clears it + // and no device is alerted. const thread = - threadShell === null || threadShell.archivedAt !== null + threadShell === null || threadShell.archivedAt !== null || threadShell.mutedAt != null ? Option.none() : Option.some(threadShell); const project = Option.isSome(thread) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 43d1f8dacffb..23851b998e36 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2389,6 +2389,7 @@ export default function Sidebar() { unpinThread, confirmAndUnpinThread, setThreadAutoSettle, + setThreadMuted, reorderPinnedThread, reorderActiveThread, markThreadUnread, @@ -4568,6 +4569,8 @@ export default function Sidebar() { const supportsTitleRegeneration = serverConfigs.get(thread.environmentId)?.environment.capabilities .threadTitleRegeneration === true; + const supportsMute = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadMute === true; const isRegeneratingTitle = thread.titleRegeneration != null; const isSettled = settledThreadKeysRef.current.has(threadKey); const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); @@ -4599,6 +4602,7 @@ export default function Sidebar() { isPinned, isSettled, autoSettleEnabled: thread.autoSettleDisabledAt == null, + isMuted: thread.mutedAt != null, isSnoozed, canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), isRegeneratingTitle, @@ -4606,6 +4610,7 @@ export default function Sidebar() { supports: { settlement: supportsSettlement, autoSettleOptOut: supportsAutoSettleOptOut, + mute: supportsMute, snooze: supportsSnooze, pinning: supportsPinning, titleRegeneration: supportsTitleRegeneration, @@ -4696,6 +4701,21 @@ export default function Sidebar() { } return; } + case "mute": + case "unmute": { + const result = await setThreadMuted(threadRef, clicked.value === "mute"); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to update notifications", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } case "rename": startThreadRename(threadRef, thread.title); return; @@ -4823,6 +4843,7 @@ export default function Sidebar() { serverConfigs, setProjectScopeKey, setThreadAutoSettle, + setThreadMuted, startThreadRename, updateThreadMetadata, timestampFormat, diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 509483d3bccd..80aea318aa30 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -146,7 +146,8 @@ function EnvironmentNotifications({ ? completedAt : (prior?.completion ?? null); next.set(thread.id, { raw: rawThread, attention, completion }); - if (!prior || thread.archivedAt !== null) continue; + // Muted threads keep their tracked state so unmuting never replays old alerts. + if (!prior || thread.archivedAt !== null || rawThread.mutedAt != null) continue; const kind = attention && attention !== prior.attention ? "input" diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 6bae63cffec6..3d879b21f9bb 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -13,6 +13,7 @@ const baseState: ThreadActionMenuState = { isPinned: false, isSettled: false, autoSettleEnabled: true, + isMuted: false, isSnoozed: false, canSnoozeNow: true, isRegeneratingTitle: false, @@ -20,6 +21,7 @@ const baseState: ThreadActionMenuState = { supports: { settlement: true, autoSettleOptOut: true, + mute: true, snooze: true, pinning: true, titleRegeneration: true, @@ -49,6 +51,7 @@ describe("buildThreadActionMenuItems", () => { isPinned: reversed, isSettled: reversed, isSnoozed: reversed, + isMuted: reversed, }); const expected = reversed ? [ @@ -58,6 +61,7 @@ describe("buildThreadActionMenuItems", () => { "rename", "regenerate-title", "auto-settle", + "unmute", "archive", "delete", ] @@ -68,6 +72,7 @@ describe("buildThreadActionMenuItems", () => { "rename", "regenerate-title", "auto-settle", + "mute", "archive", "delete", ]; @@ -98,6 +103,7 @@ describe("buildThreadActionMenuItems", () => { supports: { settlement: false, autoSettleOptOut: false, + mute: false, snooze: false, pinning: false, titleRegeneration: false, @@ -172,6 +178,14 @@ describe("buildThreadActionMenuItems", () => { ).not.toContain("auto-settle"); }); + it("offers the mute direction that reverses the current state", () => { + expect(ids(baseState)).toContain("mute"); + expect(ids({ ...baseState, isMuted: true })).toContain("unmute"); + expect(ids({ ...baseState, supports: { ...baseState.supports, mute: false } })).not.toContain( + "mute", + ); + }); + it("disables snooze when the thread cannot snooze, keeping presets visible", () => { const snooze = buildThreadActionMenuItems({ ...baseState, canSnoozeNow: false }).find( (item) => item.id === "snooze", @@ -208,6 +222,7 @@ describe("buildThreadActionMenuItems", () => { supports: { settlement: false, autoSettleOptOut: false, + mute: false, snooze: false, pinning: false, titleRegeneration: false, diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index f51328266cab..bdbdf38e43ab 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -17,6 +17,8 @@ export type ThreadActionMenuId = | "auto-settle" | "auto-settle:enabled" | "auto-settle:disabled" + | "mute" + | "unmute" | "snooze" | `snooze:${string}` | "unsnooze" @@ -85,6 +87,7 @@ export interface ThreadActionMenuState { readonly isSettled: boolean; /** False while the user has turned automatic settlement off for this thread. */ readonly autoSettleEnabled: boolean; + readonly isMuted: boolean; readonly isSnoozed: boolean; readonly canSnoozeNow: boolean; readonly isRegeneratingTitle: boolean; @@ -94,6 +97,8 @@ export interface ThreadActionMenuState { readonly settlement: boolean; /** Server understands thread.auto-settle.set. */ readonly autoSettleOptOut: boolean; + /** Server understands thread.mute.set. */ + readonly mute: boolean; readonly snooze: boolean; readonly pinning: boolean; readonly titleRegeneration: boolean; @@ -216,6 +221,13 @@ export function buildThreadActionMenuItems( }, ] : []), + ...(state.supports.mute + ? [ + state.isMuted + ? { id: "unmute" as const, label: "Unmute notifications", icon: "bell" } + : { id: "mute" as const, label: "Mute notifications", icon: "bell-off" }, + ] + : []), { id: "copy", label: "Copy", diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index db14ed8c860c..5fd201c96287 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -9,6 +9,24 @@ const ICON_PATHS: Record + setThreadMuted(threadRef, action === "mute"), + ); + return; case "rename": onStartRename(); return; @@ -359,6 +369,7 @@ export function useThreadActionMenu(input: { projects, router, setThreadAutoSettle, + setThreadMuted, settleThread, snoozeThread, threadRef, diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index cab4dde3dd20..fe6c5808b9a4 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -292,6 +292,9 @@ export function useThreadActions() { const setThreadAutoSettleMutation = useOrchestrationCommand(threadEnvironment.setAutoSettle, { reportFailure: false, }); + const setThreadMutedMutation = useOrchestrationCommand(threadEnvironment.setMuted, { + reportFailure: false, + }); const reorderPinnedThreadMutation = useOrchestrationCommand(threadEnvironment.reorderPin, { reportFailure: false, }); @@ -696,6 +699,15 @@ export function useThreadActions() { [setThreadAutoSettleMutation], ); + const setThreadMuted = useCallback( + (target: ScopedThreadRef, muted: boolean) => + setThreadMutedMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId, muted }, + }), + [setThreadMutedMutation], + ); + const pinThread = useCallback( async (target: ScopedThreadRef, opts: { orderKey?: string } = {}) => { // Version skew: never send the command to a server that predates it. @@ -1021,6 +1033,7 @@ export function useThreadActions() { reorderActiveThread, markThreadUnread, setThreadAutoSettle, + setThreadMuted, }), [ archiveThread, @@ -1032,6 +1045,7 @@ export function useThreadActions() { reorderPinnedThread, reorderActiveThread, setThreadAutoSettle, + setThreadMuted, settleThread, snoozeThread, unarchiveThread, diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index aa9b650fb5b5..8bf43f9f719b 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -252,6 +252,14 @@ export function readEnvironmentSupportsAutoSettleOptOut(environmentId: Environme ); } +/** Whether the environment's server understands thread.mute.set. */ +export function readEnvironmentSupportsMute(environmentId: EnvironmentId): boolean { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadMute === true + ); +} + export function readEnvironmentSupportsActiveReorder(environmentId: EnvironmentId): boolean { return ( appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 80af3a815b67..458005fe8274 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -231,3 +231,11 @@ Choose **Snooze → Custom…** from a thread's menu to pick a date and time in local time zone, or a duration in minutes, hours, or days. Durations start when you confirm; one day means 24 hours. On web and desktop, you can also snooze several selected threads together. Choose **Wake thread** to bring a thread back early. + +## Mute a thread + +Choose **Mute notifications** from a thread's menu to stop its alerts on every device: +desktop and browser notifications, sounds, in-app toasts, and mobile push. Use it for +threads you check on your own schedule, such as a scheduled review bot. The thread still +shows its status in the sidebar. Choose **Unmute notifications** to turn alerts back on. +You can also ask an agent to mute its own thread. diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 84f4a2ad3cff..be176a578647 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -474,6 +474,20 @@ export const setThreadAutoSettle = Effect.fn("EnvironmentCommands.setThreadAutoS }); }); +export interface SetThreadMutedInput extends ThreadCommandInput { + readonly muted: boolean; +} +export const setThreadMuted = Effect.fn("EnvironmentCommands.setThreadMuted")(function* ( + input: SetThreadMutedInput, +) { + return yield* dispatch({ + type: "thread.mute.set", + commandId: yield* allocateCommandId(input), + threadId: input.threadId, + muted: input.muted, + }); +}); + export const reorderPinnedThread = Effect.fn("EnvironmentCommands.reorderPinnedThread")(function* ( input: ReorderPinnedThreadInput, ) { diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index e9049239e7e7..c856c922ddbc 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -128,6 +128,7 @@ export interface EnvironmentThreadShell { readonly limitRecovery?: import("@t3tools/contracts").OrchestrationV2LimitRecovery | null; readonly pinnedAt: string | null; readonly autoSettleDisabledAt?: string | null; + readonly mutedAt?: string | null; /** Slot in the user-arranged pinned order; null for keyless (legacy) pins. */ readonly pinOrderKey: string | null; /** Slot in the user-arranged active order; null for keyless active threads. */ @@ -274,6 +275,7 @@ export function presentThreadShell( limitRecovery: thread.limitRecovery ?? null, pinnedAt: nullableIso(thread.pinnedAt ?? null), autoSettleDisabledAt: nullableIso(thread.autoSettleDisabledAt ?? null), + mutedAt: nullableIso(thread.mutedAt ?? null), pinOrderKey: thread.pinOrderKey ?? null, activeOrderKey: thread.activeOrderKey ?? null, ...(thread.lastVisitedAt === undefined diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.ts b/packages/client-runtime/src/state/orchestrationV2Projection.ts index 7e96a3039353..945bf2b29f4c 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.ts @@ -181,9 +181,10 @@ export function applyOrchestrationV2ProjectionEvent( case "thread.model-selection-updated": case "thread.provider-switched": return { ...base, thread: event.payload }; - // Visited tracking is read state, not activity: skip the updatedAt bump. + // Visited tracking and muting are not activity: skip the updatedAt bump. case "thread.visited": case "thread.marked-unread": + case "thread.mute-set": return { ...projection, thread: event.payload }; case "run.created": case "run.updated": { diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index cf36760baecb..1e7c9c0d8c97 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -133,6 +133,7 @@ describe("remote thread lifecycle commands", () => { ["pin", { orderKey: "a" }, { pinnedAt: expect.any(Object), pinOrderKey: "a" }], ["unpin", {}, { pinnedAt: null, pinOrderKey: null }], ["setAutoSettle", { enabled: false }, { autoSettleDisabledAt: expect.any(Object) }], + ["setMuted", { muted: true }, { mutedAt: expect.any(Object) }], ["reorderPin", { orderKey: "b" }, { pinOrderKey: "b" }], ["reorderActive", { orderKey: "b" }, { activeOrderKey: "b" }], ] as const; @@ -168,6 +169,7 @@ describe("remote thread lifecycle commands", () => { commandId: CommandId.make(action), reason: "user", enabled: false, + muted: true, orderKey: "a", snoozedUntil: "2099-01-01T00:00:00.000Z", ...input, diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index f237f1ce943d..b4f19e007a48 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -42,6 +42,7 @@ import { type ReorderPinnedThreadInput, type ReorderActiveThreadInput, type SetThreadAutoSettleInput, + type SetThreadMutedInput, type SettleThreadInput, type SnoozeThreadInput, type StartThreadTurnInput, @@ -78,6 +79,7 @@ import { reorderPinnedThread, reorderActiveThread, setThreadAutoSettle, + setThreadMuted, settleThread, snoozeThread, startThreadTurn, @@ -122,6 +124,7 @@ export type { ReorderPinnedThreadInput, ReorderActiveThreadInput, SetThreadAutoSettleInput, + SetThreadMutedInput, SettleThreadInput, SnoozeThreadInput, StartThreadTurnInput, @@ -220,6 +223,12 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + setMuted: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:set-muted", + execute: (input: SetThreadMutedInput) => setThreadMuted(input), + scheduler, + concurrency, + }), reorderActive: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:reorder-active", execute: (input: ReorderActiveThreadInput) => reorderActiveThread(input), @@ -456,6 +465,10 @@ export function createThreadEnvironmentAtoms( ...thread, autoSettleDisabledAt: input.enabled ? null : (thread.autoSettleDisabledAt ?? now), })), + setMuted: optimistic.wrap(commands.setMuted, (thread, input, now) => ({ + ...thread, + mutedAt: input.muted ? (thread.mutedAt ?? now) : null, + })), pin: optimistic.wrap(commands.pin, (thread, input, now) => ({ ...thread, pinnedAt: thread.pinnedAt ?? now, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index c9ac7482d4cc..fca2bb1bae06 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -158,6 +158,10 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server understands thread.auto-settle.set (per-thread auto-settle off). Same version-skew contract as threadSettlement. */ threadAutoSettleOptOut: Schema.optionalKey(Schema.Boolean), + /** Server understands thread.mute.set and withholds a muted thread's + notifications from every client and mobile push. Same version-skew + contract as threadSettlement. */ + threadMute: Schema.optionalKey(Schema.Boolean), /** Server understands regenerateTitle on thread.meta.update. Absent on older servers, so clients hide the action instead of sending it. */ threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index c26d5a9327e4..112b93cb9f88 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -407,6 +407,8 @@ export const OrchestrationV2AppThread = Schema.Struct({ limitRecovery: Schema.optional(Schema.NullOr(OrchestrationV2LimitRecovery)), pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), + /** Set while the user has muted this thread's notifications on every device. */ + mutedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), // Fractional-index slot in the user-arranged pinned order. Optional so // payloads from pre-reorder servers still decode. pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), @@ -1657,6 +1659,7 @@ export const OrchestrationV2DomainEvent = Schema.Union([ "thread.unsnoozed", "thread.pinned", "thread.auto-settle-set", + "thread.mute-set", "thread.unpinned", "thread.pin-reordered", "thread.active-reordered", @@ -1903,6 +1906,7 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ /** Omitted by servers that predate thread pinning. */ pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), + mutedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), /** Slot in the user-arranged pinned order; omitted by pre-reorder servers. */ pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), /** Slot in the user-arranged active order; omitted by pre-reorder servers. */ @@ -1998,6 +2002,7 @@ export const OrchestrationV2AppThreadJson = OrchestrationV2AppThread.mapFields(( snoozedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), + mutedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), lastVisitedAt: Schema.NullOr(Schema.DateTimeUtcFromString).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), @@ -2423,6 +2428,7 @@ export const OrchestrationV2ThreadShellJson = OrchestrationV2ThreadShell.mapFiel snoozedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), + mutedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), lastVisitedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), titleRegeneration: Schema.optional( Schema.NullOr( @@ -2476,6 +2482,7 @@ export const OrchestrationV2DomainEventJson = Schema.Union([ "thread.unsnoozed", "thread.pinned", "thread.auto-settle-set", + "thread.mute-set", "thread.unpinned", "thread.pin-reordered", "thread.active-reordered", @@ -2687,6 +2694,12 @@ export const OrchestrationV2Command = Schema.Union([ threadId: ThreadId, enabled: Schema.Boolean, }), + Schema.Struct({ + type: Schema.Literal("thread.mute.set"), + commandId: CommandId, + threadId: ThreadId, + muted: Schema.Boolean, + }), Schema.Struct({ type: Schema.Literal("thread.pin"), commandId: CommandId, From 307b299f5dd7bda1877404cda0eb5072f0d2dff8 Mon Sep 17 00:00:00 2001 From: Alex Ozerov Date: Thu, 8 Oct 2026 01:57:10 +0300 Subject: [PATCH 2/4] test(web): cover mute in the thread menu permission tests The menu reads readEnvironmentSupportsMute, which the mocked entities module lacked, so every case threw before the menu opened. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/hooks/useThreadActionMenu.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/hooks/useThreadActionMenu.test.ts b/apps/web/src/hooks/useThreadActionMenu.test.ts index 4b8054c986fa..20e8ef7669d8 100644 --- a/apps/web/src/hooks/useThreadActionMenu.test.ts +++ b/apps/web/src/hooks/useThreadActionMenu.test.ts @@ -48,6 +48,7 @@ vi.mock("../state/session", () => ({ })); vi.mock("../state/entities", () => ({ readEnvironmentSupportsAutoSettleOptOut: () => true, + readEnvironmentSupportsMute: () => true, readEnvironmentSupportsPinning: () => true, readEnvironmentSupportsSettlement: () => true, readEnvironmentSupportsSnooze: () => true, @@ -132,6 +133,7 @@ vi.mock("./useThreadActions", () => ({ "unsnoozeThread", "pinThread", "confirmAndUnpinThread", + "setThreadMuted", "archiveThread", "deleteThread", ].map((action) => [ @@ -193,7 +195,7 @@ describe("thread menu permissions", () => { ); }); - it.each(["rename", "regenerate-title", "delete", "pin", "settle", "archive"] as const)( + it.each(["rename", "regenerate-title", "delete", "pin", "settle", "mute", "archive"] as const)( "%s rechecks after the native menu closes", async (action) => { state.granted.add("secondary"); From 85d2d8e0ae9ec2ebc8a428d7f9f8fe5440f0b064 Mon Sep 17 00:00:00 2001 From: Alex Ozerov Date: Thu, 8 Oct 2026 02:23:04 +0300 Subject: [PATCH 3/4] fix(server): unmuting no longer alerts for work finished while muted Muting withdraws the thread from the relay, so unmuting republished its terminal state as new and the relay sent a fresh completion alert. Treat the unmute time like server start: only terminal work that finished afterwards may alert. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/relay/AgentAwarenessRelay.test.ts | 35 +++++++++++++++++++ apps/server/src/relay/AgentAwarenessRelay.ts | 28 +++++++++++---- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index f19405b12884..42ec0335c7e3 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -683,6 +683,41 @@ describe("AgentAwarenessRelay", () => { }), ); + it.effect("keeps work that finished while muted quiet after unmuting", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* relay.publishThread(THREAD_ID); + assert.equal(publications.length, 1); + yield* Ref.set(currentShell, shell({ mutedAt: yield* DateTime.now })); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.equal(publications.length, 2); + assert.equal(publications[1]?.state, null); + + const finishedWhileMuted = shell({ + status: "completed", + latestRunCompletedAt: yield* DateTime.now, + }); + yield* Ref.set(currentShell, { ...finishedWhileMuted, mutedAt: yield* DateTime.now }); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("1 second"); + yield* Ref.set(currentShell, finishedWhileMuted); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.equal(publications.length, 2); + + yield* Ref.set( + currentShell, + shell({ status: "completed", latestRunCompletedAt: yield* DateTime.now }), + ); + yield* relay.publishThread(THREAD_ID); + assert.equal(publications.length, 3); + assert.equal(publications[2]?.state?.phase, "completed"); + }), + ); + it.effect.each([ { label: "live", archived: false }, { label: "archived", archived: true }, diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index c6d4cf716cf6..53d5f7324f4d 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -423,6 +423,10 @@ export const make = Effect.gen(function* () { // tombstone can never race an in-flight live update; a recovered state // clears the deadline. Assigned after the worker exists. const publishConfirmDeadlines = new Map(); + // Threads last seen muted, and when each was unmuted. Work that finished + // while a thread was muted must not alert once it is unmuted. + const mutedThreadIds = new Set(); + const unmutedAtByThread = new Map(); let schedulePublishConfirm: (threadId: ThreadId) => Effect.Effect = () => Effect.void; const publishRetries = new Map< ThreadId, @@ -536,6 +540,11 @@ export const make = Effect.gen(function* () { // filter so archiving one stays quiet too. return; } + if (threadShell?.mutedAt != null) { + mutedThreadIds.add(threadId); + } else if (mutedThreadIds.delete(threadId)) { + unmutedAtByThread.set(threadId, (yield* DateTime.now).epochMilliseconds); + } // A muted thread publishes like an archived one, so the relay clears it // and no device is alerted. const thread = @@ -553,13 +562,18 @@ export const make = Effect.gen(function* () { }); const publishIdentity = agentAwarenessPublishIdentity(snapshot.state); const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef); - if ( - (snapshot.state?.phase === "completed" || snapshot.state?.phase === "failed") && - !publishedStateByThread.has(threadId) - ) { - // Startup has no publish history. Only work from this server process may - // produce an initial terminal alert; historical threads remain quiet. - if (Option.isNone(thread) || !terminalWorkSinceStart(thread.value, startedAt)) return; + if (snapshot.state?.phase === "completed" || snapshot.state?.phase === "failed") { + // Startup has no publish history, and unmuting follows a withdrawal. Only + // work that finished after that point may produce a terminal alert. + const quietBefore = + unmutedAtByThread.get(threadId) ?? + (publishedStateByThread.has(threadId) ? undefined : startedAt); + if ( + quietBefore !== undefined && + (Option.isNone(thread) || !terminalWorkSinceStart(thread.value, quietBefore)) + ) { + return; + } } if (publishedStateByThread.get(threadId) === publishIdentity) { // The projection is back at (or never left) the last published state, so From 3dd6ad5946a23e98ad04423a0791266604e65095 Mon Sep 17 00:00:00 2001 From: Alex Ozerov Date: Thu, 8 Oct 2026 02:45:43 +0300 Subject: [PATCH 4/4] fix(server): take the unmute cutoff from the mute event itself The cutoff was the time the publisher next read the shell, so a run that finished after the unmute but before a batched read could be silenced. Record the thread.mute-set event time instead; this also covers threads that were muted before a restart. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/relay/AgentAwarenessRelay.test.ts | 104 ++++++++++++++---- apps/server/src/relay/AgentAwarenessRelay.ts | 14 +-- 2 files changed, 90 insertions(+), 28 deletions(-) diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 42ec0335c7e3..2bc1a4695852 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -683,36 +683,100 @@ describe("AgentAwarenessRelay", () => { }), ); + // Mute and unmute reach the relay as thread.mute-set events; the unmute + // event's own time is the cutoff for terminal alerts. + const makeMuteRelay = Effect.fnUntraced(function* () { + const events = yield* Queue.unbounded(); + const pulls = yield* Queue.unbounded(); + const harness = yield* makeTestRelay({ + domainEvents: Stream.fromEffectRepeat( + Queue.offer(pulls, undefined).pipe(Effect.andThen(Queue.take(events))), + ), + }); + yield* harness.relay.start(); + yield* Queue.take(pulls); + const deliverMuteSet = Effect.fnUntraced(function* ( + mutedAt: DateTime.Utc | null, + occurredAt: DateTime.Utc, + ) { + yield* Queue.offer(events, { + id: EventId.make(`event:mute:${DateTime.toEpochMillis(occurredAt)}`), + type: "thread.mute-set", + threadId: THREAD_ID, + occurredAt, + payload: { + createdBy: "user", + creationSource: "web", + id: THREAD_ID, + projectId: PROJECT_ID, + title: "Thread", + providerInstanceId: ProviderInstanceId.make("codex"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "test-model" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { rootThreadId: THREAD_ID, parentThreadId: null, relationshipToParent: null }, + forkedFrom: null, + createdAt: occurredAt, + updatedAt: occurredAt, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + mutedAt, + }, + }); + yield* Queue.take(pulls); + yield* harness.relay.drain; + }); + // Publish live work, then mute and let the withdrawal confirm. + yield* harness.relay.publishThread(THREAD_ID); + const mutedAt = yield* DateTime.now; + yield* Ref.set(harness.currentShell, shell({ mutedAt })); + yield* deliverMuteSet(mutedAt, mutedAt); + yield* TestClock.adjust("5 seconds"); + yield* harness.relay.drain; + assert.equal(harness.publications.length, 2); + assert.equal(harness.publications[1]?.state, null); + return { ...harness, deliverMuteSet }; + }); + it.effect("keeps work that finished while muted quiet after unmuting", () => Effect.gen(function* () { - const { relay, currentShell, publications } = yield* makeTestRelay(); - yield* relay.publishThread(THREAD_ID); - assert.equal(publications.length, 1); - yield* Ref.set(currentShell, shell({ mutedAt: yield* DateTime.now })); - yield* relay.publishThread(THREAD_ID); - yield* TestClock.adjust("5 seconds"); - yield* relay.drain; - assert.equal(publications.length, 2); - assert.equal(publications[1]?.state, null); - - const finishedWhileMuted = shell({ - status: "completed", - latestRunCompletedAt: yield* DateTime.now, - }); - yield* Ref.set(currentShell, { ...finishedWhileMuted, mutedAt: yield* DateTime.now }); - yield* relay.publishThread(THREAD_ID); + const { relay, currentShell, publications, deliverMuteSet } = yield* makeMuteRelay(); + const finishedAt = yield* DateTime.now; yield* TestClock.adjust("1 second"); - yield* Ref.set(currentShell, finishedWhileMuted); - yield* relay.publishThread(THREAD_ID); + yield* Ref.set( + currentShell, + shell({ status: "completed", latestRunCompletedAt: finishedAt }), + ); + yield* deliverMuteSet(null, yield* DateTime.now); + // Past the confirmation window a first terminal state would otherwise publish. yield* TestClock.adjust("5 seconds"); yield* relay.drain; assert.equal(publications.length, 2); + }), + ); + it.effect("alerts for work that finished after the unmute even when read later", () => + Effect.gen(function* () { + const { relay, currentShell, publications, deliverMuteSet } = yield* makeMuteRelay(); + const unmutedAt = yield* DateTime.now; + yield* TestClock.adjust("3 seconds"); yield* Ref.set( currentShell, - shell({ status: "completed", latestRunCompletedAt: yield* DateTime.now }), + shell({ + status: "completed", + latestRunCompletedAt: DateTime.add(unmutedAt, { seconds: 1 }), + }), ); - yield* relay.publishThread(THREAD_ID); + yield* deliverMuteSet(null, unmutedAt); + // A first terminal state after a withdrawal waits out its confirmation. + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; assert.equal(publications.length, 3); assert.equal(publications[2]?.state?.phase, "completed"); }), diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 53d5f7324f4d..34a6606d3240 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -423,9 +423,9 @@ export const make = Effect.gen(function* () { // tombstone can never race an in-flight live update; a recovered state // clears the deadline. Assigned after the worker exists. const publishConfirmDeadlines = new Map(); - // Threads last seen muted, and when each was unmuted. Work that finished - // while a thread was muted must not alert once it is unmuted. - const mutedThreadIds = new Set(); + // When each thread was last unmuted, from the event itself so a batched + // publish cannot move the cutoff. Work that finished while a thread was muted + // must not alert once it is unmuted. const unmutedAtByThread = new Map(); let schedulePublishConfirm: (threadId: ThreadId) => Effect.Effect = () => Effect.void; const publishRetries = new Map< @@ -540,11 +540,6 @@ export const make = Effect.gen(function* () { // filter so archiving one stays quiet too. return; } - if (threadShell?.mutedAt != null) { - mutedThreadIds.add(threadId); - } else if (mutedThreadIds.delete(threadId)) { - unmutedAtByThread.set(threadId, (yield* DateTime.now).epochMilliseconds); - } // A muted thread publishes like an archived one, so the relay clears it // and no device is alerted. const thread = @@ -844,6 +839,9 @@ export const make = Effect.gen(function* () { yield* forkParked( Stream.runForEach(threads.streamDomainEvents, (event) => { const threadId = eventThreadId(event); + if (event.type === "thread.mute-set" && event.payload.mutedAt == null) { + unmutedAtByThread.set(threadId, DateTime.toEpochMillis(event.occurredAt)); + } if (!shouldPublishAgentAwarenessEvent(event)) { return Effect.void; }