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: 0 additions & 2 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@ const clientSettings: ClientSettings = {
glassOpacity: 80,
planModeEnabled: false,
providerModelPreferences: {},
sidebarAutoSettleAfterDays: 3,
sidebarAutoSettleOnMerge: true,
sidebarProjectGroupingMode: "repository_path",
sidebarProjectGroupingOverrides: {
"environment-1:/tmp/project-a": "separate",
Expand Down
39 changes: 4 additions & 35 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,6 @@ export function HomeScreen(props: HomeScreenProps) {
>(() => new Map());
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const threadListV2Enabled = useThreadListV2Enabled();
const autoSettleOnMerge =
!AsyncResult.isSuccess(preferencesResult) ||
preferencesResult.value.autoSettleOnMerge !== false;
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const openSwipeableRef = useRef<SwipeableMethods | null>(null);
const listRef = useRef<LegendListRef | null>(null);
Expand Down Expand Up @@ -486,26 +483,6 @@ export function HomeScreen(props: HomeScreenProps) {
// Settled threads stay in the live shell stream (settled ≠ archived), so
// the partition works directly off live shells — no snapshot merging or
// optimistic holds.
// PR states stream in per-row. The next partition applies the configured
// merge rule and the always-on close rule, matching web.
const [changeRequestStateByKey, setChangeRequestStateByKey] = useState<
ReadonlyMap<string, "open" | "closed" | "merged">
>(() => new Map());
const handleChangeRequestState = useCallback(
(threadKey: string, state: "open" | "closed" | "merged" | null) => {
setChangeRequestStateByKey((current) => {
if ((current.get(threadKey) ?? null) === state) return current;
const next = new Map(current);
if (state === null) {
next.delete(threadKey);
} else {
next.set(threadKey, state);
}
return next;
});
},
[],
);
const handleSettleThread = useCallback(
(thread: EnvironmentThreadShell) => {
void props.onSettleThread(thread);
Expand Down Expand Up @@ -569,19 +546,17 @@ export function HomeScreen(props: HomeScreenProps) {
const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []);
const [settledShelfExpanded, setSettledShelfExpanded] = useState(true);
const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []);
// now is quantized to the minute and ticks so the inactivity auto-settle
// boundary is actually crossed while the app stays open (mirrors web);
// without a clock dependency the partition memoizes a frozen "now".
// The minute tick only refreshes snooze labels and preset choices;
// settlement itself is projected server state.
const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16));
// Snooze wake times are second-precise; a counter bumped exactly at the
// next wake boundary re-runs the partition with a fresh clock so a woken
// thread reappears immediately instead of on the next minute tick.
const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
useEffect(() => {
if (!threadListV2Enabled) return;
// Refresh immediately on enable: the mount-time value can be hours old
// by the time the beta is switched on, which would misclassify the
// inactivity auto-settle boundary until the first tick.
// Refresh immediately on enable so snooze labels do not inherit an old
// mount-time value.
setNowMinute(new Date().toISOString().slice(0, 16));
const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000);
return () => clearInterval(id);
Expand Down Expand Up @@ -667,8 +642,6 @@ export function HomeScreen(props: HomeScreenProps) {
projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs,
searchQuery: props.searchQuery,
matchedThreadKeys,
changeRequestStateByKey,
autoSettleOnMerge,
settlementEnvironmentIds,
snoozeEnvironmentIds,
settledLimit: settledVisibleCount,
Expand All @@ -679,8 +652,6 @@ export function HomeScreen(props: HomeScreenProps) {
selectedThreadKey: null,
});
}, [
changeRequestStateByKey,
autoSettleOnMerge,
nowMinute,
snoozeWakeTick,
snoozedShelfExpanded,
Expand Down Expand Up @@ -850,7 +821,6 @@ export function HomeScreen(props: HomeScreenProps) {
onPinThread={handlePinThread}
onUnpinThread={handleUnpinThread}
onMovePinnedThread={handleMovePinnedThread}
onChangeRequestState={handleChangeRequestState}
projectCwd={
projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null
}
Expand All @@ -860,7 +830,6 @@ export function HomeScreen(props: HomeScreenProps) {
);
},
[
handleChangeRequestState,
handleDeleteThread,
arrangedPinnedKeys,
handleMovePinnedThread,
Expand Down
9 changes: 4 additions & 5 deletions apps/mobile/src/features/home/useThreadListActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,8 @@ function useThreadActionExecutor(
);
return false;
}
// Settle may only target what effectiveSettled could classify as
// settled: not starting/running sessions, not threads waiting on
// approvals or user input. Anything else would hide live work.
// Mirror the server's explicit-settle guard so obviously blocked
// requests fail locally instead of making a round trip.
if (action === "settle" && !canSettle(thread, { now: new Date().toISOString() })) {
Alert.alert(
actionFailureTitle(action),
Expand All @@ -143,8 +142,8 @@ function useThreadActionExecutor(
}
const result =
action === "unsettle"
? // reason "user" pins the thread active: auto-settle stays
// suppressed until real activity clears the pin server-side.
? // reason "user" holds the thread active: automation stays
// suppressed until real activity clears the override.
await unsettleMutation({
environmentId: thread.environmentId,
input: { threadId: thread.id, reason: "user" },
Expand Down
40 changes: 34 additions & 6 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,12 @@ import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState";
import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig";
import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
import { environmentCatalog } from "../../connection/catalog";
import { runtime } from "../../lib/runtime";
import { useThemeColor } from "../../lib/useThemeColor";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
import { environmentServerConfigsAtom, serverEnvironment } from "../../state/server";
import { useAtomCommand } from "../../state/use-atom-command";
import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
import {
type AppUpdateCheckState,
Expand Down Expand Up @@ -522,20 +525,45 @@ function ConfiguredSettingsRouteScreen() {
}

function GeneralSettingsSection() {
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const autoSettleOnMerge =
!AsyncResult.isSuccess(preferencesResult) ||
preferencesResult.value.autoSettleOnMerge !== false;
const environmentCatalogState = useAtomValue(environmentCatalog.catalogValueAtom);
const serverConfigs = useAtomValue(environmentServerConfigsAtom);
const updateServerSettings = useAtomCommand(
serverEnvironment.updateSettings,
"server settings update",
);
const environmentIds = useMemo(
() => [...environmentCatalogState.entries.keys()],
[environmentCatalogState.entries],
);
const allServerConfigsAvailable =
environmentCatalogState.isReady &&
environmentIds.length > 0 &&
environmentIds.every((environmentId) => serverConfigs.has(environmentId));
const autoSettleOnMerge = environmentIds.every(
(environmentId) => serverConfigs.get(environmentId)?.settings.threadAutoSettleOnMerge !== false,
);
const handleAutoSettleOnMergeChange = useCallback(
(value: boolean) => {
if (!allServerConfigsAvailable) return;
for (const environmentId of environmentIds) {
Comment thread
t3dotgg marked this conversation as resolved.
void updateServerSettings({
environmentId,
input: { patch: { threadAutoSettleOnMerge: value } },
});
}
},
[allServerConfigsAvailable, environmentIds, updateServerSettings],
);

return (
<SettingsSection title="General">
<SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" />
<SettingsSwitchRow
icon="arrow.triangle.branch"
label="Auto-settle merged threads"
disabled={!allServerConfigsAvailable}
value={autoSettleOnMerge}
onValueChange={(value) => savePreferences({ autoSettleOnMerge: value })}
onValueChange={handleAutoSettleOnMergeChange}
/>
<SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" />
</SettingsSection>
Expand Down
42 changes: 4 additions & 38 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
import { LegendList } from "@legendapp/list/react-native";
import type { MenuAction } from "@react-native-menu/menu";
import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
import type { EnvironmentId } from "@t3tools/contracts";
import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
Expand All @@ -30,7 +29,6 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
import { useThemeColor } from "../../lib/useThemeColor";
import { useProjects, useThreadShells } from "../../state/entities";
import { mobilePreferencesAtom } from "../../state/preferences";
import { useThreadSearch } from "../../state/queries";
import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
import { environmentServerConfigsAtom } from "../../state/server";
Expand Down Expand Up @@ -216,10 +214,6 @@ function ThreadNavigationSidebarPane(
regenerateThreadTitle,
} = useThreadListActions();
const threadListV2Enabled = useThreadListV2Enabled();
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const autoSettleOnMerge =
!AsyncResult.isSuccess(preferencesResult) ||
preferencesResult.value.autoSettleOnMerge !== false;
const pendingTasks = usePendingNewTasks();
const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
const environments = useMemo(
Expand Down Expand Up @@ -417,26 +411,6 @@ function ThreadNavigationSidebarPane(

// Thread List v2 (beta) support — same model as the compact Home list
// (HomeScreen.tsx): flat creation-order card block + settled recency tail.
// PR states stream in per-row. The next partition applies the configured
// merge rule and the always-on close rule.
const [changeRequestStateByKey, setChangeRequestStateByKey] = useState<
ReadonlyMap<string, "open" | "closed" | "merged">
>(() => new Map());
const handleChangeRequestState = useCallback(
(threadKey: string, state: "open" | "closed" | "merged" | null) => {
setChangeRequestStateByKey((current) => {
if ((current.get(threadKey) ?? null) === state) return current;
const next = new Map(current);
if (state === null) {
next.delete(threadKey);
} else {
next.set(threadKey, state);
}
return next;
});
},
[],
);
// The settled tail renders in pages; expansion resets when the filter
// context changes so environment/search flips never inherit a deep page.
const [settledVisibleCount, setSettledVisibleCount] = useState(
Expand All @@ -456,19 +430,17 @@ function ThreadNavigationSidebarPane(
const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []);
const [settledShelfExpanded, setSettledShelfExpanded] = useState(true);
const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []);
// now ticks per minute so the inactivity auto-settle boundary is actually
// crossed while the pane stays open; without a clock dependency the
// partition memoizes a frozen "now".
// The minute tick only refreshes snooze labels and preset choices;
// settlement itself is projected server state.
const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16));
// Snooze wake times are second-precise; a counter bumped exactly at the
// next wake boundary re-runs the partition with a fresh clock so a woken
// thread reappears immediately instead of on the next minute tick.
const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
useEffect(() => {
if (!threadListV2Enabled) return;
// Refresh immediately on enable: the mount-time value can be hours old
// by the time the beta is switched on, which would misclassify the
// inactivity auto-settle boundary until the first tick.
// Refresh immediately on enable so snooze labels do not inherit an old
// mount-time value.
setNowMinute(new Date().toISOString().slice(0, 16));
const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000);
return () => clearInterval(id);
Expand Down Expand Up @@ -551,8 +523,6 @@ function ThreadNavigationSidebarPane(
projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs,
searchQuery: props.searchQuery,
matchedThreadKeys,
changeRequestStateByKey,
autoSettleOnMerge,
settlementEnvironmentIds,
snoozeEnvironmentIds,
settledLimit: settledVisibleCount,
Expand All @@ -563,8 +533,6 @@ function ThreadNavigationSidebarPane(
selectedThreadKey: props.selectedThreadKey ?? null,
});
}, [
changeRequestStateByKey,
autoSettleOnMerge,
nowMinute,
snoozeWakeTick,
snoozedShelfExpanded,
Expand Down Expand Up @@ -988,7 +956,6 @@ function ThreadNavigationSidebarPane(
onPinThread={pinThread}
onUnpinThread={unpinThread}
onMovePinnedThread={movePinnedThread}
onChangeRequestState={handleChangeRequestState}
projectCwd={projectCwdByKey.get(scopeKey) ?? null}
onSwipeableClose={handleSwipeableClose}
onSwipeableWillOpen={handleSwipeableWillOpen}
Expand Down Expand Up @@ -1113,7 +1080,6 @@ function ThreadNavigationSidebarPane(
arrangedPinnedKeys,
confirmDeletePendingTask,
confirmDeleteThread,
handleChangeRequestState,
handleSelectThread,
handleSwipeableClose,
handleSwipeableWillOpen,
Expand Down
15 changes: 1 addition & 14 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -369,12 +369,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
readonly canMovePinnedDown?: boolean;
readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
readonly onSwipeableClose: (methods: SwipeableMethods) => void;
/** Reports this row's live PR state for the partition's merge and close
rules. Mirrors web's onChangeRequestState. */
readonly onChangeRequestState?: (
threadKey: string,
state: "open" | "closed" | "merged" | null,
) => void;
readonly projectCwd?: string | null;
readonly searchMatch?: EnvironmentThreadSearchMatch;
readonly searchQuery?: string;
Expand All @@ -397,17 +391,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
onPinThread,
onUnpinThread,
onMovePinnedThread,
onChangeRequestState,
} = props;
const snoozedRow = props.snoozed === true;
const pinnedRow = props.pinned === true;

const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null);
const prState = pr?.state ?? null;
const threadKey = `${thread.environmentId}:${thread.id}`;
useEffect(() => {
onChangeRequestState?.(threadKey, prState);
}, [onChangeRequestState, prState, threadKey]);

const screenColor = useThemeColor("--color-screen");
const drawerColor = useThemeColor("--color-drawer");
Expand Down Expand Up @@ -446,8 +434,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);

// Swipe: the v2 primary action is the lifecycle transition. Every settled
// row can un-settle — explicit settles clear the override, auto-settled
// rows get pinned active until real activity clears the pin.
// row can un-settle, suppressing automation until real activity resets it.
const canUnsettle = variant === "slim";
const [snoozeGateTick, bumpSnoozeGateTick] = useState(0);
const snoozeGateExpiryMs = props.snoozeSupported
Expand Down
Loading
Loading