Skip to content
Closed
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
2 changes: 2 additions & 0 deletions apps/mobile/src/components/AppSymbol.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleH
import IconEdit from "@tabler/icons-react-native/IconEdit";
import IconExternalLink from "@tabler/icons-react-native/IconExternalLink";
import IconEye from "@tabler/icons-react-native/IconEye";
import IconEyeOff from "@tabler/icons-react-native/IconEyeOff";
import IconFileText from "@tabler/icons-react-native/IconFileText";
import IconFilter from "@tabler/icons-react-native/IconFilter";
import IconFilterFilled from "@tabler/icons-react-native/IconFilterFilled";
Expand Down Expand Up @@ -165,6 +166,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
"exclamationmark.triangle": IconAlertTriangle,
"exclamationmark.circle": IconAlertCircle,
eye: IconEye,
"eye.slash": IconEyeOff,
folder: IconFolder,
"folder.badge.plus": IconFolderPlus,
"folder.fill": IconFolder,
Expand Down
94 changes: 77 additions & 17 deletions apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useRef, useState } from "react";
import { Platform, Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
import { DEFAULT_SERVER_SETTINGS, type GreenPullRequestSnooze } from "@t3tools/contracts";
import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings";
import { AppText as Text } from "../../components/AppText";
import { cn } from "../../lib/cn";
Expand All @@ -17,6 +17,7 @@ import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
import { SettingsSection } from "./components/SettingsSection";
import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection";
import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
import { SettingsChoiceRow } from "./components/SettingsChoiceRow";
import { SettingsScreen } from "./components/SettingsScreen";
import {
AndroidSettingsEnvironmentFilter,
Expand Down Expand Up @@ -55,6 +56,23 @@ export function SettingsThreadsRouteScreen() {
}

const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays ?? 3;
const GREEN_PULL_REQUEST_SNOOZE_CHOICES: ReadonlyArray<{
readonly value: GreenPullRequestSnooze | null;
readonly label: string;
readonly description: string;
}> = [
{ value: null, label: "Off", description: "Leave the thread active when checks pass." },
{
value: "indefinitely",
label: "Until something changes",
description: "Wake if a check fails or review feedback arrives.",
},
{ value: 1, label: "1 hour", description: "Wake after 1 hour." },
{ value: 3, label: "3 hours", description: "Wake after 3 hours." },
{ value: 24, label: "1 day", description: "Wake after 1 day." },
{ value: 72, label: "3 days", description: "Wake after 3 days." },
{ value: 168, label: "1 week", description: "Wake after 1 week." },
];

/**
* Mobile edits auto-settle defaults across selected capable targets.
Expand Down Expand Up @@ -91,6 +109,7 @@ function AutoSettleSettingsRows() {
patch: Partial<AutoSettleSettings> & {
autoResumeLimitedThreads?: boolean;
snoozeLimitedThreads?: boolean;
snoozeGreenPullRequests?: GreenPullRequestSnooze | null;
},
) => {
if (writeInFlight.current) return;
Expand Down Expand Up @@ -129,6 +148,17 @@ function AutoSettleSettingsRows() {
target.environment.serverConfig.environment.capabilities.projectSettingsOverrides === true,
);
const disabled = pendingWrites > 0 || (projectSelected && !supportsProjectOverrides);
const supportsPullRequestWatchAutoSnooze =
displayTargets.length > 0 &&
displayTargets.every(
(target) =>
target.environment.serverConfig.environment.capabilities.pullRequestWatchAutoSnooze ===
true,
);
const mixedSnoozeGreenPullRequests = displayTargets.some(
(target) =>
target.settings.snoozeGreenPullRequests !== referenceSettings.snoozeGreenPullRequests,
);
const hasProjectOverrides =
projectSelected &&
syncTargets.some(
Expand Down Expand Up @@ -171,22 +201,52 @@ function AutoSettleSettingsRows() {
/>
) : null}
{!projectSelected ? (
<SettingsSection title="Usage limits">
<SettingsSwitchRow
icon="clock"
label="Auto-resume limited threads"
value={uniformMobileSetting(displayTargets, "autoResumeLimitedThreads")}
disabled={disabled}
onValueChange={(value) => writeToAll({ autoResumeLimitedThreads: value })}
/>
<SettingsSwitchRow
icon="clock"
label="Snooze limited threads"
value={uniformMobileSetting(displayTargets, "snoozeLimitedThreads")}
disabled={disabled}
onValueChange={(value) => writeToAll({ snoozeLimitedThreads: value })}
/>
</SettingsSection>
<>
<SettingsSection title="Usage limits">
<SettingsSwitchRow
icon="clock"
label="Auto-resume limited threads"
value={uniformMobileSetting(displayTargets, "autoResumeLimitedThreads")}
disabled={disabled}
onValueChange={(value) => writeToAll({ autoResumeLimitedThreads: value })}
/>
<SettingsSwitchRow
icon="clock"
label="Snooze limited threads"
value={uniformMobileSetting(displayTargets, "snoozeLimitedThreads")}
disabled={disabled}
onValueChange={(value) => writeToAll({ snoozeLimitedThreads: value })}
/>
</SettingsSection>
{supportsPullRequestWatchAutoSnooze ? (
<SettingsSection
title="Snooze green pull requests"
trailing={
pendingWrites === 0 && mixedSnoozeGreenPullRequests ? (
<Text className="px-2 text-sm text-foreground-muted android:px-4">Mixed</Text>
) : null
}
>
{GREEN_PULL_REQUEST_SNOOZE_CHOICES.map((choice, index) => (
<SettingsChoiceRow
key={choice.label}
label={choice.label}
description={choice.description}
selected={
!mixedSnoozeGreenPullRequests &&
uniformMobileSetting(displayTargets, "snoozeGreenPullRequests") === choice.value
}
separated={index > 0}
disabled={disabled}
onPress={() => writeToAll({ snoozeGreenPullRequests: choice.value })}
/>
))}
<Text className="px-4 pb-4 text-sm text-foreground-muted">
A thread wakes early if a check later fails or review feedback arrives.
</Text>
</SettingsSection>
) : null}
</>
) : null}
<SettingsSection title="Auto-settle">
<SettingsSwitchRow
Expand Down
136 changes: 105 additions & 31 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ import type {
} from "@t3tools/client-runtime/state/shell";
import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
import type { EnvironmentMachineKind } from "@t3tools/contracts";
import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
import {
canSnooze,
pullRequestWakeLabel,
resolveSnoozePresets,
watchedPullRequestLabel,
} from "@t3tools/client-runtime/state/thread-settled";
import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort";
import type { MenuAction } from "@react-native-menu/menu";
import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
Expand Down Expand Up @@ -573,6 +578,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const statusLabel =
STATUS_LABEL_BY_STATUS[status] ??
(isUnread ? { label: "Done", className: "text-adaptive-emerald-700-300" } : undefined);
const watchedPullRequest = watchedPullRequestLabel(thread);
const pullRequestWake = pullRequestWakeLabel(thread);
const isPullRequestWoke =
pullRequestWake !== null &&
thread.settledOverride !== "settled" &&
!(Date.parse(thread.lastVisitedAt ?? "") >= Date.parse(thread.pullRequestSnooze?.wokeAt ?? ""));
// Settled rows label by the same stamp they sort by, so order and label
// can't disagree. updatedAt is always present, so the resolver never
// returns null here.
Expand Down Expand Up @@ -881,17 +892,27 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
type="monochrome"
/>
) : null}
<Text
className={cn(
"text-xs tabular-nums",
statusLabel?.className ??
(selected
? selectedThreadRowColors.foregroundClassName
: rowAppearance.tertiaryForegroundClassName),
)}
>
{statusLabel?.label ?? timeLabel}
</Text>
{isPullRequestWoke ? (
<SymbolView
accessibilityLabel={pullRequestWake ?? undefined}
name="eye"
size={14}
tintColorClassName="accent-warning-foreground"
type="monochrome"
/>
) : (
<Text
className={cn(
"text-xs tabular-nums",
statusLabel?.className ??
(selected
? selectedThreadRowColors.foregroundClassName
: rowAppearance.tertiaryForegroundClassName),
)}
>
{statusLabel?.label ?? timeLabel}
</Text>
)}
</View>
<Text
className={cn(
Expand Down Expand Up @@ -1045,10 +1066,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
key={`${thread.environmentId}:${thread.id}`}
interactionClassName={rowAppearance.interactionClassName}
interactionOpacity={rowAppearance.interactionOpacity}
className={rowAppearance.className}
className={cn(
rowAppearance.className,
isPullRequestWoke && "border border-warning-foreground/40",
)}
accessibilityHint={swipeAccessibilityHint}
accessibilityLabel={
props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title
watchedPullRequest !== null
? `${thread.title}, ${watchedPullRequest} for failed checks or review`
: isPullRequestWoke
? `${thread.title}, ${pullRequestWake}`
: props.hasQueuedMessages
? `${thread.title}, messages queued to send`
: thread.title
}
accessibilityRole="button"
accessibilityState={{ selected }}
Expand Down Expand Up @@ -1080,11 +1110,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
interactionOpacity={rowAppearance.interactionOpacity}
accessibilityHint={swipeAccessibilityHint}
accessibilityLabel={
props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title
watchedPullRequest !== null
? `${thread.title}, ${watchedPullRequest} for failed checks or review`
: isPullRequestWoke
? `${thread.title}, ${pullRequestWake}`
: props.hasQueuedMessages
? `${thread.title}, messages queued to send`
: thread.title
}
accessibilityRole="button"
accessibilityState={{ selected }}
className={rowAppearance.className}
className={cn(
rowAppearance.className,
isPullRequestWoke && "border border-warning-foreground/40",
)}
onPress={() => {
close();
onSelectThread(thread);
Expand Down Expand Up @@ -1131,21 +1170,56 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
) : null}
</View>
{props.hasQueuedMessages ? <QueuedMessageIcon selected={selected} /> : null}
<Text
className={cn(
"text-sm tabular-nums",
selected
? selectedThreadRowColors.mutedForegroundClassName
: snoozedRow
? rowAppearance.mutedForegroundClassName
: rowAppearance.tertiaryForegroundClassName,
)}
style={{ fontFamily: MONO_FONT }}
>
{snoozedRow && props.snoozeWakeLabelText !== undefined
? props.snoozeWakeLabelText
: timeLabel}
</Text>
{isPullRequestWoke ? (
<SymbolView
accessibilityLabel={pullRequestWake ?? undefined}
name="eye"
size={14}
tintColorClassName="accent-warning-foreground"
type="monochrome"
/>
) : watchedPullRequest !== null ? (
<View
accessibilityLabel={`${watchedPullRequest} for failed checks or review`}
className="flex-row items-center gap-1"
>
<SymbolView
name="eye.slash"
size={12}
tintColorClassName="accent-adaptive-sky-600-400"
type="monochrome"
/>
{props.snoozeWakeLabelText !== "∞" ? (
<Text
className={cn(
"text-sm tabular-nums",
selected
? selectedThreadRowColors.mutedForegroundClassName
: rowAppearance.mutedForegroundClassName,
)}
style={{ fontFamily: MONO_FONT }}
>
{props.snoozeWakeLabelText}
</Text>
) : null}
</View>
) : (
<Text
className={cn(
"text-sm tabular-nums",
selected
? selectedThreadRowColors.mutedForegroundClassName
: snoozedRow
? rowAppearance.mutedForegroundClassName
: rowAppearance.tertiaryForegroundClassName,
)}
style={{ fontFamily: MONO_FONT }}
>
{snoozedRow && props.snoozeWakeLabelText !== undefined
? props.snoozeWakeLabelText
: timeLabel}
</Text>
)}
</View>
</RowPressable>
);
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ export const make = Effect.gen(function* () {
threadPullRequests: true,
pullRequestStackActions: true,
pullRequestWatches: true,
pullRequestWatchAutoSnooze: true,
threadPullRequestLinking: true,
serverResolvedCommandContext: true,
environmentIcon: true,
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2440,6 +2440,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
...thread,
snoozedUntil,
limitRecovery: thread.limitRecovery ? { ...thread.limitRecovery, snooze: false } : null,
pullRequestSnooze:
command.pullRequest === undefined
? null
: { ...command.pullRequest, wokeAt: null, wakeReasons: [] },
snoozedAt: existingSnoozedAt ?? now,
updatedAt: existingSnoozedAt === null ? now : thread.updatedAt,
};
Expand All @@ -2450,6 +2454,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
...thread,
snoozedUntil: null,
snoozedAt: null,
pullRequestSnooze:
command.reason === "pull-request" && thread.pullRequestSnooze != null && !alreadyAwake
? {
...thread.pullRequestSnooze,
wokeAt: DateTime.formatIso(now),
wakeReasons: command.wakeReasons ?? [],
}
: null,
updatedAt: alreadyAwake ? thread.updatedAt : now,
};
}
Expand All @@ -2473,6 +2485,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
settledAt: thread.settledOverride === "settled" ? null : thread.settledAt,
snoozedUntil: null,
snoozedAt: null,
pullRequestSnooze: null,
updatedAt: alreadyPinned && !promotes ? thread.updatedAt : now,
};
}
Expand Down Expand Up @@ -2538,6 +2551,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
// Recovery changes acknowledge the same stopped run; keep its
// metadata timestamp from appearing as a fresh failure wake.
snoozedAt: now,
pullRequestSnooze: null,
}
: command.limitRecovery !== undefined &&
thread.limitRecovery?.snooze &&
Expand Down Expand Up @@ -4048,6 +4062,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
...projection.thread,
snoozedUntil: null,
snoozedAt: null,
pullRequestSnooze: null,
updatedAt: now,
};
yield* emit(
Expand Down
Loading
Loading