Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
51e6c39
feat(mobile): add new session prefill helpers
iscekic Aug 1, 2026
4d5f18c
feat(mobile): prefill continued sessions
iscekic Aug 1, 2026
2de8142
feat(mobile): add continue session action
iscekic Aug 1, 2026
2ccd38b
fix(mobile): satisfy prefill lint rules
iscekic Aug 1, 2026
42456ba
test(mobile): type prefill cases
iscekic Aug 1, 2026
94eb664
fix(mobile): resolve prefill test lint
iscekic Aug 1, 2026
05af6e2
fix(mobile): restrict prefill repositories to GitHub
iscekic Aug 1, 2026
0001f68
refactor(mobile-e2e): simplified runbook, scripted stub setup, load-t…
iscekic Aug 1, 2026
50e208c
fix(mobile-e2e): address review findings on retry and stub rollback
iscekic Aug 2, 2026
d5778b7
fix(mobile-e2e): close concurrency races found in adversarial review
iscekic Aug 2, 2026
50be1ed
fix(mobile-e2e): adversarial-review round 2
iscekic Aug 2, 2026
69e21e7
fix(mobile-e2e): seed verifies the endpoint's success flag
iscekic Aug 2, 2026
7146391
fix(mobile-e2e): server stop verifies death before dropping state
iscekic Aug 2, 2026
5c6f274
fix(mobile-e2e): address stub-claim, re-seed, and lsof review findings
iscekic Aug 2, 2026
e26d0d0
fix(mobile-e2e): tighten adoption, signal exits, and legacy-seed hand…
iscekic Aug 2, 2026
48f61f5
fix(mobile-e2e): name both sources of an existing token row
iscekic Aug 2, 2026
e5a0494
fix(mobile-e2e): stub row verification, self-cleaning start, lsof fal…
iscekic Aug 2, 2026
c7835ac
fix(mobile-e2e): honest row probe, race-safe rollback, blind-lsof reuse
iscekic Aug 2, 2026
449c242
fix(mobile-e2e): close the last rollback and adoption windows
iscekic Aug 2, 2026
fec3e1f
fix(mobile-e2e): serialize the stub per worktree instead of guarding …
iscekic Aug 2, 2026
973fdf5
fix(mobile-e2e): row-probe failures skip the missing-user hint
iscekic Aug 2, 2026
4f759f0
fix(mobile-e2e): bound the lock's hold time and the survivor edge
iscekic Aug 2, 2026
616c33b
feat(mobile-e2e): copy a real GitHub integration onto an e2e account
iscekic Aug 2, 2026
1f1eaed
fix(mobile-e2e): copies never donate or collide with the stub
iscekic Aug 2, 2026
1a34adb
docs(mobile-e2e): STE pass over the runbook additions
iscekic Aug 2, 2026
ffd814c
fix(mobile-e2e): copies validate the donor live and can never refresh
iscekic Aug 2, 2026
4ed26ca
fix(mobile-e2e): guard the integration copy's blast radius
iscekic Aug 2, 2026
c398e75
fix(mobile-e2e): env failures in the copy tool name their own fix
iscekic Aug 2, 2026
66b7904
fix(mobile-e2e): make the copy's guards atomic and expiry-safe
iscekic Aug 2, 2026
36f3437
fix(mobile-e2e): state the copy's real lifetime and align the lock bu…
iscekic Aug 2, 2026
f752367
fix(mobile-e2e): re-assert the usable window after the live probes
iscekic Aug 2, 2026
df4aac2
docs(mobile-e2e): bound big outputs, never doctor --json
iscekic Aug 2, 2026
96200a3
docs(mobile-e2e): doctor is always a full JSON dump; never tail JSON
iscekic Aug 2, 2026
8f597c7
Merge remote-tracking branch 'origin/mobile-e2e-ste' into session-for…
iscekic Aug 2, 2026
8f432d4
chore: retrigger review
iscekic Aug 2, 2026
3e1548d
Merge origin/main and drop the harness files it now owns
iscekic Aug 3, 2026
31563e3
Merge origin/main and rebuild prefill on the new-session refactor
iscekic Aug 3, 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
329 changes: 11 additions & 318 deletions apps/mobile/src/app/(app)/agent-chat/new.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,12 @@
import { type Dispatch, type SetStateAction, useCallback, useMemo, useRef, useState } from 'react';
import { View } from 'react-native';
import { useRef, useState } from 'react';
import { useLocalSearchParams } from 'expo-router';
import { useActionSheet } from '@expo/react-native-action-sheet';
import { useQuery } from '@tanstack/react-query';
import * as WebBrowser from 'expo-web-browser';
import { toast } from 'sonner-native';

import { NewSessionCloudForm } from '@/components/agents/new-session-cloud-form';
import { RemoteSpawnComposer } from '@/components/agents/remote-spawn-composer';
import { useNewSessionCreator } from '@/components/agents/use-new-session-creator';
import { RemoteSpawnInheritanceProvider } from '@/components/agents/use-remote-spawn-dispatch';
import { pickAgentAttachments } from '@/components/agents/attachment-picker';
import { type AgentMode } from '@/components/agents/mode-selector';
import { ScreenHeader } from '@/components/screen-header';
import {
getGitHubIntegrationUrl,
shouldShowGitHubIntegrationPrompt,
} from '@/lib/agent-github-integration';
import { AGENT_ATTACHMENT_MAX_FILES } from '@/lib/agent-attachments/constants';
import { useAgentAttachmentUpload } from '@/lib/agent-attachments/use-agent-attachment-upload';
import { WEB_BASE_URL } from '@/lib/config';
import { RemoteSpawnInheritanceProvider } from '@/components/agents/use-remote-spawn-dispatch';
import { resolvePrefillModel } from '@/components/agents/new-session-prefill';
import { useNewSessionPrefill } from '@/components/agents/use-new-session-prefill';
import { NewSessionScreenBody } from '@/components/agents/new-session-screen-body';
import { useAvailableModels } from '@/lib/hooks/use-available-models';
import { useAutoSelectModel } from '@/lib/hooks/use-auto-select-model';
import { useModelPreferences } from '@/lib/hooks/use-model-preferences';
import { usePersistedAgentModel } from '@/lib/hooks/use-persisted-agent-model';
import { isRepositorySectionVisible } from '@/lib/is-repository-section-visible';
import { resolveNewSessionSubmitDisabled } from '@/lib/new-session-submit';
import { type InstancePickerInstance } from '@/lib/picker-bridge';
import { shouldShowRunOnSelector } from '@/lib/should-show-run-on-selector';
import { useNewSessionShareRemote } from '@/lib/use-new-session-share-remote';
import { useTRPC } from '@/lib/trpc';
import { settleVoiceInputBeforeSubmit } from '@/lib/voice-input/voice-input-submit';

/**
* Outer shell owns picker state and the inheritance Provider so
Expand All @@ -43,7 +19,8 @@ import { settleVoiceInputBeforeSubmit } from '@/lib/voice-input/voice-input-subm
* Doing it in the body after the M1 split was a cross-component setState.
*/
export default function NewSessionScreen() {
const [mode, setMode] = useState<AgentMode>('code');
const prefill = useNewSessionPrefill();
const [mode, setMode] = useState<AgentMode>(prefill.mode);
const [model, setModel] = useState('');
const [variant, setVariant] = useState('');
const { organizationId } = useLocalSearchParams<{
Expand All @@ -54,10 +31,11 @@ export default function NewSessionScreen() {
const { models } = useAvailableModels(organizationId);
const autoSelected = useAutoSelectModel(models, organizationId);
const hasAppliedAutoSelection = useRef(false);
if (!hasAppliedAutoSelection.current && autoSelected.model && !model) {
const initialSelection = resolvePrefillModel(models, prefill) ?? autoSelected;
if (!hasAppliedAutoSelection.current && initialSelection.model && !model) {
hasAppliedAutoSelection.current = true;
setModel(autoSelected.model);
setVariant(autoSelected.variant);
setModel(initialSelection.model);
setVariant(initialSelection.variant);
}

return (
Expand All @@ -73,288 +51,3 @@ export default function NewSessionScreen() {
</RemoteSpawnInheritanceProvider>
);
}

type NewSessionScreenBodyProps = {
mode: AgentMode;
setMode: Dispatch<SetStateAction<AgentMode>>;
model: string;
setModel: Dispatch<SetStateAction<string>>;
variant: string;
setVariant: Dispatch<SetStateAction<string>>;
};

function NewSessionScreenBody({
mode,
setMode,
model,
setModel,
variant,
setVariant,
}: NewSessionScreenBodyProps) {
const { showActionSheetWithOptions } = useActionSheet();
const { organizationId, shareId: shareIdParam } = useLocalSearchParams<{
organizationId?: string;
shareId?: string;
}>();
// Param can be string | string[] depending on how the route was opened.
const shareId: string | undefined = Array.isArray(shareIdParam) ? shareIdParam[0] : shareIdParam;

// ── Selectors state ──────────────────────────────────────────────
const [selectedRepo, setSelectedRepo] = useState('');
// `null` = default Cloud Agent target (the existing path). Any
// non-null value is a live `kilo remote` instance the user picked.
// C3b switches the JSX to a reduced composer when this is non-null
// and routes the submit through `useRemoteSpawnDispatch` instead of
// the cloud-agent `submitCreate` flow.
const [runOnInstance, setRunOnInstance] = useState<InstancePickerInstance | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [hasPrompt, setHasPrompt] = useState(false);
const submissionLockRef = useRef(false);
const voiceInputSettlerRef = useRef<(() => Promise<boolean>) | null>(null);

// Org contexts support CLI instances (org attribution travels with create).
const showRunOnSelector = shouldShowRunOnSelector(organizationId);

// ── Models ───────────────────────────────────────────────────────
const {
models,
isLoading: isLoadingModels,
isError: isModelsError,
refetch: refetchModels,
} = useAvailableModels(organizationId);
const { setLastSelected: persistServerLastSelected } = useModelPreferences(organizationId);
const { saveModel } = usePersistedAgentModel();
const attachments = useAgentAttachmentUpload({ organizationId });

// ── Repositories ─────────────────────────────────────────────────
const trpc = useTRPC();
const {
data: repoData,
isLoading: isLoadingRepos,
isError: isReposError,
isRefetching: isRefetchingRepos,
refetch: refetchRepos,
} = useQuery(
organizationId
? trpc.organizations.cloudAgentNext.listGitHubRepositories.queryOptions({
organizationId,
forceRefresh: false,
})
: trpc.cloudAgentNext.listGitHubRepositories.queryOptions({
forceRefresh: false,
})
);

const showGitHubIntegrationPrompt = shouldShowGitHubIntegrationPrompt({
isLoadingRepos,
integrationInstalled: repoData?.integrationInstalled,
repositoryCount: repoData?.repositories.length,
});

const repositories = useMemo(() => {
if (!repoData?.repositories) {
return [];
}
return (repoData.repositories as { fullName: string; private: boolean }[]).map(r => ({
fullName: r.fullName,
isPrivate: r.private,
}));
}, [repoData]);

// "Run on" instance list. Fetched at the screen level (not inside the
// picker) so the selector's value label and the picker's row list stay
// in sync without round-tripping through the bridge. The picker ALSO
// re-queries on focus + polls (per the spec), so this is a soft
// pre-population, not the source of truth. C3b also reuses the
// `refetch` for the retryable-spawn-failure recovery path.
const {
data: instancesData,
isLoading: isLoadingInstances,
refetch: refetchInstances,
} = useQuery({
...trpc.activeSessions.listInstances.queryOptions(undefined, {
refetchOnWindowFocus: true,
staleTime: 5000,
}),
enabled: showRunOnSelector,
});
const instanceList: InstancePickerInstance[] = useMemo(
() => instancesData?.instances ?? [],
[instancesData]
);

// ── Session creator ──────────────────────────────────────────────
const { createSessionFromDraft, promptRef } = useNewSessionCreator({
attachments,
mode,
model,
organizationId,
selectedRepo,
setIsCreating,
variant,
});

// Share latch + remote spawn + share-aware Run-on (F1/F2). Runs under
// RemoteSpawnInheritanceProvider so mode/model/variant reach the wire.
const { remoteSpawn, handleRunOnInstanceChange } = useNewSessionShareRemote({
shareId,
organizationId,
runOnInstance,
setRunOnInstance,
refetchInstances,
instanceList,
});

// ── Handlers ─────────────────────────────────────────────────────
const handleModelSelect = useCallback(
(modelId: string, newVariant: string) => {
setModel(modelId);
setVariant(newVariant);
saveModel(organizationId, { model: modelId, variant: newVariant });
persistServerLastSelected({ model: modelId, ...(newVariant ? { variant: newVariant } : {}) });
},
[organizationId, saveModel, persistServerLastSelected, setModel, setVariant]
);

const handleOpenGitHubIntegration = useCallback(async () => {
try {
await WebBrowser.openAuthSessionAsync(getGitHubIntegrationUrl(WEB_BASE_URL, organizationId));
await refetchRepos();
} catch {
toast.error('Could not open GitHub setup. Please try again.');
}
}, [organizationId, refetchRepos]);

function handlePromptChange(text: string) {
promptRef.current = text;
const nextHasPrompt = text.trim().length > 0;
setHasPrompt(current => (current === nextHasPrompt ? current : nextHasPrompt));
}

const submitCreate = useCallback(async () => {
await settleVoiceInputBeforeSubmit({
lock: submissionLockRef,
onPendingChange: setIsSubmitting,
settleVoiceInput: async () => {
const settleVoiceInput = voiceInputSettlerRef.current;
if (settleVoiceInput === null) {
return true;
}
const settled = await settleVoiceInput();
return settled;
},
submit: createSessionFromDraft,
});
}, [createSessionFromDraft]);

const { addCandidates } = attachments;
const handleAddAttachment = useCallback(async () => {
// Fire-and-forget: the upload hook owns its own progress + error toasts,
// and `canCreate` (computed from `attachments.isUploading` /
// `attachments.hasFailedAttachments`) gates the start-session button.
void addCandidates(await pickAgentAttachments(showActionSheetWithOptions));
}, [addCandidates, showActionSheetWithOptions]);

// Cloud-Agent vs. remote-instance submit safety.
//
// - Cloud Agent (`runOnInstance === null`): `isStartDisabled` is the
// full pre-C3a canCreate expression, byte-identical to today's
// contract — the cloud-agent submit path runs through
// `handleStartSession` -> `submitCreate` -> `createSessionFromDraft`.
// No change to that branch.
// - Remote target (`runOnInstance !== null`): the start button is
// gated only by `isSpawningRemote` so the user can re-press after
// a non-retryable / retryable failure.
const isRemoteTargetSelected = runOnInstance !== null;

const isStartDisabled = isRemoteTargetSelected
? remoteSpawn.isSpawningRemote
: resolveNewSessionSubmitDisabled({
attachmentsHasFailed: attachments.hasFailedAttachments,
attachmentsIsUploading: attachments.isUploading,
hasPrompt,
isCreating,
isRemoteTargetSelected,
isSubmitting,
model,
selectedRepo,
});

const handleStartSession = useCallback(() => {
if (runOnInstance !== null) {
remoteSpawn.onStart();
return;
}
void submitCreate();
}, [remoteSpawn, runOnInstance, submitCreate]);

return (
<View className="flex-1 bg-background">
<ScreenHeader title="New session" />

{isRepositorySectionVisible(runOnInstance) ? (
<NewSessionCloudForm
attachments={attachments.attachments}
attachmentMax={AGENT_ATTACHMENT_MAX_FILES}
isCreating={isCreating}
isModelsError={isModelsError}
isLoadingModels={isLoadingModels}
mode={mode}
model={model}
variant={variant}
modelOptions={models}
onChangeText={handlePromptChange}
onModeChange={setMode}
onModelSelect={handleModelSelect}
onAddAttachment={() => {
void handleAddAttachment();
}}
onRemoveAttachment={id => {
attachments.removeAttachment(id);
}}
onRetryAttachment={id => {
attachments.retryAttachment(id);
}}
onRefetchModels={() => {
void refetchModels();
}}
onPrefillAttachments={addCandidates}
shareId={shareId}
voiceInputSettlerRef={voiceInputSettlerRef}
showRunOnSelector={showRunOnSelector}
runOnInstance={runOnInstance}
instanceList={instanceList}
isLoadingInstances={isLoadingInstances}
onChangeRunOnInstance={handleRunOnInstanceChange}
showInstanceDisconnectedNote={remoteSpawn.showInstanceDisconnectedNote}
isReposError={isReposError}
isLoadingRepos={isLoadingRepos}
isRefetchingRepos={isRefetchingRepos}
onChangeRepo={setSelectedRepo}
onOpenGitHubIntegration={() => {
void handleOpenGitHubIntegration();
}}
onRefetchRepos={() => {
void refetchRepos();
}}
repositories={repositories}
showGitHubIntegrationPrompt={showGitHubIntegrationPrompt}
selectedRepo={selectedRepo}
isStartDisabled={isStartDisabled}
onStartSession={handleStartSession}
/>
) : (
<RemoteSpawnComposer
runOnInstance={runOnInstance}
instanceList={instanceList}
isLoadingInstances={isLoadingInstances}
onChangeRunOnInstance={handleRunOnInstanceChange}
isSpawningRemote={remoteSpawn.isSpawningRemote}
isStartDisabled={isStartDisabled}
onStart={handleStartSession}
/>
)}
</View>
);
}
Loading