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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ We wanted something performant, remote-ready, and truly open. If we ever go the
>
> - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login`
> - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login`
> - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login`
> - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login`. To run threads as Cursor Cloud agents instead, add a Cursor API key as `CURSOR_API_KEY` in Cursor's provider settings.
> - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login`
> - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login`
> - Antigravity: enable it in Settings, then use **Install Antigravity** and **Sign in with Google**. No CLI is required.
Expand Down
52 changes: 36 additions & 16 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { cloudProviderSnapshot } from "@t3tools/client-runtime/provider-execution";
import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { useAtomValue } from "@effect/atom-react";
Expand Down Expand Up @@ -268,6 +269,17 @@ export function ComposerSurface(props: {
}

export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {
const runsInCloud = props.selectedThread.executionTarget === "cloud";
const threadServerConfig = useMemo(
() =>
runsInCloud && props.serverConfig
? {
...props.serverConfig,
providers: props.serverConfig.providers.map(cloudProviderSnapshot),
}
: props.serverConfig,
[props.serverConfig, runsInCloud],
);
const project = useProject(scopeProjectRef(props.environmentId, props.selectedThread.projectId));
const { themeVariables: materialTheme } = useAppearancePreferences();
const composerPanel = materialTheme["--color-composer-panel"];
Expand Down Expand Up @@ -321,15 +333,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
const currentRuntimeMode = props.selectedThread.runtimeMode;
const modelUnavailable =
props.connectionState === "connected" &&
isModelSelectionUnavailable(props.serverConfig, currentModelSelection);
isModelSelectionUnavailable(threadServerConfig, currentModelSelection);
const selectedProviderStatus = useMemo(() => {
if (!props.serverConfig) return null;
if (!threadServerConfig) return null;
return (
props.serverConfig.providers.find(
threadServerConfig.providers.find(
(p) => p.instanceId === props.selectedThread.modelSelection.instanceId,
) ?? null
);
}, [props.serverConfig, props.selectedThread.modelSelection.instanceId]);
}, [threadServerConfig, props.selectedThread.modelSelection.instanceId]);
const composerOwnerKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
const openDraftDocument = (attachment: ComposerDocumentAttachment) => {
Keyboard.dismiss();
Expand All @@ -350,23 +362,23 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
selectedProviderStatus !== null &&
hasProviderUsageLimits(
selectedProviderStatus.driver,
props.serverConfig?.providers ?? [],
props.serverConfig?.usageLimitSources ?? [],
threadServerConfig?.providers ?? [],
threadServerConfig?.usageLimitSources ?? [],
);
// Answered locally from the last Limits snapshot; the agent never sees it.
const openUsageLimits = useCallback(() => {
const report = collectProviderUsageLimits(
currentModelSelection.instanceId,
props.serverConfig?.providers ?? [],
props.serverConfig?.usageLimitSources ?? [],
threadServerConfig?.providers ?? [],
threadServerConfig?.usageLimitSources ?? [],
Date.now(),
);
onShowUsageLimits(report);
if (!report) {
Alert.alert("Usage limits unavailable", "This provider does not currently report limits.");
}
return report !== null;
}, [currentModelSelection.instanceId, onShowUsageLimits, props.serverConfig]);
}, [currentModelSelection.instanceId, onShowUsageLimits, threadServerConfig]);

const composerMenu = useComposerCommandMenu({
draftMessage: props.draftMessage,
Expand Down Expand Up @@ -520,8 +532,14 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer

// ── Model menu ───────────────────────────────────────────
const modelOptions = useMemo(
() => buildModelOptions(props.serverConfig, currentModelSelection),
[props.serverConfig, currentModelSelection],
() =>
buildModelOptions(threadServerConfig, currentModelSelection).filter(
(option) =>
!runsInCloud ||
(option.selection.instanceId === currentModelSelection.instanceId &&
option.selection.model === currentModelSelection.model),
),
[threadServerConfig, currentModelSelection, runsInCloud],
);
const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]);
// An existing thread is bound to its harness: sessions can't move between
Expand All @@ -538,11 +556,13 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
) ?? null;
const providerOptionDescriptors = useMemo(
() =>
resolveProviderOptionDescriptors({
capabilities: currentModelOption?.capabilities,
selections: currentModelSelection.options,
}),
[currentModelOption?.capabilities, currentModelSelection.options],
runsInCloud
? []
: resolveProviderOptionDescriptors({
capabilities: currentModelOption?.capabilities,
selections: currentModelSelection.options,
}),
[currentModelOption?.capabilities, currentModelSelection.options, runsInCloud],
);
const settingsOwnerId = composerOwnerKey;
const settingsRouteSession = useMemo<ExistingThreadSettingsRouteSession>(
Expand Down
30 changes: 30 additions & 0 deletions apps/mobile/src/lib/modelOptions.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { cloudProviderSnapshot } from "@t3tools/client-runtime/provider-execution";
import { describe, expect, it } from "vite-plus/test";

import { ProviderInstanceId, type ModelSelection, type ServerConfig } from "@t3tools/contracts";
Expand All @@ -13,6 +14,35 @@ import {
} from "./modelOptions";

describe("mobile model options", () => {
it("uses cloud models without requiring a local Cursor CLI", () => {
const selection: ModelSelection = {
instanceId: ProviderInstanceId.make("cursor"),
model: "cloud-model",
};
const config = {
providers: [
{
instanceId: "cursor",
driver: "cursor",
enabled: true,
installed: false,
auth: { status: "unauthenticated" },
models: [{ slug: "local-model", name: "Local", isCustom: false, capabilities: null }],
cloud: {
available: true,
models: [{ slug: "cloud-model", name: "Cloud", isCustom: false, capabilities: null }],
},
},
],
} as unknown as ServerConfig;
const cloudConfig = { ...config, providers: config.providers.map(cloudProviderSnapshot) };
expect(resolveSelectableModelSelection(cloudConfig, selection)).toEqual(selection);
const options = buildModelOptions(cloudConfig, selection);
expect(options).toHaveLength(1);
expect(options[0]).toMatchObject({ label: "Cloud", selection });
expect(options[0]?.isUnavailable).not.toBe(true);
});

it("groups models by provider and flags legacy entries", () => {
const config = {
providers: [
Expand Down
39 changes: 39 additions & 0 deletions apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
ProviderRuntimeEvent,
ProviderSession,
ProviderInstanceId,
type ThreadExecutionTarget,
} from "@t3tools/contracts";
import {
CommandId,
Expand Down Expand Up @@ -307,6 +308,7 @@ describe("CheckpointReactor", () => {
readonly localStatusRefName?: string | null;
readonly providerSessionCwd?: string;
readonly providerName?: ProviderDriverKind;
readonly executionTarget?: ThreadExecutionTarget;
readonly gitStatusRefreshCalls?: Array<string>;
readonly pullRequestRefreshCalls?: Array<string>;
readonly pullRequestRefresh?: Effect.Effect<void>;
Expand Down Expand Up @@ -458,6 +460,7 @@ describe("CheckpointReactor", () => {
branch: options?.threadBranch ?? null,
worktreePath:
options?.threadWorktreePath !== undefined ? options.threadWorktreePath : cwd,
...(options?.executionTarget ? { executionTarget: options.executionTarget } : {}),
createdAt,
})
.pipe(
Expand Down Expand Up @@ -1095,6 +1098,42 @@ describe("CheckpointReactor", () => {
).toBe(false);
});

it("does not checkpoint the local checkout for a cloud thread's turn", async () => {
const harness = await createHarness({
seedFilesystemCheckpoints: false,
executionTarget: "cloud",
});
const createdAt = "2026-01-01T00:00:00.000Z";
harness.provider.emit({
type: "turn.started",
eventId: EventId.make("evt-remote-turn-started"),
provider: ProviderDriverKind.make("codex"),
createdAt,
threadId: ThreadId.make("thread-1"),
turnId: asTurnId("turn-remote"),
});
// A local edit during the turn is not the remote agent's work.
NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "local edit\n", "utf8");
harness.provider.emit({
type: "turn.completed",
eventId: EventId.make("evt-remote-turn-completed"),
provider: ProviderDriverKind.make("codex"),
createdAt,
threadId: ThreadId.make("thread-1"),
turnId: asTurnId("turn-remote"),
payload: { state: "completed" },
});
await harness.drain();

const thread = (await harness.readModel()).threads.find((entry) => entry.id === "thread-1");
expect(thread?.checkpoints).toEqual([]);
for (const turnCount of [0, 1]) {
expect(
gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), turnCount)),
).toBe(false);
}
});

it("refreshes local git status state on turn completion using the session cwd", async () => {
const gitStatusRefreshCalls: string[] = [];
const harness = await createHarness({
Expand Down
15 changes: 12 additions & 3 deletions apps/server/src/orchestration/Layers/CheckpointReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
EventId,
MessageId,
type ProjectId,
type ThreadExecutionTarget,
ThreadId,
TurnId,
type OrchestrationEvent,
Expand Down Expand Up @@ -205,14 +206,22 @@ const make = Effect.gen(function* () {

// Resolves the workspace CWD for checkpoint operations, preferring the
// active provider session CWD and falling back to the thread/project config.
// Returns undefined when no CWD can be determined or the workspace is not
// a git repository.
// Returns undefined when no CWD can be determined, the workspace is not
// a git repository, or the thread's agent works in a cloud workspace that
// the local checkout does not reflect.
const resolveCheckpointCwd = Effect.fn("resolveCheckpointCwd")(function* (input: {
readonly threadId: ThreadId;
readonly thread: { readonly projectId: ProjectId; readonly worktreePath: string | null };
readonly thread: {
readonly projectId: ProjectId;
readonly worktreePath: string | null;
readonly executionTarget?: ThreadExecutionTarget | undefined;
};
readonly projects: ReadonlyArray<{ readonly id: ProjectId; readonly workspaceRoot: string }>;
readonly preferSessionRuntime: boolean;
}): Effect.fn.Return<string | undefined, CheckpointStoreError> {
if (input.thread.executionTarget === "cloud") {
return undefined;
}
const fromSession = yield* resolveSessionRuntimeForThread(input.threadId);
const fromThread = resolveThreadWorkspaceCwd({
thread: input.thread,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
interactionMode: event.payload.interactionMode,
branch: event.payload.branch,
worktreePath: event.payload.worktreePath,
executionTarget: event.payload.executionTarget ?? null,
linkedPullRequest: null,
branchPullRequest: null,
latestTurnId: null,
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,11 @@ function mapLatestTurn(
};
}

// Only cloud threads carry the field; its absence means local.
function mapExecutionTarget(row: Schema.Schema.Type<typeof ProjectionThreadDbRowSchema>) {
return row.executionTarget === "cloud" ? { executionTarget: "cloud" as const } : {};
}

function mapTitleRegeneration(row: Schema.Schema.Type<typeof ProjectionThreadDbRowSchema>) {
return row.titleRegenerationRequestId != null && row.titleRegenerationStartedAt != null
? {
Expand Down Expand Up @@ -574,6 +579,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
interaction_mode AS "interactionMode",
branch,
worktree_path AS "worktreePath",
execution_target AS "executionTarget",
linked_pull_request_json AS "linkedPullRequest",
branch_pull_request_json AS "branchPullRequest",
latest_turn_id AS "latestTurnId",
Expand Down Expand Up @@ -622,6 +628,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
interaction_mode AS "interactionMode",
branch,
worktree_path AS "worktreePath",
execution_target AS "executionTarget",
linked_pull_request_json AS "linkedPullRequest",
branch_pull_request_json AS "branchPullRequest",
latest_turn_id AS "latestTurnId",
Expand Down Expand Up @@ -697,6 +704,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
interaction_mode AS "interactionMode",
branch,
worktree_path AS "worktreePath",
execution_target AS "executionTarget",
linked_pull_request_json AS "linkedPullRequest",
branch_pull_request_json AS "branchPullRequest",
latest_turn_id AS "latestTurnId",
Expand Down Expand Up @@ -1301,6 +1309,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
interaction_mode AS "interactionMode",
branch,
worktree_path AS "worktreePath",
execution_target AS "executionTarget",
linked_pull_request_json AS "linkedPullRequest",
branch_pull_request_json AS "branchPullRequest",
latest_turn_id AS "latestTurnId",
Expand Down Expand Up @@ -2373,6 +2382,7 @@ pending_approval_requests AS (
interactionMode: row.interactionMode,
branch: row.branch,
worktreePath: row.worktreePath,
...mapExecutionTarget(row),
...mapThreadPullRequests(
pullRequestsByThread.get(row.threadId) ?? [],
row.projectId,
Expand Down Expand Up @@ -2619,6 +2629,7 @@ pending_approval_requests AS (
interactionMode: row.interactionMode,
branch: row.branch,
worktreePath: row.worktreePath,
...mapExecutionTarget(row),
...mapThreadPullRequests(
pullRequestsByThread.get(row.threadId) ?? [],
row.projectId,
Expand Down Expand Up @@ -2780,6 +2791,7 @@ pending_approval_requests AS (
interactionMode: row.interactionMode,
branch: row.branch,
worktreePath: row.worktreePath,
...mapExecutionTarget(row),
branchPullRequest: row.branchPullRequest,
...mapThreadPullRequests(
pullRequestsByThread.get(row.threadId) ?? [],
Expand Down Expand Up @@ -2966,6 +2978,7 @@ pending_approval_requests AS (
interactionMode: row.interactionMode,
branch: row.branch,
worktreePath: row.worktreePath,
...mapExecutionTarget(row),
branchPullRequest: row.branchPullRequest,
...mapThreadPullRequests(
pullRequestsByThread.get(row.threadId) ?? [],
Expand Down Expand Up @@ -3312,6 +3325,7 @@ pending_approval_requests AS (
interactionMode: threadRow.value.interactionMode,
branch: threadRow.value.branch,
worktreePath: threadRow.value.worktreePath,
...mapExecutionTarget(threadRow.value),
...mapThreadPullRequests(
pullRequestRows.map(mapPullRequestRow),
threadRow.value.projectId,
Expand Down Expand Up @@ -3614,6 +3628,7 @@ pending_approval_requests AS (
interactionMode: threadRow.value.interactionMode,
branch: threadRow.value.branch,
worktreePath: threadRow.value.worktreePath,
...mapExecutionTarget(threadRow.value),
...mapThreadPullRequests(
pullRequestRows.map(mapPullRequestRow),
threadRow.value.projectId,
Expand Down
Loading
Loading