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
94 changes: 94 additions & 0 deletions apps/mobile/src/components/agents/new-session-prompt-seed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest';

import {
type NewSessionPromptSeedInput,
resolveNewSessionPromptSeedDecision,
} from './new-session-prompt-seed';

function decide(overrides: Partial<NewSessionPromptSeedInput> = {}) {
return resolveNewSessionPromptSeedDecision({
draftSettled: true,
seed: 'pending',
prompt: '',
initialPrompt: undefined,
sharePrefillText: null,
userEdited: false,
...overrides,
});
}

describe('resolveNewSessionPromptSeedDecision', () => {
it('keeps waiting while the draft load has not settled', () => {
expect(decide({ draftSettled: false })).toEqual({ type: 'keep' });
});

it('settles in place instead of restoring a stored draft when the user cleared a pre-identity prompt', () => {
// Type, clear, then the identity resolves with an older saved draft: the
// explicit clear must win over the stored draft.
expect(
decide({
prompt: '',
initialPrompt: 'older saved draft',
userEdited: true,
})
).toEqual({ type: 'settle' });
});

it('restores the stored draft when the user never edited the prompt', () => {
expect(
decide({
prompt: '',
initialPrompt: 'older saved draft',
})
).toEqual({
type: 'restore',
prompt: 'older saved draft',
hasPrompt: true,
seed: 'restore',
});
});

it('settles when the user typed before the identity resolved', () => {
expect(
decide({
prompt: 'typed before identity',
initialPrompt: 'older saved draft',
userEdited: true,
})
).toEqual({ type: 'settle' });
});

it('does not remount for a share prefill that already seeded the first render', () => {
expect(
decide({
initialPrompt: 'shared text',
sharePrefillText: 'shared text',
})
).toEqual({
type: 'restore',
prompt: 'shared text',
hasPrompt: true,
seed: 'settled',
});
});

it('settles when there is no stored draft to restore', () => {
expect(decide({ initialPrompt: undefined })).toEqual({ type: 'settle' });
});

it('preserves a settled non-empty prompt across an identity change', () => {
expect(decide({ draftSettled: false, seed: 'settled', prompt: 'typed' })).toEqual({
type: 'keep',
});
});

it('resets a restored draft when the identity changes', () => {
expect(decide({ draftSettled: false, seed: 'restore', prompt: 'stored draft' })).toEqual({
type: 'reset',
});
});

it('leaves an already settled seed untouched', () => {
expect(decide({ seed: 'settled' })).toEqual({ type: 'keep' });
});
});
70 changes: 70 additions & 0 deletions apps/mobile/src/components/agents/new-session-prompt-seed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* The uncontrolled prompt input's remount seed. `pending` is the initial and
* re-armed state while the identity-bound draft loads; `settled` means the
* input already shows the right text; `restore` is the only value that changes
* the form key and remounts the input onto a stored draft.
*/
export type NewSessionPromptSeed = 'pending' | 'settled' | 'restore';

/** What the seed effect should do for one render. */
export type NewSessionPromptSeedDecision =
| { type: 'keep' }
| { type: 'reset' }
| { type: 'settle' }
| { type: 'restore'; prompt: string; hasPrompt: boolean; seed: NewSessionPromptSeed };

export type NewSessionPromptSeedInput = {
draftSettled: boolean;
seed: NewSessionPromptSeed;
prompt: string;
initialPrompt: string | undefined;
sharePrefillText: string | null;
/** Whether the user edited the prompt (including clearing it) before the load settled. */
userEdited: boolean;
};

/**
* Resolves the seed effect's action from the draft-load and prompt state.
*
* The prompt input is uncontrolled: only a `restore` decision remounts it onto
* a stored draft. A prompt the user edited before the identity-bound draft
* load settled — including one the user cleared back to empty — is already the
* input's visible text, so it must settle in place instead of being treated as
* an untouched empty ref and overwritten by an older saved draft.
*/
export function resolveNewSessionPromptSeedDecision(
input: NewSessionPromptSeedInput
): NewSessionPromptSeedDecision {
const { draftSettled, seed, prompt, initialPrompt, sharePrefillText, userEdited } = input;
if (!draftSettled) {
if (seed === 'pending') {
return { type: 'keep' };
}
// The identity or entity changed. A `restore` seed is a stored draft the
// input remounts away, so reset the route-owned prompt with it. A
// `settled` seed keeps its non-empty text: the remount key stays `empty`,
// so resetting the route-owned prompt would desync it from the visible
// field.
if (seed === 'settled' && prompt !== '') {
return { type: 'keep' };
}
return { type: 'reset' };
}
if (seed !== 'pending') {
return { type: 'keep' };
}
// An explicit pre-identity edit beats a stored draft, even when the edit is
// an intentional clear to empty: the input already shows it, and a restore
// would remount the older text over it.
if (prompt !== '' || userEdited || !initialPrompt) {
return { type: 'settle' };
}
return {
type: 'restore',
prompt: initialPrompt,
hasPrompt: initialPrompt.trim().length > 0,
// A share prefill already seeded the first render; only a stored draft
// needs the remount.
seed: initialPrompt === sharePrefillText ? 'settled' : 'restore',
};
}
62 changes: 45 additions & 17 deletions apps/mobile/src/components/agents/new-session-screen-body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import { type KiloSessionId, type RemoteModelOverride } from '@kilocode/cloud-ag
import { NewSessionConfigureForm } from '@/components/agents/new-session-configure-form';
import { resetSelectedBranchOverrides } from '@/components/agents/new-session-repository-state';
import { resolveNewSessionModelView } from '@/components/agents/new-session-model-view';
import {
type NewSessionPromptSeed,
resolveNewSessionPromptSeedDecision,
} from '@/components/agents/new-session-prompt-seed';
import {
type CloudCreateFailure,
useNewSessionCreator,
Expand Down Expand Up @@ -462,34 +466,40 @@ export function NewSessionScreenBody() {
// the uncontrolled input, which only a remount can do. Only `restore`
// changes the form key, so the settled path never remounts and never
// destroys typing.
const [promptSeed, setPromptSeed] = useState<'pending' | 'settled' | 'restore'>('pending');
const [promptSeed, setPromptSeed] = useState<NewSessionPromptSeed>('pending');
// A prompt the user edited before the identity (and its draft load) resolved,
// including one they cleared back to empty. The input is uncontrolled, so
// this edit is already its visible text; the seed decision below and the
// identity-arrival persist both honor it over any stored draft.
const promptEditedRef = useRef(false);
useEffect(() => {
if (!draftState.settled) {
if (promptSeed !== 'pending') {
// The identity or entity changed, so the input remounts empty: clear
// the route-owned prompt state with it, or Start would submit text the
// user can no longer see.
promptRef.current = '';
setHasPrompt(false);
setPromptSeed('pending');
}
const decision = resolveNewSessionPromptSeedDecision({
draftSettled: draftState.settled,
seed: promptSeed,
prompt: promptRef.current,
initialPrompt,
sharePrefillText,
userEdited: promptEditedRef.current,
});
if (decision.type === 'keep') {
return;
}
if (promptSeed !== 'pending') {
if (decision.type === 'reset') {
promptRef.current = '';
setHasPrompt(false);
setPromptSeed('pending');
return;
}
if (promptRef.current !== '' || !initialPrompt) {
if (decision.type === 'settle') {
setPromptSeed('settled');
return;
}
// `hasPrompt` is exactly what `resolveNewSessionPromptForCreate` re-derives
// on submit, so seeding both from one value keeps the Start gate and the
// submitted text in agreement.
promptRef.current = initialPrompt;
setHasPrompt(initialPrompt.trim().length > 0);
// A share prefill already seeded the first render; only a stored draft
// needs the remount.
setPromptSeed(initialPrompt === sharePrefillText ? 'settled' : 'restore');
promptRef.current = decision.prompt;
setHasPrompt(decision.hasPrompt);
setPromptSeed(decision.seed);
}, [draftState.settled, initialPrompt, promptRef, promptSeed, sharePrefillText]);

const { remoteSpawn, handleRunOnInstanceChange } = useNewSessionShareRemote({
Expand Down Expand Up @@ -555,6 +565,7 @@ export function NewSessionScreenBody() {
);

function handlePromptChange(text: string) {
promptEditedRef.current = true;
promptRef.current = text;
const nextHasPrompt = text.trim().length > 0;
setHasPrompt(current => (current === nextHasPrompt ? current : nextHasPrompt));
Expand All @@ -563,6 +574,23 @@ export function NewSessionScreenBody() {
}
}

// The composer mounts before `user.getMe` resolves, so a prompt typed in that
// window has no account to write under and `handlePromptChange` skips the
// save. Persist the current prompt once the identity arrives (and if it
// changes), so text typed before the query settled survives a background or
// kill rather than waiting for the next keystroke. An explicit edit is
// persisted even when it left the prompt empty, so a pre-identity clear
// replaces the stored draft instead of letting it resurface later.
useEffect(() => {
Comment thread
iscekic marked this conversation as resolved.
if (isCloneEntry || !userId) {
return;
}
const text = promptRef.current;
if (text.trim().length > 0 || promptEditedRef.current) {
saveDraft(userId, NEW_SESSION_DRAFT_KEY, text);
}
}, [userId, isCloneEntry, promptRef]);

// Discard confirm: leaving with a non-empty prompt or unsent uploads asks
// first. Discard clears the stored draft and the route-owned prompt ref, then
// releases admitted uploads before the captured navigation action is
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/lib/use-new-session-repos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ type UseNewSessionReposResult = {
recents: NewSessionRepository[];
groups: RepositoryGroup[];
isRetrying: boolean;
/** True once every provider query has settled and at least one repo is visible. */
/** True once every provider query has settled without error and at least one repo is visible. */
reposSettled: boolean;
openIntegration: (platform: RepositoryPlatform) => void;
refreshReposForceFresh: () => Promise<void>;
Expand Down Expand Up @@ -365,6 +365,9 @@ export function useNewSessionRepos({
!githubQuery.isLoading &&
!gitlabQuery.isLoading &&
!bitbucketQuery.isLoading &&
!githubQuery.isError &&
!gitlabQuery.isError &&
!bitbucketQuery.isError &&
repositories.length > 0;

return {
Expand Down
Loading