Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 7 additions & 2 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ interface HomeScreenProps {
readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
readonly onSnoozeThread: (
thread: EnvironmentThreadShell,
snoozedUntil: string,
snoozedUntil: string | null,
) => Promise<boolean>;
readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
Expand Down Expand Up @@ -402,7 +402,7 @@ export function HomeScreen(props: HomeScreenProps) {
// optimistic holds.
const handleSettleThread = props.onSettleThread;
const handleSnoozeThread = useCallback(
(thread: EnvironmentThreadShell, snoozedUntil: string) => {
(thread: EnvironmentThreadShell, snoozedUntil: string | null) => {
void props.onSnoozeThread(thread, snoozedUntil);
},
[props.onSnoozeThread],
Expand Down Expand Up @@ -496,6 +496,7 @@ export function HomeScreen(props: HomeScreenProps) {
machineByEnvironmentId,
settlementEnvironmentIds,
snoozeEnvironmentIds,
snoozeUntilAttentionEnvironmentIds,
pinningEnvironmentIds,
autoSettleOptOutEnvironmentIds,
pinReorderEnvironmentIds,
Expand Down Expand Up @@ -755,6 +756,9 @@ export function HomeScreen(props: HomeScreenProps) {
settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
onSettleThread={handleSettleThread}
snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
snoozeUntilAttentionSupported={snoozeUntilAttentionEnvironmentIds.has(
thread.environmentId,
)}
pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)}
reorderSupported={
Expand Down Expand Up @@ -808,6 +812,7 @@ export function HomeScreen(props: HomeScreenProps) {
providersByEnvironmentId,
settlementEnvironmentIds,
snoozeEnvironmentIds,
snoozeUntilAttentionEnvironmentIds,
threadSearchMatchByKey,
titleRegenerationEnvironmentIds,
toggleSettledShelf,
Expand Down
51 changes: 41 additions & 10 deletions apps/mobile/src/features/home/useThreadListActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ function environmentSupportsSnooze(environmentId: EnvironmentThreadShell["enviro
);
}

function environmentSupportsSnoozeUntilAttention(
environmentId: EnvironmentThreadShell["environmentId"],
) {
return (
appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
.threadSnoozeUntilAttention === true
);
}

function environmentSupportsPinning(environmentId: EnvironmentThreadShell["environmentId"]) {
return (
appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
Expand Down Expand Up @@ -248,7 +257,11 @@ export function useThreadListActions(): {
readonly archiveThread: (thread: EnvironmentThreadShell) => void;
readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void;
readonly settleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
readonly snoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => Promise<boolean>;
/** Snoozes until `snoozedUntil`, or until a linked pull request needs attention when null. */
readonly snoozeThread: (
thread: EnvironmentThreadShell,
snoozedUntil: string | null,
) => Promise<boolean>;
readonly unsnoozeThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
readonly pinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
Expand All @@ -268,6 +281,9 @@ export function useThreadListActions(): {
const executeAction = useThreadActionExecutor();
const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false });
const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false });
const snoozeUntilAttentionMutation = useAtomCommand(threadEnvironment.snoozeUntilAttention, {
reportFailure: false,
});
const pinMutation = useAtomCommand(threadEnvironment.pin, { reportFailure: false });
const unpinMutation = useAtomCommand(threadEnvironment.unpin, { reportFailure: false });
const setAutoSettleMutation = useAtomCommand(threadEnvironment.setAutoSettle, {
Expand All @@ -290,7 +306,7 @@ export function useThreadListActions(): {
[executeAction],
);
const snoozeThread = useCallback(
async (thread: EnvironmentThreadShell, snoozedUntil: string) => {
async (thread: EnvironmentThreadShell, snoozedUntil: string | null) => {
if (!checkThreadOperationPermission(thread, "Could not snooze thread")) return false;
const key = scopedThreadKey(thread.environmentId, thread.id);
if (snoozeInFlightThreadKeys.current.has(key)) {
Expand All @@ -305,6 +321,16 @@ export function useThreadListActions(): {
);
return false;
}
if (
snoozedUntil === null &&
!environmentSupportsSnoozeUntilAttention(thread.environmentId)
) {
Alert.alert(
"Could not snooze thread",
"This environment's server does not support snoozing until a pull request needs attention yet. Update the server to use it.",
);
return false;
}
if (!canSnooze(thread, { now: new Date().toISOString() })) {
Alert.alert(
"Could not snooze thread",
Expand All @@ -319,13 +345,18 @@ export function useThreadListActions(): {
const result = await withThreadDismissal(
key,
() =>
snoozeMutation({
environmentId: thread.environmentId,
input: {
threadId: thread.id,
snoozedUntil,
},
}),
snoozedUntil === null
? snoozeUntilAttentionMutation({
environmentId: thread.environmentId,
input: { threadId: thread.id },
})
: snoozeMutation({
environmentId: thread.environmentId,
input: {
threadId: thread.id,
snoozedUntil,
},
}),
(result) => result._tag === "Success",
);
if (result._tag === "Failure") {
Expand All @@ -343,7 +374,7 @@ export function useThreadListActions(): {
snoozeInFlightThreadKeys.current.delete(key);
}
},
[snoozeMutation],
[snoozeMutation, snoozeUntilAttentionMutation],
);
const unsnoozeThread = useCallback(
async (thread: EnvironmentThreadShell) => {
Expand Down
22 changes: 22 additions & 0 deletions apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync";
import { useSettingsEnvironmentFilter } from "./settings-environment-filter";
import {
everyMobileSettingsTargetSupports,
planMobileScopedSettingsClear,
planMobileScopedSettingsPatch,
resolveMobileSettingsTargets,
Expand Down Expand Up @@ -96,6 +97,7 @@ function AutoSettleSettingsRows() {
patch: Partial<AutoSettleSettings> & {
autoResumeLimitedThreads?: boolean;
snoozeLimitedThreads?: boolean;
snoozePullRequestsAwaitingReview?: boolean;
},
) => {
if (
Expand Down Expand Up @@ -141,6 +143,10 @@ function AutoSettleSettingsRows() {
);
const disabled =
!canWriteSettings || pendingWrites > 0 || (projectSelected && !supportsProjectOverrides);
const supportsSnoozeUntilAttention = everyMobileSettingsTargetSupports(
selectedTargets,
"threadSnoozeUntilAttention",
);
const hasProjectOverrides =
projectSelected &&
syncTargets.some(
Expand Down Expand Up @@ -207,6 +213,22 @@ function AutoSettleSettingsRows() {
/>
</SettingsSection>
) : null}
{!projectSelected ? (
<SettingsSection title="Pull requests">
<SettingsSwitchRow
icon="eye"
label="Snooze pull requests awaiting review"
subtitle={
supportsSnoozeUntilAttention
? "Let agents snooze threads whose pull requests only wait on someone else's review. They wake when the pull request needs attention."
: "Update older servers to let agents snooze pull requests awaiting review."
}
value={uniformMobileSetting(displayTargets, "snoozePullRequestsAwaitingReview")}
disabled={disabled || !supportsSnoozeUntilAttention}
onValueChange={(value) => writeToAll({ snoozePullRequestsAwaitingReview: value })}
/>
</SettingsSection>
) : null}
<SettingsSection title="Auto-settle">
<SettingsSwitchRow
icon="arrow.triangle.branch"
Expand Down
34 changes: 32 additions & 2 deletions apps/mobile/src/features/settings/settings-scoped-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import {
type ProjectId,
type ServerSettings,
} from "@t3tools/contracts";
import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings";
import { describe, expect, it } from "vite-plus/test";

import type { SettingsTarget } from "./settings-environment-filter";
import {
everyMobileSettingsTargetSupports,
planMobileScopedSettingsClear,
planMobileScopedSettingsPatch,
resolveMobileSettingsTargets,
Expand All @@ -19,8 +21,12 @@ const secondId = "second" as EnvironmentId;
const firstProject = "first-project" as ProjectId;
const secondProject = "second-project" as ProjectId;

describe("mobile usage-limit settings across environments", () => {
it.each(["autoResumeLimitedThreads", "snoozeLimitedThreads"] as const)(
describe("mobile thread behavior switches across environments", () => {
it.each([
"autoResumeLimitedThreads",
"snoozeLimitedThreads",
"snoozePullRequestsAwaitingReview",
] as const)(
"shows %s as mixed and can enable it everywhere without changing other settings",
(key) => {
const targets = resolveMobileSettingsTargets(
Expand Down Expand Up @@ -55,6 +61,30 @@ describe("mobile usage-limit settings across environments", () => {
);
});

describe("mobile settings capability across selected environments", () => {
it("counts an older server that other rows skip for lacking auto-settlement", () => {
const current = {
connection: { phase: "connected" },
serverConfig: {
environment: {
capabilities: { threadAutoSettlement: true, threadSnoozeUntilAttention: true },
},
},
} as SettingsTarget;
const older = {
connection: { phase: "connected" },
serverConfig: { environment: { capabilities: {} } },
} as SettingsTarget;

expect([current, older].filter(supportsSharedSettingsSync)).toEqual([current]);

expect(everyMobileSettingsTargetSupports([current], "threadSnoozeUntilAttention")).toBe(true);
expect(everyMobileSettingsTargetSupports([current, older], "threadSnoozeUntilAttention")).toBe(
false,
);
});
});

function environment(environmentId: EnvironmentId, settings: ServerSettings): SettingsTarget {
return {
environmentId,
Expand Down
10 changes: 10 additions & 0 deletions apps/mobile/src/features/settings/settings-scoped-server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
isNullableProjectSettingsOverride,
type ExecutionEnvironmentCapabilities,
PROJECT_SCOPED_SERVER_SETTING_KEYS,
type EnvironmentId,
type ProjectId,
Expand Down Expand Up @@ -46,6 +47,15 @@ export function resolveMobileSettingsTargets(
});
}

export function everyMobileSettingsTargetSupports(
environments: readonly Pick<SettingsTarget, "serverConfig">[],
capability: keyof ExecutionEnvironmentCapabilities,
) {
return environments.every(
(environment) => environment.serverConfig.environment.capabilities[capability] === true,
);
}

export function planMobileScopedSettingsPatch(
targets: readonly ScopedMobileSettingsTarget[],
projectSelected: boolean,
Expand Down
14 changes: 14 additions & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
presentProviderGoal,
} from "@t3tools/client-runtime/state/thread-execution";
import { resolveSubagentPillSegment } from "@t3tools/client-runtime/state/thread-subagents";
import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
import {
formatModelSelectionEffort,
type ProviderSubagentStatus,
Expand Down Expand Up @@ -138,6 +139,7 @@ import {
ThreadComposer,
} from "./ThreadComposer";
import { ThreadFeed, type ThreadFeedHistoryControls } from "./ThreadFeed";
import { useThreadListActions } from "../home/useThreadListActions";
import { useThreadTurnSubagents } from "./ThreadAgentsSheet";
import { ComposerQueuedEditBanner } from "./ComposerQueuedEdit";
import { useThreadQueuedCount } from "./ThreadQueueControl";
Expand Down Expand Up @@ -470,6 +472,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const pendingBackgroundWork = presentPendingBackgroundWork(
props.selectedThread.pendingBackgroundTasks,
);
const { unsnoozeThread } = useThreadListActions();
const snoozedUntilAttention =
props.selectedThread.snoozedUntilNeedsAttention === true &&
props.serverConfig?.environment.capabilities.threadSnooze === true &&
effectiveSnoozed(props.selectedThread, { now: new Date().toISOString() });
const floatingStatus = ((): FloatingWorkingStatus | null => {
const connectionStatus = connectionFloatingStatus({
connectionError: props.connectionError,
Expand Down Expand Up @@ -503,6 +510,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") {
return { kind: "working", startedAt: props.activeWorkStartedAt };
}
if (snoozedUntilAttention && contentPresentationKind === "ready") {
return {
kind: "snoozed",
label: "Snoozed until its pull request needs attention",
onWake: () => void unsnoozeThread(props.selectedThread),
};
}
if (pendingBackgroundWork !== null && contentPresentationKind === "ready") {
return {
kind: "background",
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ function ThreadNavigationSidebarPane(
machineByEnvironmentId,
settlementEnvironmentIds,
snoozeEnvironmentIds,
snoozeUntilAttentionEnvironmentIds,
pinningEnvironmentIds,
autoSettleOptOutEnvironmentIds,
pinReorderEnvironmentIds,
Expand Down Expand Up @@ -730,6 +731,9 @@ function ThreadNavigationSidebarPane(
settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
onSettleThread={settleThread}
snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
snoozeUntilAttentionSupported={snoozeUntilAttentionEnvironmentIds.has(
thread.environmentId,
)}
pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)}
reorderSupported={
Expand Down Expand Up @@ -828,6 +832,7 @@ function ThreadNavigationSidebarPane(
sidebarScrollGesture,
snoozeEnvironmentIds,
snoozeThread,
snoozeUntilAttentionEnvironmentIds,
toggleSettledShelf,
toggleSnoozedShelf,
toggleWorkingShelf,
Expand Down
25 changes: 22 additions & 3 deletions apps/mobile/src/features/threads/floating-working-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,15 +159,15 @@ export function FloatingWorkingControl(props: {
hasAgents ||
hasQueue ||
(props.devicePreview !== null && props.browserPreview !== null);
// The queue, agents, and reconnect labels have separate tap targets.
const statusInteractive = props.status?.kind === "connection";
// The queue, agents, reconnect, and wake labels have separate tap targets.
const statusInteractive = props.status?.kind === "connection" || props.status?.kind === "snoozed";
const capsuleInteractive = statusInteractive || hasQueue || hasAgents || hasPreview;
// The host stays centered on the capsule, but its measurement constraint
// comes from the overlay, independent of the capsule's current width.
const statusContent =
props.status !== null ? (
<View
pointerEvents={props.status.kind === "connection" ? "box-none" : "none"}
pointerEvents={statusInteractive ? "box-none" : "none"}
className="h-11 items-center justify-center"
>
<Animated.View className="h-11" style={capsuleSizerStyle} />
Expand Down Expand Up @@ -417,6 +417,25 @@ function FloatingStatusLabel(props: {
</StatusLabelRow>
);
}
if (props.status.kind === "snoozed") {
return (
<StatusLabelRow
key="snoozed"
accessibilityLabel={`${props.status.label}. Wake`}
accessibilityRole="button"
className="gap-2"
onLayout={props.onLayout}
onPress={props.status.onWake}
>
<SymbolView name="eye" size={13} tintColorClassName="foreground" type="monochrome" />
<Text className="shrink font-t3-medium text-xs text-foreground" numberOfLines={1}>
{props.status.label}
</Text>
<View className="h-4 w-px bg-border" />
<Text className="font-t3-medium text-xs text-foreground-muted">Wake</Text>
</StatusLabelRow>
);
}
if (props.status.kind === "goal") {
return (
<StatusLabelRow
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/features/threads/floating-working-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ export type FloatingWorkingStatus =
readonly accessibilityLabel: string;
readonly waiting: boolean;
}
// Snoozed until its pull request needs attention. The watch holding it would
// otherwise read as background work, so this leads it; tapping wakes the thread.
| { readonly kind: "snoozed"; readonly label: string; readonly onWake: () => void }
// A native /goal on an idle thread: paused, blocked, complete, or set.
| { readonly kind: "goal"; readonly label: string; readonly accessibilityLabel: string }
// A task whose thread the server has not created yet: the worktree may
Expand Down
Loading
Loading