Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
2534ffb
fix: settle and snooze threads while their environment is offline
Oct 25, 2026
49d939a
fix: keep offline settle and snooze safe across replay, removal, and …
Oct 25, 2026
2d3a411
fix: keep removed environments out of offline triage loads, and enabl…
Oct 25, 2026
b930245
test(web): update thread action fixtures for offline triage
Oct 25, 2026
5bdf5fb
fix: decide queued settle Undo when it runs, and keep removed grants …
Oct 25, 2026
f806de2
fix: let web triage pass the offline grant, queue the acted-on snapsh…
Oct 25, 2026
e6c9ed5
fix(web): show Pending on a snoozed row waiting to sync
Oct 25, 2026
e88b63b
fix: drop the queued change a newer action replaced after its replay …
Oct 25, 2026
8f4d880
fix(web): keep the orchestration command hook compilable by React Com…
Oct 25, 2026
60daf54
fix: check shared storage before replaying a queued settle or snooze
Oct 25, 2026
c24f97a
fix: judge expiry on the replayed change and stop replay with its reg…
Oct 25, 2026
d6f1350
fix(client-runtime): replay triage queued during reconnect
Oct 25, 2026
5c73439
fix: let the server undo a queued change that may already have landed
Oct 25, 2026
2092c65
fix: keep the sent mark when a queued change is replaced
Oct 25, 2026
1500acc
fix: read and mark a replayed change in one storage step
Oct 25, 2026
cc47398
fix(mobile): restore snooze during offline settle undo
Oct 25, 2026
7f8fc82
fix: serialize the offline queue across tabs and replay after a faile…
Oct 25, 2026
c18be0f
fix(web): reject offline queue writes without a shared lock
Oct 25, 2026
c107bb6
refactor(web): hold the offline queue's Web Lock on the caller's fiber
Oct 25, 2026
dfdc648
fix(web): cancel pending offline queue lock requests
Oct 25, 2026
ca72a3f
fix(web): release queue locks granted after cancellation
Oct 25, 2026
f501590
Merge remote-tracking branch 'github/main' into issue-watch/24532
maria-rcks Oct 26, 2026
ad600b0
fix(client): guard offline triage through reconnect and undo
maria-rcks Oct 26, 2026
a073ca7
chore: merge main into offline thread triage
maria-rcks Oct 26, 2026
76d097c
fix(client): load the full triage outbox before reconnect refresh
maria-rcks Oct 26, 2026
820631b
chore: merge main into offline thread triage
maria-rcks Oct 27, 2026
3eeb368
Merge remote-tracking branch 'origin/main' into issue-watch/24532
Oct 29, 2026
83d9f4d
fix(web): queue offline settle and snooze on plain HTTP too
Oct 29, 2026
5bc76a9
refactor(web): build the outbox storage error where it fails
Oct 29, 2026
2d21e1f
fix(server): a stopped provider session no longer drops queued settle…
Oct 29, 2026
1c3f425
Merge remote-tracking branch 'origin/main' into issue-watch/24532
Oct 29, 2026
55c8f98
fix(web): Pending outranks a folder row's status and wake label
Oct 29, 2026
24659c5
fix(client): settle and snooze during reconnect still work against ol…
Oct 29, 2026
27cb024
Merge remote-tracking branch 'origin/main' into issue-watch/24532
Oct 29, 2026
776b48a
merge: sync offline triage with main
maria-rcks Oct 30, 2026
43d7f19
Merge remote-tracking branch 'origin/main' into issue-watch/24532
Oct 30, 2026
89b7646
Merge remote-tracking branch 'origin/main' into issue-watch/24532
Oct 30, 2026
a2d6dfd
Merge remote-tracking branch 'origin/main' into issue-watch/24532
Oct 30, 2026
70c1d76
merge: sync offline thread triage with main
maria-rcks Oct 31, 2026
d9d74d0
fix(client): say why snooze is refused while a settle waits to sync
shivamhwp Nov 3, 2026
c360472
Merge remote-tracking branch 'origin/main' into issue-watch/24532
shivamhwp Nov 3, 2026
2cc00a9
fix(client): offline, un-settle and wake need a change waiting to sync
shivamhwp Nov 3, 2026
75356c7
Merge remote-tracking branch 'origin/main' into issue-watch/24532
shivamhwp Nov 8, 2026
22139fb
fix(mobile): keep the environment-cleanup error catch after merging main
shivamhwp Nov 8, 2026
9463c54
Merge remote-tracking branch 'origin/main' into issue-watch/24532
shivamhwp Nov 8, 2026
f3ba024
refactor(client-runtime): simplify the offline triage queue
shivamhwp Nov 8, 2026
dbe264f
fix(client-runtime): stop replay storms and late writes after removal
shivamhwp Nov 8, 2026
881b7df
test(client-runtime): prove replay never sends a change it could not …
shivamhwp Nov 8, 2026
aa82609
fix(client-runtime): leave a change unmarked when the server cannot r…
shivamhwp Nov 8, 2026
28f69a9
fix(web,mobile): announce pending sync and keep outbox logs bounded
shivamhwp Nov 8, 2026
3c13184
Merge remote-tracking branch 'origin/main' into issue-watch/24532
shivamhwp Nov 10, 2026
0a9cd75
refactor: queue offline settle and snooze on the client only
shivamhwp Nov 10, 2026
fca9be5
chore: merge main into offline thread triage
maria-rcks Nov 10, 2026
23becb7
Merge remote-tracking branch 'origin/issue-watch/24532' into issue-wa…
shivamhwp Nov 10, 2026
0ab8f18
fix(client-runtime): judge queued changes at send time and by every t…
shivamhwp Nov 10, 2026
80d5595
fix(client-runtime): replay reads the stored queue and marks only whi…
shivamhwp Nov 10, 2026
b2f589c
fix(client-runtime): a change whose send never left the device stays …
shivamhwp Nov 10, 2026
37d7698
fix(client-runtime): a disconnect while a change is marked leaves it …
shivamhwp Nov 10, 2026
285a9bd
fix(client-runtime): replay only takes off a sent mark it made itself
shivamhwp Nov 10, 2026
5bc9fda
fix(client-runtime): retry reading the offline queue after a failed f…
shivamhwp Nov 10, 2026
64b8f90
Merge remote-tracking branch 'github/main' into issue-watch/24532
maria-rcks Nov 10, 2026
6c3c886
Merge remote-tracking branch 'github/main' into issue-watch/24532
maria-rcks Nov 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/mobile/src/connection/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { appAtomRegistry } from "../state/atom-registry";
import { clearThreadOutboxEnvironment } from "../state/thread-outbox-removal";
import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts";
import { clearThreadComposerErrorsForEnvironment } from "../state/thread-composer-error";
import { mobileThreadTriageOutboxStore } from "../state/thread-triage-outbox-storage";
import { mobileApplicationActiveWakeup } from "./app-state-wakeups";
import * as ConnectionStorage from "./storage";

Expand Down Expand Up @@ -264,6 +265,7 @@ const layerEnvironmentOwnedDataCleanup = Layer.succeed(
Effect.promise(() => clearThreadOutboxEnvironment(environmentId)),
Effect.promise(() => clearComposerDraftsEnvironment(environmentId)),
Effect.sync(() => clearThreadComposerErrorsForEnvironment(environmentId)),
Effect.ignore(mobileThreadTriageOutboxStore.clearEnvironment(environmentId)),
],
{ concurrency: "unbounded", discard: true },
),
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/connection/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
removeCatalogValue,
replaceCatalogValue,
Persistence,
ThreadTriageOutboxStore,
} from "@t3tools/client-runtime/platform";
import { TokenStore } from "@t3tools/client-runtime/authorization";
import {
Expand All @@ -21,6 +22,7 @@ import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";

import { mobileThreadTriageOutboxStore } from "../state/thread-triage-outbox-storage";
import * as CatalogStore from "./catalog-store";

function targetPersistenceError(
Expand Down Expand Up @@ -164,6 +166,7 @@ export const layer = Layer.effectContext(
Context.add(ProfileStore.ConnectionProfileStore, profileStore),
Context.add(CredentialStore.ConnectionCredentialStore, credentialStore),
Context.add(TokenStore.RemoteDpopAccessTokenStore, remoteTokenStore),
Context.add(ThreadTriageOutboxStore, mobileThreadTriageOutboxStore),
);
}),
);
44 changes: 42 additions & 2 deletions apps/mobile/src/features/home/useThreadListActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const state = vi.hoisted(() => ({
failedAction: null as string | null,
archivedRefreshes: [] as string[],
scopes: new Map<string, Set<string>>(),
sessionNotLoaded: new Set<string>(),
successValues: new Map<string, unknown>(),
shells: [] as EnvironmentThreadShell[],
requests: [] as {
action: string;
Expand Down Expand Up @@ -60,6 +62,9 @@ vi.mock("../archive/useArchivedThreadSnapshots", () => ({
vi.mock("../../state/session", () => ({
readEnvironmentScope: (environmentId: string, scope: string) =>
state.scopes.get(environmentId)?.has(scope) === true,
readThreadTriageScope: (environmentId: string) =>
state.scopes.get(environmentId)?.has(AuthOrchestrationOperateScope) === true ||
state.sessionNotLoaded.has(environmentId),
}));
vi.mock("../../state/presentation", () => ({
connectedEnvironmentIdsAtom: "connected-environments",
Expand Down Expand Up @@ -145,13 +150,17 @@ vi.mock("../../state/threads", () => ({
input: { threadId: string; orderKey?: string };
}) => {
state.requests.push({ action, ...request });
if (!state.scopes.get(request.environmentId)?.has(AuthOrchestrationOperateScope)) {
// Without a loaded session the command itself queues settle and snooze.
if (
!state.scopes.get(request.environmentId)?.has(AuthOrchestrationOperateScope) &&
!state.sessionNotLoaded.has(request.environmentId)
) {
return AsyncResult.failure(Cause.fail(new Error("Thread operation denied")));
}
if (action === state.failedAction)
return AsyncResult.failure(Cause.fail(new Error("Server rejected action")));
state.afterRequest?.();
return AsyncResult.success(undefined);
return AsyncResult.success(state.successValues.get(action));
},
]),
),
Expand Down Expand Up @@ -191,6 +200,8 @@ beforeEach(() => {
state.archivedRefreshes = [];
state.pendingOrder = null;
state.dropBusy = false;
state.sessionNotLoaded = new Set();
state.successValues = new Map();
state.scopes = new Map([
[primaryEnvironmentId, new Set([AuthOrchestrationOperateScope])],
[otherEnvironmentId, new Set<string>()],
Expand Down Expand Up @@ -230,6 +241,19 @@ describe("thread list operation permissions", () => {
expect(state.requests).toEqual([]);
});

it.each(mutationCases)(
"%s runs before the session loads only for settle, snooze and their undo",
async (handler, action) => {
state.scopes.get(primaryEnvironmentId)!.clear();
state.sessionNotLoaded.add(primaryEnvironmentId);

await useThreadListActions()[handler](makeThread(), "2099-01-01T00:00:00.000Z");

const triage = ["settle", "unsettle", "snooze", "unsnooze"].includes(action);
expect(state.requests.map((request) => request.action)).toEqual(triage ? [action] : []);
},
);

it("rechecks permission after a retained delete confirmation", async () => {
useThreadListActions().confirmDeleteThread(makeThread());
const confirm = state.dialogs[0]?.onConfirm;
Expand Down Expand Up @@ -431,6 +455,22 @@ describe("thread action undo", () => {
expect(readThreadUndoNotice()).toBeNull();
});

// Whether the queued settle reached the server is known only when Undo runs.
it.each([
["cancels it before it syncs", { sequence: 0, cancelledPendingSync: true }, ["snooze"]],
["finds it delivered", { sequence: 3 }, ["pin", "snooze"]],
])("restores a queued settle only as far as needed when Undo %s", async (_, undone, restored) => {
state.successValues.set("unsettle", undone);
const thread = makeThread({
pinnedAt: "2026-09-02T00:00:00.000Z",
pinOrderKey: "m",
snoozedUntil: "2099-01-01T09:00:00.000Z",
});
expect(await useThreadListActions().settleThread(thread)).toBe(true);
await readThreadUndoNotice()!.undo();
expect(state.requests.map(({ action }) => action)).toEqual(["settle", "unsettle", ...restored]);
});

// regression: #21401 restore archive and thread action undo
it("wakes a snoozed thread through the undo action", async () => {
const thread = makeThread();
Expand Down
37 changes: 28 additions & 9 deletions apps/mobile/src/features/home/useThreadListActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort";
import { appAtomRegistry } from "../../state/atom-registry";
import { environmentServerConfigsAtom } from "../../state/server";
import { connectedEnvironmentIdsAtom } from "../../state/presentation";
import { readEnvironmentScope } from "../../state/session";
import { readEnvironmentScope, readThreadTriageScope } from "../../state/session";
import { environmentThreadShells, threadEnvironment } from "../../state/threads";
import { queuedThreadKeysAtom } from "../../state/use-thread-outbox";
import { useAtomCommand } from "../../state/use-atom-command";
Expand Down Expand Up @@ -77,8 +77,18 @@ function actionFailureTitle(action: ThreadListAction): string {
return "Could not delete thread";
}

function checkThreadOperationPermission(thread: EnvironmentThreadShell, title: string): boolean {
if (readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope)) return true;
/** Settle, snooze and their undo may queue before the environment's session loads. */
function checkThreadOperationPermission(
thread: EnvironmentThreadShell,
title: string,
triage = false,
): boolean {
if (
triage
? readThreadTriageScope(thread.environmentId)
: readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope)
)
return true;
Dialog.alert(title, "This connection cannot change threads.");
return false;
}
Expand All @@ -97,8 +107,10 @@ function useThreadActionExecutor(
action: ThreadListAction,
thread: EnvironmentThreadShell,
onStarted?: () => void,
onSucceeded?: (value: unknown) => void,
) => {
if (!checkThreadOperationPermission(thread, actionFailureTitle(action))) return false;
const triage = action === "settle" || action === "unsettle";
if (!checkThreadOperationPermission(thread, actionFailureTitle(action), triage)) return false;
const key = scopedThreadKey(thread.environmentId, thread.id);
if (inFlightThreadKeys.current.has(key)) {
return false;
Expand Down Expand Up @@ -160,6 +172,7 @@ function useThreadActionExecutor(
if (action === "archive" || action === "unarchive" || action === "delete") {
refreshArchivedThreadsForEnvironment(thread.environmentId);
}
onSucceeded?.(result.value);
onCompleted?.(action, thread);
return true;
})();
Expand Down Expand Up @@ -234,21 +247,27 @@ export function useThreadListActions(): {
action,
claim,
undo: async () => {
// A local cancellation keeps pin placement, but may have replaced a queued snooze.
let cancelledQueued = false;
try {
await ThreadUndo.executeThreadUndo(
plan,
async (step) => {
switch (step.action) {
case "unsettle":
return unsettleThread(thread);
return (
(await executeAction("unsettle", thread, undefined, (value) => {
cancelledQueued = ThreadUndo.cancelledPendingSync(value);
})) === true
);
case "unsnooze":
return unsnoozeThread(thread);
case "pin":
return pinThread(thread, step.orderKey);
return cancelledQueued || pinThread(thread, step.orderKey);
case "snooze": {
const failureTitle =
action === "Unpinned" ? "Could not undo unpin" : "Could not undo settle";
if (!checkThreadOperationPermission(thread, failureTitle)) return false;
if (!checkThreadOperationPermission(thread, failureTitle, true)) return false;
const result = await snoozeMutation({
environmentId: thread.environmentId,
input: { threadId: thread.id, snoozedUntil: step.snoozedUntil },
Expand Down Expand Up @@ -291,7 +310,7 @@ export function useThreadListActions(): {
return succeeded;
};
const snoozeThread = async (thread: EnvironmentThreadShell, snoozedUntil: string) => {
if (!checkThreadOperationPermission(thread, "Could not snooze thread")) return false;
if (!checkThreadOperationPermission(thread, "Could not snooze thread", true)) return false;
const key = scopedThreadKey(thread.environmentId, thread.id);
if (snoozeInFlightThreadKeys.current.has(key)) {
return false;
Expand Down Expand Up @@ -347,7 +366,7 @@ export function useThreadListActions(): {
return succeeded;
};
const unsnoozeThread = async (thread: EnvironmentThreadShell) => {
if (!checkThreadOperationPermission(thread, "Could not wake thread")) return false;
if (!checkThreadOperationPermission(thread, "Could not wake thread", true)) return false;
const key = scopedThreadKey(thread.environmentId, thread.id);
if (snoozeInFlightThreadKeys.current.has(key)) {
return false;
Expand Down
10 changes: 8 additions & 2 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ import { appAtomRegistry } from "../../state/atom-registry";
import { threadStreamHaptics } from "./threadStreamHaptics";
import { useAtomCommand } from "../../state/use-atom-command";
import { orchestrationEnvironment } from "../../state/orchestration";
import { useThreadTriageScope } from "../../state/session";
import { useDelayedStatus } from "../../lib/useDelayedStatus";
import type { PendingUserInputDraftAnswer } from "@t3tools/client-runtime/state/pending-user-input";
import type { RunStopPresentation } from "@t3tools/client-runtime/state/thread-execution";
Expand Down Expand Up @@ -585,6 +586,7 @@ function ThreadDetailBody(
});
const unsnoozeThread = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false });
const unsettleThread = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false });
const canTriageThread = useThreadTriageScope(props.environmentId);
const updateMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false });
const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false });
const anyRunStop = useAtomValue(
Expand Down Expand Up @@ -681,7 +683,9 @@ function ThreadDetailBody(
latestRunCompletedAt: props.selectedThread.latestRun?.completedAt ?? null,
});
const runStatusAction = async (action: "stop" | "wake" | "unsettle" | "dismiss") => {
if (statusPending || !props.canOperateThread) return;
const permitted =
action === "wake" || action === "unsettle" ? canTriageThread : props.canOperateThread;
if (statusPending || !permitted) return;
const request: BackgroundWorkStopRequest = {
threadKey: selectedThreadKey,
taskIds:
Expand Down Expand Up @@ -868,7 +872,9 @@ function ThreadDetailBody(
: "Dismissing..."
: actionLabel,
accessibilityLabel: actionLabel,
disabled: !props.canOperateThread || statusPending,
disabled:
!(lifecycleSnoozed || settled ? canTriageThread : props.canOperateThread) ||
statusPending,
onPress: () =>
void runStatusAction(lifecycleSnoozed ? "wake" : settled ? "unsettle" : "dismiss"),
},
Expand Down
41 changes: 29 additions & 12 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useAtomValue } from "@effect/atom-react";
import * as Dialog from "../../components/ui/Dialog";
import type { ThreadRowProviderInstance } from "./thread-provider-instance";
import {
Expand Down Expand Up @@ -50,7 +51,7 @@ import { cn } from "../../lib/cn";
import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
import { copyThreadReference } from "../../lib/copyThreadReference";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
import { useEnvironmentScope } from "../../state/session";
import { useEnvironmentScope, useThreadTriageScope } from "../../state/session";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
pendingDraftBranchNeedsRefs,
Expand Down Expand Up @@ -281,6 +282,8 @@ type ThreadListV2ShelfHeaderProps = {
readonly pane?: "screen" | "sidebar";
};

const TRIAGE_MENU_ACTION_IDS = new Set(["settle", "snooze", "unsettle", "unsnooze"]);

const SHELF_LABEL = { working: "Working", snoozed: "Snoozed", settled: "Settled" } as const;

function ThreadListV2ShelfHeader(
Expand Down Expand Up @@ -775,9 +778,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
? { ...workingLabel, label: "Goal" }
: workingLabel) ?? (isUnread ? DONE_STATUS_LABEL : undefined);
const recede = shouldRecedeThreadRow({ status, selected });
const pendingSync = useAtomValue(threadEnvironment.pendingSyncAtom(thread.environmentId)).has(
thread.id,
);
const timeLabel = pendingSync ? "Waiting to sync" : props.timeLabel;
Comment thread
shivamhwp marked this conversation as resolved.
Resolved
const isRegeneratingTitle = thread.titleRegeneration != null;
const rowAccessibilityLabel = `${thread.title}${providerNameLabel ? `, ${providerNameLabel}` : ""}${props.hasQueuedMessages ? ", messages queued to send" : ""}${isRegeneratingTitle ? ", regenerating title" : ""}`;
const timeLabel = props.timeLabel;
const rowAccessibilityLabel = `${thread.title}${providerNameLabel ? `, ${providerNameLabel}` : ""}${pendingSync ? ", waiting to sync" : ""}${props.hasQueuedMessages ? ", messages queued to send" : ""}${isRegeneratingTitle ? ", regenerating title" : ""}`;

const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false });
const handleAcknowledgeWoke = useCallback(() => {
Expand All @@ -799,6 +805,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);
const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]);
const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope);
// Before the session loads, only settle, snooze and their undo are offered: they can queue.
const triageOnly = useThreadTriageScope(thread.environmentId) && !canOperateThread;
const triageAllows = (action: string) => TRIAGE_MENU_ACTION_IDS.has(action);
const handleRegenerateTitle = useCallback(
() => onRegenerateThreadTitle(thread),
[onRegenerateThreadTitle, thread],
Expand Down Expand Up @@ -1148,11 +1157,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
: null,
[handleMenuAction, snoozePresetActions, swipeActions.secondary, thread.title],
);
const swipeAccessibilityHint = !canOperateThread
? "Opens the thread"
: secondaryAction === null
? `Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.`
: `Opens the thread. Swipe left for ${primaryAction.label.toLowerCase()} and snooze actions.`;
const swipeAccessibilityHint =
!canOperateThread && !(triageOnly && triageAllows(swipeActions.primary))
? "Opens the thread"
: secondaryAction === null
? `Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.`
: `Opens the thread. Swipe left for ${primaryAction.label.toLowerCase()} and snooze actions.`;

const titleTextInset = props.project ? projectIconSize + 8 : 0;
const cardContent = (
Expand Down Expand Up @@ -1536,17 +1546,16 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
)}
style={{ fontFamily: MONO_FONT }}
>
{snoozedRow && props.snoozeWakeLabelText !== undefined
{!pendingSync && snoozedRow && props.snoozeWakeLabelText !== undefined
? props.snoozeWakeLabelText
: timeLabel}
</Text>
</View>
</RowPressable>
);

if (!canOperateThread)
if (!canOperateThread && !(triageOnly && triageAllows(swipeActions.primary)))
return <View style={rowAppearance.swipeContainerStyle}>{rowContent(() => {})}</View>;

return (
<View collapsable={false}>
{customSnoozeOpen && (
Expand All @@ -1571,7 +1580,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
threadTitle={thread.title}
>
{(close) => (
<Menu actions={menuActions} onPressAction={handleMenuAction} shouldOpenOnLongPress>
<Menu
actions={
triageOnly
? menuActions.filter((action) => triageAllows(action.id ?? ""))
: menuActions
}
onPressAction={handleMenuAction}
shouldOpenOnLongPress
>
{rowContent(close)}
</Menu>
)}
Expand Down
Loading
Loading