Skip to content
Merged
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/features/archive/archivedThreadList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ function makeThread(
hasPendingUserInput: false,
hasActionableProposedPlan: false,
...input,
settledOverride: input.settledOverride ?? null,
settledAt: input.settledAt ?? null,
};
}

Expand Down
21 changes: 6 additions & 15 deletions apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,21 +123,12 @@ export function HomeRouteScreen() {
onProjectSortOrderChange={setProjectSortOrder}
onSearchQueryChange={setSearchQuery}
onSelectThread={(thread) => {
// Archived (= manually settled) rows are invisible to the live
// thread queries — opening the screen without unarchiving leaves
// it unresolvable and can drop outbox sends. Opening is
// activity, and activity un-settles: unarchive first, then
// navigate. Auto-settled rows (archivedAt null) are still live.
void (async () => {
if (thread.archivedAt !== null) {
const unsettled = await unsettleThread(thread);
if (!unsettled) return;
}
navigation.navigate("Thread", {
environmentId: thread.environmentId,
threadId: thread.id,
});
})();
// Settled threads are live shells: opening one is plain
// navigation, and sending a message un-settles server-side.
navigation.navigate("Thread", {
environmentId: thread.environmentId,
threadId: thread.id,
});
}}
onSelectPendingTask={openPendingTask}
onDeletePendingTask={confirmDeletePendingTask}
Expand Down
142 changes: 31 additions & 111 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ import { AppText as Text } from "../../components/AppText";
import { EmptyState } from "../../components/EmptyState";
import type { WorkspaceState } from "../../state/workspaceModel";
import type { SavedRemoteConnection } from "../../lib/connection";
import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
import { useArchivedThreadSnapshots } from "../archive/useArchivedThreadSnapshots";
import { scopedProjectKey } from "../../lib/scopedEntities";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
import { environmentServerConfigsAtom } from "../../state/server";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
PendingTaskListRow,
Expand Down Expand Up @@ -291,15 +291,9 @@ export function HomeScreen(props: HomeScreenProps) {

// Thread List v2 (beta): one flat list in creation order, no grouping.
// Settled threads collapse into a recency tail below the card block.
// Settle = archive in the client-only model, and the live shell stream
// drops archived threads — merge them back from the archived snapshot so
// they render as the settled tail. Live shells win on overlap.
const archivedEnvironmentIds = useMemo(
() =>
threadListV2Enabled ? props.environments.map((environment) => environment.environmentId) : [],
[props.environments, threadListV2Enabled],
);
const { snapshots: archivedSnapshots } = useArchivedThreadSnapshots(archivedEnvironmentIds);
// 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<
Expand All @@ -320,84 +314,14 @@ export function HomeScreen(props: HomeScreenProps) {
},
[],
);
// Bridge the gap between the live stream dropping a just-settled thread
// and the archived snapshot returning it: hold the shell we settled,
// marked archived, until the snapshot carries it. Held explicitly at
// settle time so deleted threads are never resurrected.
const [settledHolds, setSettledHolds] = useState<ReadonlyMap<string, EnvironmentThreadShell>>(
() => new Map(),
);
const handleSettleThread = useCallback(
(thread: EnvironmentThreadShell) => {
const threadKey = scopedThreadKey(thread.environmentId, thread.id);
// An existing hold means a settle for this thread is already in flight
// (or done and awaiting its snapshot). Re-triggering would fail the
// executor's in-flight check and the rollback below would strip the
// first settle's hold, flickering the row out of the settled tail.
if (settledHolds.has(threadKey)) {
return;
}
setSettledHolds((current) =>
new Map(current).set(threadKey, {
...thread,
archivedAt: thread.archivedAt ?? new Date().toISOString(),
}),
);
void (async () => {
// Roll the optimistic hold back if the settle was blocked or failed —
// otherwise a never-archived thread would render settled forever.
const succeeded = await props.onSettleThread(thread);
if (!succeeded) {
setSettledHolds((current) => {
const next = new Map(current);
next.delete(threadKey);
return next;
});
}
})();
},
[props.onSettleThread, settledHolds],
);
// Delete and un-settle both invalidate any hold for the thread.
const dropSettledHold = useCallback((thread: EnvironmentThreadShell) => {
setSettledHolds((current) => {
const threadKey = scopedThreadKey(thread.environmentId, thread.id);
if (!current.has(threadKey)) return current;
const next = new Map(current);
next.delete(threadKey);
return next;
});
}, []);
const handleDeleteThread = useCallback(
(thread: EnvironmentThreadShell) => {
dropSettledHold(thread);
props.onDeleteThread(thread);
},
[dropSettledHold, props.onDeleteThread],
);
const handleUnsettleThread = useCallback(
(thread: EnvironmentThreadShell) => {
dropSettledHold(thread);
props.onUnsettleThread(thread);
void props.onSettleThread(thread);
},
[dropSettledHold, props.onUnsettleThread],
[props.onSettleThread],
);
useEffect(() => {
if (settledHolds.size === 0) return;
const covered = new Set<string>();
for (const { environmentId, snapshot } of archivedSnapshots) {
for (const thread of snapshot.threads) {
covered.add(scopedThreadKey(environmentId, thread.id));
}
}
if ([...settledHolds.keys()].some((threadKey) => covered.has(threadKey))) {
setSettledHolds((current) => {
const next = new Map(current);
for (const threadKey of covered) next.delete(threadKey);
return next;
});
}
}, [archivedSnapshots, settledHolds]);
const handleDeleteThread = props.onDeleteThread;
const handleUnsettleThread = props.onUnsettleThread;
// 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 @@ -422,35 +346,36 @@ export function HomeScreen(props: HomeScreenProps) {
const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000);
return () => clearInterval(id);
}, [threadListV2Enabled]);
const threadListV2Layout = useMemo(() => {
if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 };
const merged = new Map<string, EnvironmentThreadShell>();
for (const { environmentId, snapshot } of archivedSnapshots) {
for (const thread of snapshot.threads) {
merged.set(scopedThreadKey(environmentId, thread.id), { ...thread, environmentId });
// Threads on servers without the settlement capability never classify as
// settled (the user could neither un-settle nor pin them).
const serverConfigs = useAtomValue(environmentServerConfigsAtom);
const settlementEnvironmentIds = useMemo(() => {
const supported = new Set<EnvironmentId>();
for (const [environmentId, config] of serverConfigs) {
if (config.environment.capabilities.threadSettlement === true) {
supported.add(environmentId);
}
}
for (const thread of props.threads) {
merged.set(scopedThreadKey(thread.environmentId, thread.id), thread);
}
for (const [threadKey, shell] of settledHolds) {
if (merged.has(threadKey)) continue;
merged.set(threadKey, shell);
}
return supported;
}, [serverConfigs]);
const threadListV2Layout = useMemo(() => {
if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 };
// Settled threads are live shells; archived threads keep their original
// "hidden from lists" meaning.
return buildThreadListV2Items({
threads: [...merged.values()],
threads: props.threads.filter((thread) => thread.archivedAt === null),
environmentId: props.selectedEnvironmentId,
searchQuery: props.searchQuery,
changeRequestStateByKey,
settlementEnvironmentIds,
settledLimit: settledVisibleCount,
now: `${nowMinute}:00.000Z`,
});
}, [
changeRequestStateByKey,
nowMinute,
settledHolds,
settledVisibleCount,
archivedSnapshots,
settlementEnvironmentIds,
props.searchQuery,
props.selectedEnvironmentId,
props.threads,
Expand Down Expand Up @@ -591,17 +516,12 @@ export function HomeScreen(props: HomeScreenProps) {
const keyExtractor = useCallback((item: HomeListItem) => item.key, []);

/* Empty states */
// v2 shows archived threads as its settled tail, so an archived-only
// workspace still has a list to render there. The signal must ignore the
// search/environment filters: an active query that matches nothing needs
// the in-list "No results" state, not the full-page "No threads yet".
// The signal must ignore the search/environment filters: an active query
// that matches nothing needs the in-list "No results" state, not the
// full-page "No threads yet". Settled threads are unarchived live shells,
// so the v1 check already covers v2.
const hasAnyThreads =
props.threads.some((thread) => thread.archivedAt === null) ||
props.pendingTasks.length > 0 ||
(threadListV2Enabled &&
(props.threads.length > 0 ||
settledHolds.size > 0 ||
archivedSnapshots.some(({ snapshot }) => snapshot.threads.length > 0)));
props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0;
const hasResults = projectGroups.length > 0;
const selectedEnvironmentLabel =
props.selectedEnvironmentId === null
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/home/homeListItems.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell {
createdAt: "2026-06-01T00:00:00.000Z",
updatedAt: "2026-06-01T00:00:00.000Z",
archivedAt: null,
settledOverride: null,
settledAt: null,
session: null,
latestUserMessageAt: null,
hasPendingApprovals: false,
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/home/homeThreadList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ function makeThread(
hasPendingUserInput: false,
hasActionableProposedPlan: false,
...input,
settledOverride: input.settledOverride ?? null,
settledAt: input.settledAt ?? null,
};
}

Expand Down
79 changes: 49 additions & 30 deletions apps/mobile/src/features/home/useThreadListActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,20 @@ import { Alert } from "react-native";
import { showConfirmDialog } from "../../components/ConfirmDialogHost";
import { scopedThreadKey } from "../../lib/scopedEntities";
import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots";
import { appAtomRegistry } from "../../state/atom-registry";
import { environmentServerConfigsAtom } from "../../state/server";
import { threadEnvironment } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";

/** Version skew: never send settle/unsettle to a server that predates them
(capability defaults false on decode for older servers). */
function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["environmentId"]) {
return (
appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
.threadSettlement === true
);
}

type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";

const ACTION_VERBS: Record<ThreadListAction, string> = {
Expand Down Expand Up @@ -48,10 +59,8 @@ function useThreadActionExecutor(
const archiveMutation = useAtomCommand(threadEnvironment.archive, { reportFailure: false });
const unarchiveMutation = useAtomCommand(threadEnvironment.unarchive, { reportFailure: false });
const deleteMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false });
// Client-only settled model: settle/unsettle ride the archive lifecycle so
// no server upgrade is required. See client-runtime threadSettled.ts.
const settleMutation = archiveMutation;
const unsettleMutation = unarchiveMutation;
const settleMutation = useAtomCommand(threadEnvironment.settle, { reportFailure: false });
const unsettleMutation = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false });
const inFlightThreadKeys = useRef(new Set<string>());

const executeAction = useCallback(
Expand All @@ -64,9 +73,19 @@ function useThreadActionExecutor(
inFlightThreadKeys.current.add(key);
selectionHaptic();
try {
if (
(action === "settle" || action === "unsettle") &&
!environmentSupportsSettlement(thread.environmentId)
) {
Alert.alert(
actionFailureTitle(action),
"This environment's server does not support settling yet. Update the server to use Settle.",
);
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 archive live work.
// approvals or user input. Anything else would hide live work.
if (action === "settle" && !canSettle(thread)) {
Alert.alert(
actionFailureTitle(action),
Expand All @@ -87,35 +106,35 @@ function useThreadActionExecutor(
);
return false;
}
// Auto-settled rows (inactivity / merged PR) are not archived;
// unarchiving them would be rejected. Nothing to undo — no-op.
if (action === "unsettle" && thread.archivedAt === null) {
return false;
}
const mutation =
action === "settle"
? settleMutation
: action === "unsettle"
? unsettleMutation
: action === "archive"
? archiveMutation
: action === "unarchive"
? unarchiveMutation
: deleteMutation;
const result = await mutation({
environmentId: thread.environmentId,
input: { threadId: thread.id },
});
const result =
action === "unsettle"
? // reason "user" pins the thread active: auto-settle stays
// suppressed until real activity clears the pin server-side.
await unsettleMutation({
environmentId: thread.environmentId,
input: { threadId: thread.id, reason: "user" },
})
: await (
action === "settle"
? settleMutation
: action === "archive"
? archiveMutation
: action === "unarchive"
? unarchiveMutation
: deleteMutation
)({
environmentId: thread.environmentId,
input: { threadId: thread.id },
});
if (result._tag === "Failure") {
Alert.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause));
return false;
}
// Archived threads leave the live shell stream, and the v2 list
// renders them from the archived snapshot — keep it fresh for every
// action that changes what that snapshot should contain (delete
// included, or a deleted settled row lingers until some later
// refresh).
refreshArchivedThreadsForEnvironment(thread.environmentId);
// Settled threads stay in the live shell stream; only the archive
// lifecycle still feeds the archived-snapshot surface.
if (action === "archive" || action === "unarchive" || action === "delete") {
refreshArchivedThreadsForEnvironment(thread.environmentId);
}
onCompleted?.(action, thread);
return true;
} finally {
Expand Down
13 changes: 6 additions & 7 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ function threadTimeLabel(thread: EnvironmentThreadShell, status: ThreadListV2Sta
return relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt);
}

// No separate Archive item: settle IS archive in the client-only model,
// and offering both invites a failing double-archive on settled rows.
// Menus stay lifecycle-focused: settle/un-settle plus delete. Archive keeps
// its own surface (thread screen / settings) rather than crowding the row.
const CARD_MENU_ACTIONS: MenuAction[] = [
{ id: "settle", title: "Settle", image: "checkmark" },
{ id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
Expand Down Expand Up @@ -187,11 +187,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
[handleDelete, handleSettle, handleUnsettle],
);

// Swipe: the v2 primary action is the lifecycle transition. Un-settle only
// exists when there is an archive to undo; an auto-settled slim row
// (inactivity / merged PR, archivedAt null) offers Settle, which archives
// it — the explicit "keep it settled" the row can actually deliver.
const canUnsettle = variant === "slim" && thread.archivedAt !== null;
// 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.
const canUnsettle = variant === "slim";
const primaryAction = useMemo(
() =>
canUnsettle
Expand Down
Loading
Loading