Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
d4e1cee
perf(server): leave idle subagent chains out of the shell for clients…
fireboltdude1357 Nov 2, 2026
12313a8
fix(client): load the viewed thread family after shell trimming
maria-rcks Nov 2, 2026
f61dfb6
fix(client): wait for family recovery before filtering threads
maria-rcks Nov 2, 2026
beadf76
fix(web): recover blocked thread family reads
maria-rcks Nov 2, 2026
7d352b4
fix(web,mobile): only a blocked open thread waits for its family read
t3dotgg Nov 12, 2026
ad29ee5
test(server): transfer budget covers nested idle subagent chains
t3dotgg Nov 12, 2026
2b59d9f
fix(client): an opened idle subagent reads its family once
t3dotgg Nov 12, 2026
c27a651
Merge remote-tracking branch 'origin/main' into takeover/29129-idle-s…
t3dotgg Nov 12, 2026
5fcd5b8
Merge remote-tracking branch 'origin/main' into takeover/29129-idle-s…
juliusmarminge Nov 12, 2026
79b49ff
fix(server): idle subagents above a side chat or fork stay in trimmed…
juliusmarminge Nov 12, 2026
319364b
fix(client): woken children whose idle parent the shell left out reco…
juliusmarminge Nov 12, 2026
2d31815
Merge remote-tracking branch 'origin/main' into takeover/29129-idle-s…
t3dotgg Nov 12, 2026
b59f2c4
test(server): an idle subagent with a remote task keeps its chain wit…
t3dotgg Nov 12, 2026
150caf8
docs(shell): the idle-subagent trim rules name the remote task parent
t3dotgg Nov 12, 2026
418c0d5
Merge remote-tracking branch 'origin/main' into takeover/29129-idle-s…
t3dotgg Nov 12, 2026
dad1111
Merge remote-tracking branch 'origin/main' into t3/fix-mobile-idle-su…
t3dotgg Nov 12, 2026
b302c2c
Merge remote-tracking branch 'origin/main' into t3/fix-mobile-idle-su…
t3dotgg Nov 12, 2026
d79c6c3
fix(mobile): an open thread reads the family rows the shell left out
t3dotgg Nov 12, 2026
400dcf8
fix(mobile): the iPad detail column reopens a chat the shell left out
t3dotgg Nov 12, 2026
4c18b85
Merge remote-tracking branch 'origin/main' into t3/fix-ipad-thread-re…
t3dotgg Nov 12, 2026
9ac21c0
fix(mobile): a cold start reopens a subagent by its real id, and a ke…
t3dotgg Nov 12, 2026
1a00021
Merge remote-tracking branch 'origin/main' into t3/fix-ipad-thread-re…
t3dotgg Nov 12, 2026
0387a9f
Merge remote-tracking branch 'origin/main' into t3/fix-ipad-thread-re…
t3dotgg Nov 12, 2026
39c2f30
Merge remote-tracking branch 'origin/main' into t3/fix-ipad-thread-re…
t3dotgg Nov 12, 2026
534637c
fix(mobile): a reopened chat closes only on synced detail, and never …
t3dotgg Nov 12, 2026
658dd1f
Merge remote-tracking branch 'origin/main' into t3/fix-ipad-thread-re…
t3dotgg Nov 12, 2026
6f53385
Merge remote-tracking branch 'origin/main' into t3/fix-ipad-thread-re…
t3dotgg Nov 12, 2026
ab2a560
fix(mobile): a thread whose reads fail keeps its route until one of t…
t3dotgg Nov 12, 2026
20078a9
Merge remote-tracking branch 'origin/main' into t3/fix-ipad-thread-re…
t3dotgg Nov 12, 2026
7b49938
Merge remote-tracking branch 'origin/main' into t3/fix-ipad-thread-re…
t3dotgg Nov 12, 2026
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
6 changes: 3 additions & 3 deletions apps/mobile/src/connection/environment-cache-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,9 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
select: (stored) =>
stored.environmentId === environmentId
? Option.some(
stored.origin === undefined
stored.originV2 === undefined
? stored.snapshot
: { ...stored.snapshot, origin: stored.origin },
: { ...stored.snapshot, origin: stored.originV2 },
)
: Option.none(),
}).pipe(
Expand All @@ -130,7 +130,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION,
environmentId,
snapshot,
...(origin === undefined ? {} : { origin }),
...(origin === undefined ? {} : { originV2: origin }),
}).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause)));
yield* database
.saveCache(
Expand Down
7 changes: 7 additions & 0 deletions apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,11 @@ import { useAndroidWorkspaceRoot } from "./workspace/AndroidWorkspaceTabsProvide
import { dispatchRootStackReset, rootStackState } from "../../lib/root-stack-reset";
import {
currentWorkspaceSelections,
markWorkspaceThreadReopened,
workspaceSelectionCheck,
} from "./workspace/workspace-selection-store";
import {
threadReopenedByTab,
workspaceTabColumns,
workspaceTabNavigationState,
} from "./workspace/workspace-tab-navigation";
Expand Down Expand Up @@ -555,6 +557,11 @@ function AdaptiveWorkspaceLayoutContent(
seedPullRequest: withKnownPullRequestSummary,
},
);
if (tab === "threads") {
markWorkspaceThreadReopened(
threadReopenedByTab(workspace, next.routes, currentWorkspaceSelections()),
);
}
if (layout.variant === "medium") {
const opened = next.routes.at(-1 - overlays.length)?.key ?? null;
setOverlaySidebar((current) => ({ ...current, visible: true, keepOpenFor: opened }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,12 @@ import { workspaceRoutesOf } from "../workspace-overlay-routes";
import type { WorkspaceSelections } from "../../../persistence/mobile-preferences";
import { appAtomRegistry } from "../../../state/atom-registry";
import { environmentPresentations } from "../../../state/presentation";
import {
environmentThreadShells,
startOpeningEnvironmentThread,
threadEnvironment,
} from "../../../state/threads";
import { environmentShell } from "../../../state/shell";
import { environmentThreadShells, startOpeningEnvironmentThread } from "../../../state/threads";
import {
rememberedSettingsPage,
rememberedThreadReopens,
threadHasPageOpened,
workspaceSelectionsIn,
type WorkspaceSelectionCheck,
} from "./workspace-tab-navigation";
Expand All @@ -38,6 +37,9 @@ const REMEMBERS = Platform.OS === "ios";
let selections: WorkspaceSelections = {};
let saved = selections;
let hydration: Promise<void> | null = null;
// The chat a tab switch or a cold start reopened by itself, until the user opens something.
let reopened: WorkspaceSelections["threads"];
let reopenings = 0;

function save() {
const writing = selections;
Expand Down Expand Up @@ -88,7 +90,40 @@ export function currentWorkspaceSelections() {
*/
export function recordWorkspaceSelections(state: NavigationState | undefined) {
if (!REMEMBERS || !state) return;
selections = workspaceSelectionsIn(state.routes.slice(0, state.index + 1), selections);
const routes = state.routes.slice(0, state.index + 1);
selections = workspaceSelectionsIn(routes, selections);
// Opening another chat, or anything from the reopened one, makes what is on screen the user's.
if (
reopened &&
(!sameThread(reopened, selections.threads) || threadHasPageOpened(routes, reopened))
) {
reopened = undefined;
}
}

function sameThread(
left: { readonly environmentId: string; readonly threadId: string },
right: { readonly environmentId: string; readonly threadId: string } | undefined,
) {
return left.environmentId === right?.environmentId && left.threadId === right.threadId;
}

/** Marks the chat a tab switch reopened by itself; see `threadReopenedByTab`. */
export function markWorkspaceThreadReopened(thread: WorkspaceSelections["threads"]) {
reopened = thread;
if (thread) reopenings += 1;
}

/**
* The number of the reopening that put this chat on screen, or null when somebody opened it.
* Nobody asked for a reopened chat, so its screen closes it when it turns out archived or
* deleted, where a chat somebody opened shows what it has. iOS keeps a screen mounted across tab
* switches, so what the screen learned counts for one reopening only.
*/
export function workspaceThreadReopening(environmentId: EnvironmentId, threadId: ThreadId) {
return reopened !== undefined && sameThread(reopened, { environmentId, threadId })
? reopenings
: null;
}

function environmentRemoved(environmentId: string) {
Expand All @@ -101,22 +136,31 @@ function environmentRemoved(environmentId: string) {
);
}

/**
* A remembered chat is gone once its environment was removed, or once that environment's
* snapshot has loaded without it. Before it loads there is nothing to contradict it.
*/
/** Reads what `rememberedThreadReopens` decides from: the shell rows and the removals it applied. */
export const workspaceSelectionCheck: WorkspaceSelectionCheck = {
thread: (selection) => {
if (environmentRemoved(selection.environmentId)) return false;
const environmentId = selection.environmentId as EnvironmentId;
if (!appAtomRegistry.get(threadEnvironment.snapshotAtom(environmentId))) return true;
return appAtomRegistry
.get(environmentThreadShells.environmentThreadIndexAtom(environmentId))
.has(selection.threadId as ThreadId);
const threadId = selection.threadId as ThreadId;
return rememberedThreadReopens({
listed: appAtomRegistry
.get(environmentThreadShells.environmentThreadIndexAtom(environmentId))
.has(threadId),
removed: appAtomRegistry
.get(environmentShell.stateValueAtom(environmentId))
.removedAt.has(threadId),
});
},
pullRequest: (selection) => !environmentRemoved(selection.environmentId),
};

/** Stops reopening a chat its own screen found archived or deleted. */
export function forgetWorkspaceThread(environmentId: EnvironmentId, threadId: ThreadId) {
const { threads, ...rest } = selections;
if (threads && sameThread(threads, { environmentId, threadId })) selections = rest;
if (reopened && sameThread(reopened, { environmentId, threadId })) reopened = undefined;
}

/** Whether the window lays the workspace out in columns, by the rule the layout itself uses. */
export function workspaceHasColumns() {
const metrics = currentNativeLayoutMetrics();
Expand Down Expand Up @@ -153,9 +197,9 @@ export async function launchWorkspaceURL(): Promise<string | null> {
}
await metrics;
if (!thread || !workspaceHasColumns() || !workspaceSelectionCheck.thread(thread)) return null;
return Linking.createURL(
`threads/${encodeURIComponent(thread.environmentId)}/${encodeURIComponent(thread.threadId)}`,
);
markWorkspaceThreadReopened(thread);
// Not `createURL(path)`: it encodes the path again, and a subagent's id holds `%` and `:`.
return `${Linking.createURL("/")}threads/${encodeURIComponent(thread.environmentId)}/${encodeURIComponent(thread.threadId)}`;
}

/** Whether the workspace shows its first detail page, directly above its list. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { describe, expect, it } from "vite-plus/test";

import { restoreSettingsLinkState } from "../../settings/settings-navigation";
import {
rememberedThreadReopens,
threadHasPageOpened,
threadReopenedByTab,
workspaceSelectionsIn,
workspaceTabColumns,
workspaceTabNavigationState,
Expand Down Expand Up @@ -48,6 +51,42 @@ describe("workspace tab selections", () => {
});
});

describe("reopening the remembered chat", () => {
it("reopens a chat the shell left out, and not one the shell removed", () => {
// An idle subagent or an older settled chat: no row, and nothing removed it.
expect(rememberedThreadReopens({ listed: false, removed: false })).toBe(true);
expect(rememberedThreadReopens({ listed: true, removed: false })).toBe(true);
// Archived or deleted in this session.
expect(rememberedThreadReopens({ listed: false, removed: true })).toBe(false);
// Archived, then restored: the row is back.
expect(rememberedThreadReopens({ listed: true, removed: true })).toBe(true);
});

it("marks the chat a tab switch put on screen, and not one that was already there", () => {
const selections = { threads: thread.params };
expect(threadReopenedByTab([home, pullRequest], [home, thread], selections)).toBe(
selections.threads,
);
expect(threadReopenedByTab([home, thread], [home, thread], selections)).toBe(undefined);
expect(threadReopenedByTab([home, pullRequest], [home], selections)).toBe(undefined);
});
});

describe("a reopened chat the user opened something from", () => {
it("counts a page on its route or a subagent beside it, and nothing else", () => {
const sheet = { key: "agents", name: "ThreadAgents" };
const other = { key: "b", name: "Thread", params: { environmentId: "env", threadId: "b" } };
const withSubagent = { ...thread, params: { ...thread.params, inspector: { type: "thread" } } };
expect(threadHasPageOpened([home, thread], thread.params)).toBe(false);
expect(threadHasPageOpened([home, thread, sheet], thread.params)).toBe(true);
expect(threadHasPageOpened([home, thread, other], thread.params)).toBe(true);
expect(threadHasPageOpened([home, withSubagent], thread.params)).toBe(true);
// Not on screen: a tab switch away, or the launch state before the chat's route exists.
expect(threadHasPageOpened([home], thread.params)).toBe(false);
expect(threadHasPageOpened([home, pullRequest], thread.params)).toBe(false);
});
});

describe("opening a workspace tab", () => {
const columns = { columns: true, check: available };

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,47 @@ export function rememberedSettingsPage(selections: WorkspaceSelections): Setting
: DEFAULT_SETTINGS_PAGE;
}

/**
* Whether a remembered chat is reopened. Shells leave idle subagents and older settled chats out,
* so a missing row does not say the chat is gone; a removal the shell applied does. A chat
* reopened without a row closes itself when its detail says it was archived or deleted.
*/
export function rememberedThreadReopens(input: {
readonly listed: boolean;
readonly removed: boolean;
}) {
return input.listed || !input.removed;
}

function showsThread(route: RouteLike, thread: ThreadSelection) {
return (
route.name === "Thread" &&
stringParam(route.params, "environmentId") === thread.environmentId &&
stringParam(route.params, "threadId") === thread.threadId
);
}

/** The remembered chat, when opening a tab put it on screen and it was not there before. */
export function threadReopenedByTab(
previous: readonly RouteLike[],
next: readonly RouteLike[],
selections: WorkspaceSelections,
) {
const thread = selections.threads;
if (!thread || previous.some((route) => showsThread(route, thread))) return undefined;
return next.some((route) => showsThread(route, thread)) ? thread : undefined;
}

/**
* Whether the user opened something from the chat: a page or a sheet on its route, or a subagent
* beside it, which lives in the route's `inspector` param. A chat that is not on screen says no.
*/
export function threadHasPageOpened(routes: readonly RouteLike[], thread: ThreadSelection) {
const index = routes.findLastIndex((route) => showsThread(route, thread));
const params = routes[index]?.params as { readonly inspector?: unknown } | undefined;
return index >= 0 && (index < routes.length - 1 || params?.inspector !== undefined);
}

/** Whether a remembered chat or pull request can still be opened. */
export interface WorkspaceSelectionCheck {
readonly thread: (selection: ThreadSelection) => boolean;
Expand Down Expand Up @@ -167,12 +208,7 @@ function detailRoute(
const thread = selections.threads;
if (!thread || !check.thread(thread)) return undefined;
return (
state.routes.find(
(route) =>
route.name === "Thread" &&
stringParam(route.params, "environmentId") === thread.environmentId &&
stringParam(route.params, "threadId") === thread.threadId,
) ?? {
state.routes.find((route) => showsThread(route, thread)) ?? {
key: thread.key,
name: "Thread",
params: { environmentId: thread.environmentId, threadId: thread.threadId },
Expand Down
Loading
Loading