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
1 change: 0 additions & 1 deletion apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ const clientSettings: ClientSettings = {
fontSmoothing: true,
glassOpacity: 80,
providerModelPreferences: {},
sidebarAutoSettleAfterDays: 3,
sidebarProjectGroupingMode: "repository_path",
sidebarProjectGroupingOverrides: {
"environment-1:/tmp/project-a": "separate",
Expand Down
34 changes: 4 additions & 30 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -480,26 +480,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 (rows own the VCS subscriptions); a merged or
// closed PR auto-settles its thread on the next partition (mirrors 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 @@ -551,19 +531,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 @@ -617,7 +595,6 @@ export function HomeScreen(props: HomeScreenProps) {
projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs,
searchQuery: props.searchQuery,
matchedThreadKeys,
changeRequestStateByKey,
settlementEnvironmentIds,
snoozeEnvironmentIds,
settledLimit: settledVisibleCount,
Expand All @@ -628,7 +605,6 @@ export function HomeScreen(props: HomeScreenProps) {
selectedThreadKey: null,
});
}, [
changeRequestStateByKey,
nowMinute,
snoozeWakeTick,
snoozedShelfExpanded,
Expand Down Expand Up @@ -789,7 +765,6 @@ export function HomeScreen(props: HomeScreenProps) {
onUnsettleThread={handleUnsettleThread}
onPinThread={handlePinThread}
onUnpinThread={handleUnpinThread}
onChangeRequestState={handleChangeRequestState}
projectCwd={
projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null
}
Expand All @@ -799,7 +774,6 @@ export function HomeScreen(props: HomeScreenProps) {
);
},
[
handleChangeRequestState,
handleDeleteThread,
handlePinThread,
handleSettleThread,
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 @@ -97,9 +97,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 @@ -122,8 +121,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
34 changes: 4 additions & 30 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -406,26 +406,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; merged/closed PRs auto-settle their thread
// on the next partition.
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 @@ -445,19 +425,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 @@ -509,7 +487,6 @@ function ThreadNavigationSidebarPane(
projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs,
searchQuery: props.searchQuery,
matchedThreadKeys,
changeRequestStateByKey,
settlementEnvironmentIds,
snoozeEnvironmentIds,
settledLimit: settledVisibleCount,
Expand All @@ -520,7 +497,6 @@ function ThreadNavigationSidebarPane(
selectedThreadKey: props.selectedThreadKey ?? null,
});
}, [
changeRequestStateByKey,
nowMinute,
snoozeWakeTick,
snoozedShelfExpanded,
Expand Down Expand Up @@ -934,7 +910,6 @@ function ThreadNavigationSidebarPane(
onUnsettleThread={unsettleThread}
onPinThread={pinThread}
onUnpinThread={unpinThread}
onChangeRequestState={handleChangeRequestState}
projectCwd={projectCwdByKey.get(scopeKey) ?? null}
onSwipeableClose={handleSwipeableClose}
onSwipeableWillOpen={handleSwipeableWillOpen}
Expand Down Expand Up @@ -1056,7 +1031,6 @@ function ThreadNavigationSidebarPane(
archiveThread,
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 @@ -356,12 +356,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
readonly pinningSupported: boolean;
readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
readonly onSwipeableClose: (methods: SwipeableMethods) => void;
/** Reports this row's live PR state up so the partition can auto-settle
merged/closed work (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 @@ -382,17 +376,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
onArchiveThread,
onPinThread,
onUnpinThread,
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 All @@ -419,8 +407,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
33 changes: 18 additions & 15 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
ProjectId,
ProviderInstanceId,
ThreadId,
TurnId,
} from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

Expand Down Expand Up @@ -288,7 +287,7 @@ describe("buildThreadListV2Items", () => {
expect(layout.snoozedCount).toBe(1);
});

it("renders pinned threads first and exempts them from auto-settle — parity with web", () => {
it("renders pinned threads first during a transient pin/settle projection overlap", () => {
const layout = buildThreadListV2Items({
threads: [
makeThread({ id: ThreadId.make("active"), title: "Active" }),
Expand Down Expand Up @@ -572,13 +571,28 @@ describe("buildThreadListV2Items", () => {
title: "Newer",
createdAt: "2026-06-01T12:00:00.000Z",
}),
makeThread({
id: ThreadId.make("settled-first"),
title: "Settled first",
settledAt: "2026-06-01T13:00:00.000Z",
}),
makeThread({
id: ThreadId.make("settled-last"),
title: "Settled last",
settledAt: "2026-06-01T14:00:00.000Z",
}),
],
environmentId: null,
searchQuery: "",
now: NOW,
});

expect(items.map((item) => item.thread.id)).toEqual(["newer-created", "older-created"]);
expect(items.map((item) => item.thread.id)).toEqual([
"newer-created",
"older-created",
"settled-last",
"settled-first",
]);
});

it("keeps settled threads in the tail and filters by search query", () => {
Expand Down Expand Up @@ -678,18 +692,7 @@ describe("buildThreadListV2Items settled paging", () => {
id: ThreadId.make(`settled-${index}`),
title: `Settled ${index}`,
settledOverride: "settled",
settledAt: NOW,
latestUserMessageAt: `2026-06-01T0${index}:00:00.000Z`,
// A turn adopted the message (same requestedAt): without it the
// thread reads as a queued turn start, which never settles.
latestTurn: {
turnId: TurnId.make(`turn-${index}`),
state: "completed",
requestedAt: `2026-06-01T0${index}:00:00.000Z`,
startedAt: `2026-06-01T0${index}:00:00.000Z`,
completedAt: `2026-06-01T0${index}:10:00.000Z`,
assistantMessageId: null,
},
settledAt: `2026-06-01T0${index}:10:00.000Z`,
}),
),
];
Expand Down
Loading
Loading