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: 1 addition & 1 deletion apps/mobile/src/features/threads/ThreadAgentsSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Pl

type AgentsTarget = { readonly environmentId: EnvironmentId; readonly threadId: ThreadId };

export function useThreadTurnSubagents(target: AgentsTarget): ThreadTurnSubagents | null {
function useThreadTurnSubagents(target: AgentsTarget): ThreadTurnSubagents | null {
return useAtomValue(environmentThreadDetails.turnSubagentsAtom(target));
}

Expand Down
18 changes: 3 additions & 15 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useThreadReportedModelSelection } from "../../state/entities";
import { useSelectedThreadComposerMetadata } from "../../state/use-thread-detail";
import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard";
import { useNavigation } from "@react-navigation/native";
import type { WorktreeSetupCardProps } from "./worktree-setup-card";
Expand Down Expand Up @@ -127,9 +127,7 @@ import {
ThreadComposer,
} from "./ThreadComposer";
import { ThreadFeed, type ThreadFeedHistoryControls } from "./ThreadFeed";
import { useThreadTurnSubagents } from "./ThreadAgentsSheet";
import { ComposerQueuedEditBanner } from "./ComposerQueuedEdit";
import { useThreadQueuedCount } from "./ThreadQueueControl";
import type { ThreadContentPresentation } from "./threadContentPresentation";
import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow";

Expand Down Expand Up @@ -304,10 +302,8 @@ const USER_INPUT_TOGGLE_TIMING = {

export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {
const navigation = useNavigation();
const reportedModelSelection = useThreadReportedModelSelection({
environmentId: props.environmentId,
threadId: props.selectedThread.id,
});
const { reportedModelSelection, queuedCount, turnSubagents } =
useSelectedThreadComposerMetadata();
const deviceState = useEnvironmentQuery(
deviceEnvironment.state({ environmentId: props.environmentId, input: {} }),
);
Expand Down Expand Up @@ -368,14 +364,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + 44;
const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;
const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
const queuedCount = useThreadQueuedCount({
environmentId: props.environmentId,
threadId: props.selectedThread.id,
});
const turnSubagents = useThreadTurnSubagents({
environmentId: props.environmentId,
threadId: props.selectedThread.id,
});
const agentsSegment = resolveSubagentPillSegment(turnSubagents);
const composerEditorRef = useRef<ComposerEditorHandle>(null);
// A provider-native subagent shows status instead of a composer.
Expand Down
4 changes: 0 additions & 4 deletions apps/mobile/src/features/threads/ThreadQueueControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,6 @@ export function useThreadQueueWorkflow(target: QueueTarget) {
return useAtomValue(environmentThreadDetails.queueWorkflowAtom(target));
}

export function useThreadQueuedCount(target: QueueTarget) {
return useAtomValue(environmentThreadDetails.queuedCountAtom(target));
}

export function ThreadQueueSheet({ route }: StaticScreenProps<QueueTarget>) {
const target = route.params;
const navigation = useNavigation();
Expand Down
11 changes: 1 addition & 10 deletions apps/mobile/src/state/entities.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { useAtomValue } from "@effect/atom-react";
import { deriveReportedModelSelection } from "@supacode/client-runtime/state/thread-execution";

import { appAtomRegistry } from "./atom-registry";
import type {
EnvironmentProject,
EnvironmentThread,
EnvironmentThreadShell,
} from "@supacode/client-runtime/state/shell";
import type {
Expand All @@ -17,7 +15,7 @@ import { Atom } from "effect/unstable/reactivity";

import { environmentProjects } from "./projects";
import { environmentServerConfigsAtom, serverEnvironment } from "./server";
import { environmentThreadDetails, environmentThreadShells } from "./threads";
import { environmentThreadShells } from "./threads";

const EMPTY_PROJECT_ATOM = Atom.make<EnvironmentProject | null>(null).pipe(
Atom.withLabel("mobile-project:empty"),
Expand Down Expand Up @@ -89,10 +87,3 @@ export function useEnvironmentServerConfig(
export function useServerConfigs(): ReadonlyMap<EnvironmentId, ServerConfig> {
return useAtomValue(environmentServerConfigsAtom);
}

const selectReportedModelSelection = (thread: EnvironmentThread | null) =>
thread === null ? null : deriveReportedModelSelection(thread.projection);

export function useThreadReportedModelSelection(ref: ScopedThreadRef) {
return useAtomValue(environmentThreadDetails.threadAtom(ref), selectReportedModelSelection);
}
8 changes: 3 additions & 5 deletions apps/mobile/src/state/use-thread-composer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ export function useThreadComposerState() {
const {
selectedThread: selectedThreadShell,
selectedThreadCreation,
selectedThreadDetailRef,
selectedEnvironmentRuntime,
} = useThreadSelection();
const selectedThreadProjection = useSelectedThreadProjection();
Expand Down Expand Up @@ -299,12 +300,9 @@ export function useThreadComposerState() {
// Steering needs a live provider turn the adapter can interrupt; the queue
// workflow already derives that from the session's capabilities.
const queueWorkflow = useAtomValue(
selectedThreadShell === null
selectedThreadDetailRef === null
? EMPTY_QUEUE_WORKFLOW_ATOM
: environmentThreadDetails.queueWorkflowAtom({
environmentId: selectedThreadShell.environmentId,
threadId: selectedThreadShell.id,
}),
: environmentThreadDetails.queueWorkflowAtom(selectedThreadDetailRef),
);
const canSteerActiveTurn = queueWorkflow?.canPromoteToSteer === true;
const queuedRunEdit = useQueuedRunEdit(selectedThreadKey);
Expand Down
147 changes: 147 additions & 0 deletions apps/mobile/src/state/use-thread-detail.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { RegistryContext, useAtomValue } from "@effect/atom-react";
import {
createEnvironmentThreadDetailAtoms,
EMPTY_ENVIRONMENT_THREAD_STATE,
type EnvironmentThreadState,
} from "@supacode/client-runtime/state/threads";
import type { EnvironmentThreadShell } from "@supacode/client-runtime/state/shell";
import { CommandId, EnvironmentId, MessageId } from "@supacode/contracts";
import * as Option from "effect/Option";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import { v2Projection } from "../../../../packages/client-runtime/src/state/orchestrationV2TestFixtures";
import { appAtomRegistry } from "./atom-registry";
import {
pendingThreadCreationOutcomesAtom,
pendingThreadCreationShell,
recordPendingThreadCreationOutcome,
} from "./pending-thread-creation";
import type { QueuedThreadMessage } from "./thread-outbox-model";
import { useSelectedThreadComposerMetadata } from "./use-thread-detail";

const transport = vi.hoisted(() => ({ serverCreated: false, snapshotRequests: 0 }));
const environmentId = EnvironmentId.make("environment-1");
const threadId = v2Projection.thread.id;
const threadKey = `${environmentId}:${threadId}`;
const message: QueuedThreadMessage = {
environmentId,
threadId,
commandId: CommandId.make("create-thread"),
messageId: MessageId.make("first-message"),
text: "Check the thread",
attachments: [],
modelSelection: v2Projection.thread.modelSelection,
runtimeMode: "full-access",
createdAt: "2026-10-04T18:30:00.000Z",
creation: {
projectId: v2Projection.thread.projectId,
workspaceMode: "local",
branch: null,
worktreePath: null,
},
};
const shellAtom = Atom.make<EnvironmentThreadShell | null>(null);
const queueAtom = Atom.make<Readonly<Record<string, ReadonlyArray<QueuedThreadMessage>>>>({});
const emptyStateAtom = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE));
const stateAtom = Atom.make(() => {
transport.snapshotRequests += 1;
return AsyncResult.success<EnvironmentThreadState>(
transport.serverCreated
? { ...EMPTY_ENVIRONMENT_THREAD_STATE, data: Option.some(v2Projection), status: "live" }
: { ...EMPTY_ENVIRONMENT_THREAD_STATE, status: "deleted" },
);
}).pipe(Atom.setIdleTTL(0));
const details = createEnvironmentThreadDetailAtoms(() => stateAtom);

vi.mock("@react-navigation/native", () => ({
useRoute: () => ({ params: { environmentId, threadId } }),
}));
vi.mock("./entities", () => ({
useThreadShell: () => useAtomValue(shellAtom),
useProject: () => null,
}));
vi.mock("./threads", () => ({
get environmentThreadDetails() {
return details;
},
useEnvironmentThread: (environment: unknown, thread: unknown) => {
const state = useAtomValue(
environment === null || thread === null ? emptyStateAtom : stateAtom,
);
return Option.getOrThrow(AsyncResult.value(state));
},
}));
vi.mock("./use-thread-outbox", () => ({
useThreadOutboxMessages: () => useAtomValue(queueAtom),
}));
vi.mock("./use-remote-environment-registry", () => ({
useRemoteEnvironmentRuntime: () => null,
useSavedRemoteConnection: () => null,
}));

let renderer: ReactTestRenderer | null = null;
function Probe() {
useSelectedThreadComposerMetadata();
return null;
}

beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
transport.serverCreated = false;
transport.snapshotRequests = 0;
appAtomRegistry.set(shellAtom, null);
appAtomRegistry.set(queueAtom, { [threadKey]: [message] });
appAtomRegistry.set(pendingThreadCreationOutcomesAtom, {});
});
afterEach(async () => {
await act(() => renderer?.unmount());
renderer = null;
vi.unstubAllGlobals();
});

async function openThread() {
await act(() => {
renderer = create(
<RegistryContext.Provider value={appAtomRegistry}>
<Probe />
</RegistryContext.Provider>,
);
});
}

describe("selected thread detail readers", () => {
it("waits for queued creation delivery before requesting the thread snapshot", async () => {
await openThread();
expect(transport.snapshotRequests).toBe(0);

transport.serverCreated = true;
await act(() => {
recordPendingThreadCreationOutcome({ kind: "delivered", message });
appAtomRegistry.set(queueAtom, {});
});
expect(transport.snapshotRequests).toBe(1);
expect(Option.getOrThrow(AsyncResult.value(appAtomRegistry.get(stateAtom))).status).toBe(
"live",
);
});

it("starts reading when the server shell arrives before creation delivery returns", async () => {
await openThread();
expect(transport.snapshotRequests).toBe(0);

transport.serverCreated = true;
await act(() => appAtomRegistry.set(shellAtom, pendingThreadCreationShell(message)));
expect(transport.snapshotRequests).toBe(1);
});

it("does not request a snapshot for a rejected creation", async () => {
await openThread();
await act(() => {
recordPendingThreadCreationOutcome({ kind: "failed", message, reason: "Launch failed" });
appAtomRegistry.set(queueAtom, {});
});
expect(transport.snapshotRequests).toBe(0);
});
});
28 changes: 28 additions & 0 deletions apps/mobile/src/state/use-thread-detail.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useAtomValue } from "@effect/atom-react";
import type { PendingThreadRequests } from "@supacode/client-runtime/state/thread-requests";
import { deriveReportedModelSelection } from "@supacode/client-runtime/state/thread-execution";
import type { ThreadTurnSubagents } from "@supacode/client-runtime/state/thread-subagents";
import type { EnvironmentThread } from "@supacode/client-runtime/state/shell";
import type { EnvironmentId, OrchestrationV2ThreadProjection, ThreadId } from "@supacode/contracts";
import { Atom } from "effect/unstable/reactivity";
Expand Down Expand Up @@ -54,6 +56,32 @@ export function useSelectedThreadProjection(): EnvironmentThread | null {
});
}

const selectReportedModelSelection = (thread: EnvironmentThread | null) =>
thread === null ? null : deriveReportedModelSelection(thread.projection);
const EMPTY_QUEUED_COUNT_ATOM = Atom.make(0);
const EMPTY_TURN_SUBAGENTS_ATOM = Atom.make<ThreadTurnSubagents | null>(null);

export function useSelectedThreadComposerMetadata() {
const { selectedThreadDetailRef } = useThreadSelection();
const reportedModelSelection = useAtomValue(
selectedThreadDetailRef === null
? EMPTY_THREAD_PROJECTION_ATOM
: environmentThreadDetails.threadAtom(selectedThreadDetailRef),
selectReportedModelSelection,
);
const queuedCount = useAtomValue(
selectedThreadDetailRef === null
? EMPTY_QUEUED_COUNT_ATOM
: environmentThreadDetails.queuedCountAtom(selectedThreadDetailRef),
);
const turnSubagents = useAtomValue(
selectedThreadDetailRef === null
? EMPTY_TURN_SUBAGENTS_ATOM
: environmentThreadDetails.turnSubagentsAtom(selectedThreadDetailRef),
);
return { reportedModelSelection, queuedCount, turnSubagents };
}

export function useThreadVisibleTurnItems(
target: ThreadDetailTarget,
): OrchestrationV2ThreadProjection["visibleTurnItems"] {
Expand Down
Loading