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,