From 4c5d5210454e746619af7f2ca41f7c5a511f393e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 27 Sep 2026 00:44:09 +0000 Subject: [PATCH 1/3] fix(mobile): persist the new-session prompt and retry a failed repo prefill --- .../components/agents/new-session-screen-body.tsx | 15 +++++++++++++++ apps/mobile/src/lib/use-new-session-repos.ts | 5 ++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/components/agents/new-session-screen-body.tsx b/apps/mobile/src/components/agents/new-session-screen-body.tsx index 01f157c03c..555383151d 100644 --- a/apps/mobile/src/components/agents/new-session-screen-body.tsx +++ b/apps/mobile/src/components/agents/new-session-screen-body.tsx @@ -563,6 +563,21 @@ 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. + useEffect(() => { + if (isCloneEntry || !userId) { + return; + } + const text = promptRef.current; + if (text.trim().length > 0) { + 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 diff --git a/apps/mobile/src/lib/use-new-session-repos.ts b/apps/mobile/src/lib/use-new-session-repos.ts index 81dfb9bc05..0dd6fe165b 100644 --- a/apps/mobile/src/lib/use-new-session-repos.ts +++ b/apps/mobile/src/lib/use-new-session-repos.ts @@ -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; @@ -365,6 +365,9 @@ export function useNewSessionRepos({ !githubQuery.isLoading && !gitlabQuery.isLoading && !bitbucketQuery.isLoading && + !githubQuery.isError && + !gitlabQuery.isError && + !bitbucketQuery.isError && repositories.length > 0; return { From 4f669045d329357a6f33bbe75b90020241fa2d10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 27 Sep 2026 00:56:58 +0000 Subject: [PATCH 2/3] fix(mobile): keep a pre-identity prompt in sync across the draft reset The seed effect cleared the route-owned prompt when the draft load re-settled on an identity change, even though a `settled` seed does not remount the uncontrolled input. That desynced the visible text from `promptRef`/hasPrompt: the prompt typed before the identity arrived was not persisted and Start stayed disabled. Preserve a non-empty route-owned prompt unless the input remounts (a `restore` seed). --- .../agents/new-session-screen-body.tsx | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/components/agents/new-session-screen-body.tsx b/apps/mobile/src/components/agents/new-session-screen-body.tsx index 555383151d..17f7249ab3 100644 --- a/apps/mobile/src/components/agents/new-session-screen-body.tsx +++ b/apps/mobile/src/components/agents/new-session-screen-body.tsx @@ -465,14 +465,23 @@ export function NewSessionScreenBody() { const [promptSeed, setPromptSeed] = useState<'pending' | 'settled' | 'restore'>('pending'); 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'); + if (promptSeed === 'pending') { + return; + } + // The identity or entity changed. A `restore` seed is a stored draft the + // input remounts away, so clear the route-owned prompt with it. A + // `settled` seed that still holds text is the user's own typing: the + // remount key stays `empty`, so the input keeps showing that text and + // clearing the route-owned prompt (which Start and the create body read) + // would desync it from the visible field. Preserve it instead — the + // persist effect below then saves the pre-identity prompt under the + // settled identity. + if (promptSeed === 'settled' && promptRef.current !== '') { + return; } + promptRef.current = ''; + setHasPrompt(false); + setPromptSeed('pending'); return; } if (promptSeed !== 'pending') { From 434c9a54a140a496c6361b5ce1b4a967bc0facb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 28 Sep 2026 18:49:17 +0000 Subject: [PATCH 3/3] fix(mobile): keep a pre-identity prompt clear over a stored draft The seed effect only preserved a pre-identity edit when the route-owned prompt was non-empty, so clearing the field to empty before the identity resolved let the identity-bound draft load remount the older saved draft over the clear. Track any pre-identity edit, including a clear, and settle the seed in place instead of restoring; persist the explicit empty edit on identity arrival so the stored draft cannot resurface. The decision is extracted to a pure helper with a regression test for typing, clearing, then resolving the identity with an existing saved draft. --- .../agents/new-session-prompt-seed.test.ts | 94 +++++++++++++++++++ .../agents/new-session-prompt-seed.ts | 70 ++++++++++++++ .../agents/new-session-screen-body.tsx | 58 ++++++------ 3 files changed, 195 insertions(+), 27 deletions(-) create mode 100644 apps/mobile/src/components/agents/new-session-prompt-seed.test.ts create mode 100644 apps/mobile/src/components/agents/new-session-prompt-seed.ts diff --git a/apps/mobile/src/components/agents/new-session-prompt-seed.test.ts b/apps/mobile/src/components/agents/new-session-prompt-seed.test.ts new file mode 100644 index 0000000000..b6e88960f3 --- /dev/null +++ b/apps/mobile/src/components/agents/new-session-prompt-seed.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; + +import { + type NewSessionPromptSeedInput, + resolveNewSessionPromptSeedDecision, +} from './new-session-prompt-seed'; + +function decide(overrides: Partial = {}) { + 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' }); + }); +}); diff --git a/apps/mobile/src/components/agents/new-session-prompt-seed.ts b/apps/mobile/src/components/agents/new-session-prompt-seed.ts new file mode 100644 index 0000000000..5dc95e8adb --- /dev/null +++ b/apps/mobile/src/components/agents/new-session-prompt-seed.ts @@ -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', + }; +} diff --git a/apps/mobile/src/components/agents/new-session-screen-body.tsx b/apps/mobile/src/components/agents/new-session-screen-body.tsx index 17f7249ab3..4bdf3aa295 100644 --- a/apps/mobile/src/components/agents/new-session-screen-body.tsx +++ b/apps/mobile/src/components/agents/new-session-screen-body.tsx @@ -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, @@ -462,43 +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('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') { - return; - } - // The identity or entity changed. A `restore` seed is a stored draft the - // input remounts away, so clear the route-owned prompt with it. A - // `settled` seed that still holds text is the user's own typing: the - // remount key stays `empty`, so the input keeps showing that text and - // clearing the route-owned prompt (which Start and the create body read) - // would desync it from the visible field. Preserve it instead — the - // persist effect below then saves the pre-identity prompt under the - // settled identity. - if (promptSeed === 'settled' && promptRef.current !== '') { - return; - } + const decision = resolveNewSessionPromptSeedDecision({ + draftSettled: draftState.settled, + seed: promptSeed, + prompt: promptRef.current, + initialPrompt, + sharePrefillText, + userEdited: promptEditedRef.current, + }); + if (decision.type === 'keep') { + return; + } + if (decision.type === 'reset') { promptRef.current = ''; setHasPrompt(false); setPromptSeed('pending'); return; } - if (promptSeed !== '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({ @@ -564,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)); @@ -576,13 +578,15 @@ export function NewSessionScreenBody() { // 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. + // 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(() => { if (isCloneEntry || !userId) { return; } const text = promptRef.current; - if (text.trim().length > 0) { + if (text.trim().length > 0 || promptEditedRef.current) { saveDraft(userId, NEW_SESSION_DRAFT_KEY, text); } }, [userId, isCloneEntry, promptRef]);