Repository navigation
feat(mobile): first-run hero and the provider key gate, like desktop - #6167
Conversation
…esktop A brand-new project has no provider key. On desktop the chat says so before the send: `useAgentModelKeyStatus().gateActive` raises `ConnectModelBanner` above the composer, disables the composer, and parks the first-run seed until a key lands. /m had none of it. The send went out, the runner answered 422 "no usable credential", and the user got a red "The agent run failed" bubble carrying a raw backend reason, with nothing to click. The vault lived only in Settings, which a new user has no reason to open. Mount the same gate in the mobile chat skin. The engine leaves this to the skin on purpose (`useAgentConversation` says so), and the hook, the vault atoms, and the key sheet were all already shared or already in this app. - `ConnectModelStrip` — the mobile skin of the desktop banner, entering and leaving through `RevealCollapse` like the mic notice already does. Its button opens THIS app's `ProviderKeySheet` scoped to the agent's own provider, rather than desktop's all-providers drawer plus a model repoint: one field, no config write, same end state. - `pendingTaskPolicy` — the parked-task rule, extracted pure and tested. A task typed on Home now waits while the gate is up and goes out on its own the moment a key is saved, which is what desktop's `useFirstRunSeed` does. The gate stays down while the vault query is unresolved (`gateActive` is false when `loading`), so a reload cannot flash a false "add a key" at a project that has one.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughSummary by CodeRabbit
WalkthroughThe mobile app now supports first-run agent creation, seeded session handoff, and model-key gating. Chat request preparation retries unavailable workflow requests within a deadline and uses shared asset exports. ChangesMobile agent onboarding and conversation flow
Bounded request preparation
Estimated code review effort: 4 (Complex) | ~45 minutes Merge Risk: 🟡 Moderate · up to The new mobile first-run and provider-key flow can still lose a submission while provider status is unresolved, and timed-out tasks can drop attachments or leave stale errors visible in later chats. These bounded correctness issues should be fixed or explicitly accepted before merging. Sequence Diagram(s)sequenceDiagram
participant User
participant FirstRunComposer
participant useNewAgentAction
participant LiveConversation
participant ProviderKeySheet
participant buildRequestWithinDeadline
participant buildAgentRequest
User->>FirstRunComposer: Submit prompt with optional attachments
FirstRunComposer->>useNewAgentAction: createFromPrompt(text, sessionId, parts)
useNewAgentAction->>LiveConversation: Navigate to selected route
LiveConversation->>LiveConversation: Check hydration and model-key gate
LiveConversation->>ProviderKeySheet: Open provider-key setup when blocked
ProviderKeySheet-->>LiveConversation: Model-key gate clears
LiveConversation->>buildRequestWithinDeadline: Prepare send request
buildRequestWithinDeadline->>buildAgentRequest: Build request
buildAgentRequest-->>buildRequestWithinDeadline: Return request or null
buildRequestWithinDeadline-->>LiveConversation: Return request after retry or bounded error
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Railway Preview Environment
|
… Home Desktop never shows a new user its Home. `OnboardingEntry` sees an empty agents list and redirects into the playground, which asks "What do you want to build?" with the composer ready. /m sent them to Home, which is the wrong screen for someone with nothing: it asks what you want to DO, shows an empty agents panel, and renders no composer at all — Home's composer runs a task with an agent that already exists, so with none it does not mount. One "New agent" button on an empty page was the entire first-run experience. Render a first-run surface in that frame instead, in place rather than behind a redirect. The redirect is what forces desktop's detection to be certain before it decides; a swap under the same URL corrects itself when the list lands, and keeps the nav drawer, which is how a keyless user still reaches Settings. - `onboarding/homeSurface` — the three-way rule, pure and tested. Keeps desktop's two real constraints: a failed fetch is never evidence of emptiness, and an empty list that is still resolving holds a skeleton rather than flashing Home. A cached non-empty list short-circuits the hold. - `onboarding/FirstRunScreen` — the desktop hero's own words, the composer, and the shared create button for templates. - `onboarding/FirstRunComposer` — `HomeTaskComposer` cannot serve this (it needs an agent to exist and disables itself without one), so this binds the same `ChatComposer` to the create path. Starters FILL the input rather than send it: a phone keyboard makes "describe an agent" expensive. - `useNewAgentAction.createFromPrompt` — the first submit runs the SAME shared mint+commit core the template and blank creates run, then stashes what was typed against the session it mints and hands off to the chat. `run` now reports whether the hand-off committed, because a caller cannot read `error` after an await: that flag belongs to the render it read it in. - `agents/agentHandoff` — the hand-off route, extracted pure and tested. The chain closes on the gate from the previous commit: first submit creates the agent, opens the chat, and on a keyless project the typed message parks while the strip asks for a key, then sends itself when the key lands. Trade-off against desktop: desktop mints an EPHEMERAL client-only agent and commits it on send, so abandoning the hero leaves nothing behind. Here the first submit creates the real agent, which is what "New agent" already does on this app.
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
web/mobile/src/features/onboarding/homeSurface.ts (1)
1-19: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winKeep in-code comments concise and limited to surprising constraints. Remove product-history and design-rationale narration across the listed files; retain only short notes for bugs, races, or ordering requirements.
Source: Coding guidelines
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository YAML (base), Organization UI (inherited)
Review profile: CHILL
Plan: Pro Plus
Run ID: 07b9c3bc-cb15-4d3a-bbd9-9c4d13c09933
📒 Files selected for processing (14)
web/mobile/src/features/agents/agentHandoff.tsweb/mobile/src/features/agents/useNewAgentAction.tsweb/mobile/src/features/chat/ConnectModelStrip.tsxweb/mobile/src/features/chat/LiveConversation.tsxweb/mobile/src/features/chat/pendingTaskPolicy.tsweb/mobile/src/features/home/HomeScreen.tsxweb/mobile/src/features/onboarding/FirstRunComposer.tsxweb/mobile/src/features/onboarding/FirstRunScreen.tsxweb/mobile/src/features/onboarding/copy.tsweb/mobile/src/features/onboarding/homeSurface.tsweb/mobile/src/features/onboarding/states/FirstRunStates.tsxweb/mobile/tests/unit/agentHandoff.test.tsweb/mobile/tests/unit/homeSurface.test.tsweb/mobile/tests/unit/pendingTaskPolicy.test.ts
Included review availability: Your plan provides up to 8 included reviews per hour; 6 remain after this review.
| sessionId, | ||
| sentFor: sentPendingTaskFor.current, | ||
| hydrating: isHydrating, | ||
| modelBlocked, |
There was a problem hiding this comment.
🤖 The AI agent says:
[P1] Wait for vault resolution before consuming the pending task
gateActive is intentionally false while modelKey.loading is true. On a cold first-run session, hydration can finish first, so this effect consumes and sends the pending task before the vault tells us the project is keyless. That first run fails with 422, and there is no task left to send after the user adds a key. Hold the pending task while modelKey.loading is true, while continuing to hide the connect-model strip until loading resolves.
There was a problem hiding this comment.
Fixed in e23df7b. The race is real: gateActive is false while the vault loads, so a cold first run where hydration settles first sent the parked message straight into a 422, and the slot was already consumed.
canSendPendingTask now takes modelKeyLoading and holds on it, and LiveConversation passes modelKey.loading. The strip is untouched, so it still keys off gateActive and stays hidden until loading resolves. Two tests in pendingTaskPolicy.test.ts cover the cold-start order.
One caveat to flag, because it is a real trade: loading is !Array.isArray(vaultQuery.data), and the vault query sets no retry override, so a vault fetch that keeps failing leaves loading true forever. The parked task then waits instead of failing with a 422. The composer stays usable, but the parked text is no longer visible to the user. That is a silent loss where before it was a loud error. If you want it bounded, the desktop has the pattern already (useFirstRunSeed waits at most 10s for the build-kit overlay, then sends anyway). Say the word and I will add the same bound here.
There was a problem hiding this comment.
Bound added in ed7a7e7: the parked task now waits at most 10 seconds for the vault, then releases, puts its text back in the composer, and shows "Couldn't check this project's provider keys — the message was not sent. Please try again." A hold on an ACTIVE gate stays unbounded, because that one is visible and the user releases it by saving a key. 7 unit tests on the bound.
|
|
||
| const seed = params?.seedMessage?.trim() | ||
| const sessionId = seed ? crypto.randomUUID() : null | ||
| const sessionId = seed ? (params?.sessionId ?? crypto.randomUUID()) : null |
There was a problem hiding this comment.
🤖 The AI agent says:
[P2] Treat attachment-only submits as seeded handoffs
ChatComposer intentionally calls onSubmit("") when attachments are ready but the text is empty. Here seed becomes falsy, so sessionId becomes null even when seedParts is non-empty. The parts are never stashed, navigation falls back to the agent overview, and the caller clears the attachments after a reported successful handoff. Treat non-empty seedParts as a seeded session and stash them even when the text is empty.
There was a problem hiding this comment.
Fixed in 059a1c1. Confirmed at the source: SendButton.tsx:46 calls onSubmit("") when the editor is empty and the staged files have settled, so an attachment-only first message left seed falsy, minted no session, stashed no parts, landed on the agent overview, and then had its attachments cleared as a success.
A create is now seeded by text OR parts. The decision moved into agentHandoff.ts as isSeededCreate({seed, partCount}), which keeps it beside the route it decides and makes it testable in the node suite. Three cases are covered in agentHandoff.test.ts. The stash carries text: "" with the parts, which the engine accepts: useAgentConversation.send only refuses when the text, the files and the parts are all empty.
| const parts = staged.length > 0 ? stagedFilesToParts(staged, sessionId) : undefined | ||
| // The outcome comes back as a value, not off `newAgent.error`: that flag belongs to THIS | ||
| // render, so reading it after the await would read the state from before the create. | ||
| const handedOff = await newAgent.createFromPrompt({text, sessionId, parts}) |
There was a problem hiding this comment.
🤖 The AI agent says:
[P2] Restore the first-run draft when creation fails
RichChatInput clears the editor after submit before this promise resolves. When agent creation or navigation returns false, this branch preserves the attachments but never restores text, so a transient failure loses the entire agent description. Restore the submitted markdown through inputRef when handedOff is false.
There was a problem hiding this comment.
Fixed in 69ef26b. When handedOff is false, the submitted markdown goes back into the editor through inputRef.current.setMarkdown(text).
One guard on top of that: it refills only an editor that is still empty (getMarkdown().trim()). The create is awaited, so the user can start typing a new idea while it runs, and an unconditional refill would overwrite what they just typed.
| * of them would shift into the other. | ||
| */ | ||
| export const FirstRunLoading = () => ( | ||
| <div className="flex flex-col gap-6 px-4 pb-6 lg:px-16 lg:pt-14"> |
There was a problem hiding this comment.
🤖 The AI agent says:
[P3] Match the loading skeleton to the first-run frame
On wide viewports this container spans the scaffold and uses gap-6, while FirstRunScreen uses pageContentWidthClass and gap-5. The crossfade therefore shifts horizontally and vertically. Give the loading state the same width cap, gutters, and primary spacing as the first-run frame so the state swap preserves identical geometry.
There was a problem hiding this comment.
Fixed in 69ef26b. The loading state now carries the hero's exact frame: ${pageContentWidthClass} flex flex-col gap-5 px-4 pb-6 lg:px-16 lg:pt-14.
I also matched the inner structure, not only the outer box. The two title lines moved into a gap-2 block, as in FirstRunScreen, so the subtitle skeleton sits where the subtitle lands instead of one gap lower. The width cap is shared with Home as well, so the loading-to-Home swap keeps the same gutters too.
The connect-model gate reports `gateActive: false` while the vault query is still in flight, on purpose — the banner must not flash on every reload. The pending-task effect read that same flag, so on a cold first run, where hydration can settle before the vault does, the parked message went out before anything knew the project was keyless: a 422, and no task left to send once the user added a key. The gate now takes `modelKeyLoading` as its own hold. The strip still stays hidden until loading resolves.
…d hand-off Two ways the create path reported a hand-off it had not made: - The composer submits `""` once staged files settle, so a picture-only first message left `seed` falsy: no session was minted, the parts were never stashed, we landed on the agent overview, and the caller cleared the attachments. A create is now seeded by text OR parts (`isSeededCreate`). - `router.push` RESOLVES false on a cancelled navigation instead of throwing, so only the throw was handled. Both outcomes now release the latch, drop the stash and return false.
…n a failed create - The rich input clears itself on submit, so a failed create lost the whole agent description. It is restored through the input handle, unless the user has already started retyping. - The starter pills had hover and disabled states but no keyboard focus state; they now carry the app's focus ring (`focus-visible:border-ring` + `ring-[3px]`). - The loading skeleton spanned the scaffold at `gap-6` while the hero uses the page width cap at `gap-5`, so the crossfade shifted. Same frame now.
Review pass on 6167 — everything answeredAll six inline findings are fixed and pushed. Head is now
The comment nitpick: not applied, and whyThe rule it cites is real. I left them, for two reasons. The blocks carry the desktop-parity rationale that the code cannot show — which rule of If you do want the trim, it is one commit and I will do it on request. My suggestion would be to cut the product-history passages and keep the parity rules. Verification on the pushed head
Behavior is unchanged where it should be: the composer still locks behind the key gate, the strip still stays hidden until the vault answers, a failed agent list still shows Home rather than the hero, and the first submit still creates the real agent and opens its chat. One trade is worth your attention before merge, and it is written up in full on the P1 thread: holding the parked task on |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
web/mobile/src/features/chat/LiveConversation.tsx (1)
270-281: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winDisable the Composer while the model-key status is unresolved.
modelBlockedis false whilemodelKeyLoadingis true. After hydration completes, the Composer can therefore submit before the vault resolves. A keyless project can reject that message before the model gate appears, and this direct submission is not parked for retry.Include
modelKeyLoadingin the Composer disable condition.Proposed fix
- disabled={conversation.isHydrating || modelBlocked} + disabled={conversation.isHydrating || modelKeyLoading || modelBlocked}
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository YAML (base), Organization UI (inherited)
Review profile: CHILL
Plan: Pro Plus
Run ID: 1cb48bbf-4e6f-4a7a-afe5-2833672f8f84
📒 Files selected for processing (8)
web/mobile/src/features/agents/agentHandoff.tsweb/mobile/src/features/agents/useNewAgentAction.tsweb/mobile/src/features/chat/LiveConversation.tsxweb/mobile/src/features/chat/pendingTaskPolicy.tsweb/mobile/src/features/onboarding/FirstRunComposer.tsxweb/mobile/src/features/onboarding/states/FirstRunStates.tsxweb/mobile/tests/unit/agentHandoff.test.tsweb/mobile/tests/unit/pendingTaskPolicy.test.ts
Included review availability: Your plan provides up to 8 included reviews per hour; 5 remain after this review.
… (M3) The first message to a newly created agent failed on /m with "This agent workflow has no invocation URL — it can't be run yet." The same agent ran from the desktop a minute later, and from /m on retry. Root cause: the shared chat engine's `prepareSendMessagesRequest` threw on the FIRST null from `buildAgentRequest`. Null there does not mean "not runnable" — it means the workflow entity carrying `data.url`/`data.uri` has not been fetched yet. The first-run hand-off navigates, resolves the agent's latest revision over the network, and fires the stashed message as soon as the transcript settles, which is often before that entity lands. The desktop never hit it because `useAgentChatSession` has bounded the same build since #6042; the shared hook the mobile app uses never got that fix. So give both surfaces one copy: `boundedRequest` moves from the oss chat slice into `@agenta/chat/assets`, the shared engine wraps its build in it, and the desktop imports it from the package. A not-yet-loaded workflow now retries for up to 15s and fails with a clear message if the URL never appears, instead of failing instantly. Also caps a build that HANGS, which is what the deadline was written for.
M3 fixed on this branch —
|
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository YAML (base), Organization UI (inherited)
Review profile: CHILL
Plan: Pro Plus
Run ID: d6e0b8fe-aefc-4af5-b2e5-20d4f69c304c
📒 Files selected for processing (6)
web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.tsweb/packages/agenta-chat/src/assets/boundedRequest.tsweb/packages/agenta-chat/src/assets/index.tsweb/packages/agenta-chat/src/hooks/useAgentConversation.tsweb/packages/agenta-chat/tests/unit/assets/boundedRequest.test.tsweb/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts
Included review availability: Your plan provides up to 8 included reviews per hour; 3 remain after this review.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
web/packages/agenta-chat/src/assets/boundedRequest.ts (1)
40-41: 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick winAlways yield after a
nullbuild.If
retryMsis0or negative, Line 41 skips the await. An immediately resolvingnullbuild then loops in the microtask queue until the deadline. This blocks rendering for up to 15 seconds.Clamp the delay and await
setTimeouteven when the delay is zero.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository YAML (base), Organization UI (inherited)
Review profile: CHILL
Plan: Pro Plus
Run ID: d6e0b8fe-aefc-4af5-b2e5-20d4f69c304c
📒 Files selected for processing (6)
web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.tsweb/packages/agenta-chat/src/assets/boundedRequest.tsweb/packages/agenta-chat/src/assets/index.tsweb/packages/agenta-chat/src/hooks/useAgentConversation.tsweb/packages/agenta-chat/tests/unit/assets/boundedRequest.test.tsweb/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts
Included review availability: Your plan provides up to 8 included reviews per hour; 3 remain after this review.
Holding the first message until the vault answers fixed the cold-start 422, but the vault query has no retry cap: an outage left `loading` true forever and parked the message with nothing on screen to explain it. Unlike a hold on an active gate — which the strip explains and the user releases by saving a key — that wait is invisible. So bound it, the way the desktop bounds its own waits. After 10 seconds with the vault still unresolved, the task is released, the text goes back into the composer, and a line above it says the message was not sent. A hold on an ACTIVE gate stays unbounded, because that one is the feature working. The rule is now a three-way decision (hold / send / abandon) rather than a boolean, since "stop holding" and "send" are different outcomes. 14 unit tests, 7 on the bound.
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
web/mobile/src/features/chat/LiveConversation.tsx (1)
326-329: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy liftDo not send a composer task while
modelKeyLoadingis true.After hydration completes before vault resolution, the composer is enabled because
modelBlockedis still false. Line 328 then callsconversation.senddirectly and bypassespendingTaskDecision. A normal user submission can enter the keyless request path during the same unresolved-vault window. Queue these submissions through the gate, or disable submission until the vault resolves.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository YAML (base), Organization UI (inherited)
Review profile: CHILL
Plan: Pro Plus
Run ID: 5da9447a-5720-452f-b977-0e5cc9e98719
📒 Files selected for processing (3)
web/mobile/src/features/chat/LiveConversation.tsxweb/mobile/src/features/chat/pendingTaskPolicy.tsweb/mobile/tests/unit/pendingTaskPolicy.test.ts
Included review availability: Your plan provides up to 8 included reviews per hour; 1 remains after this review.
| // swallow the next session's stashed task. | ||
| const takePendingTask = useSetAtom(takePendingTaskAtom) | ||
| const sentPendingTaskFor = useRef<string | null>(null) | ||
| const [pendingTaskError, setPendingTaskError] = useState<string | null>(null) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Scope pendingTaskError to the affected task.
pendingTaskError has no reset path. This component survives session changes, so a timeout for one session remains visible in a later session. The error also remains after the restored message sends successfully. Reset it when sessionId changes and clear it when the composer accepts a retry.
Also applies to: 321-329
| const task = takePendingTask(sessionId) | ||
| if (!task) return | ||
| // Consumed either way — a released task must not replay on the next render. | ||
| sentPendingTaskFor.current = sessionId | ||
| if (decision === "abandon") { | ||
| setPendingTaskError(PENDING_TASK_NOT_SENT_MESSAGE) | ||
| // Hand the text back so "try again" is one tap. The composer is usable here: the gate | ||
| // is not up, because an unresolved vault never raises it. | ||
| if (task.text) composerRef.current?.setMarkdown(task.text) | ||
| return |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Restore task.parts when the task is abandoned.
Line 126 consumes the task. Lines 130-135 restore only task.text. task.parts is only passed to send at line 137. An attachment-only task, or attachments attached to a text task, are lost after the vault deadline. Restore the complete task through composer draft state, or re-queue it until the user retries.
What a brand-new user meets on
/m: the first-run hero, the provider key gate, and (added during release QA) the fix for the first message to a new agent failing. Based onrelease/v0.113.0(retargeted after #6152 merged).The gap
Desktop never shows a new user its Home.
OnboardingEntry.tsx:45sees an empty agents list (useAgentsFirstRun,web/oss/src/components/pages/agents/store.ts:75) and redirects into the playground, which asks "What do you want to build?" with the composer ready. And it tells the user about a missing provider key before the send:useAgentModelKeyStatus(entityId).gateActive(web/packages/agenta-chat/src/hooks/useAgentModelKeyStatus.ts:82, rule at:67) disables the composer (AgentConversation.tsx:194,:265), raisesConnectModelBanner.tsx:36, and parks the first-run seed until a key lands (useFirstRunSeed.ts:169)./mhad neither.HomeScreen.tsx:96). One "New agent" button on an empty page was the whole first-run experience.422 no usable credential(sdks/python/agenta/sdk/agents/connections/errors.py:40), and the user got a red "The agent run failed" bubble with a raw backend reason (TurnRow.tsx:303) and nothing to click. The vault lived only in Settings, which a new user has no reason to open.useAgentConversation.ts:16says the key gate is left to the skin on purpose. The mobile skin never composed it.1. The provider key gate (
590df9ed3c)Nothing had to be extracted: the hook is already in
@agenta/chat/hooks, the vault atoms in@agenta/entities/secret, and/malready ships a provider-key sheet in Settings.features/chat/ConnectModelStrip.tsx— the mobile skin of the desktop banner. Enters and leaves throughRevealCollapse, the shared composer-chrome idiom this app already uses for the mic notice. Its button opens this app'sProviderKeySheetscoped to the agent's own provider (providerEntry), rather than desktop's all-providers drawer plus a model repoint (useOnboardingProviderSetup). One field, no config write, same end state — and that repoint is the only part of the desktop path that is app-layer.features/chat/pendingTaskPolicy.ts— the parked-task rule, pure and tested. A task typed on Home now waits while the gate is up and goes out on its own when a key is saved, matchinguseFirstRunSeed. It also waits while the VAULT is still unresolved —gateActiveis deliberately false then, and on a cold first run hydration can settle first, which sent the message before anything knew the project was keyless. That wait is bounded at 10 s: the vault query has no retry cap, and unlike a hold on an active gate (strip up, composer disabled) an unresolved-vault hold is invisible. On the deadline the task is released, its text goes back into the composer, and a line says it was not sent.features/chat/LiveConversation.tsx— composes the hook, disables the composer, renders the strip, routes the parked send through the policy.gateActiveis false while the vault query is unresolved, so a reload cannot flash a false "add a key" at a project that already has one.LiveConversationis the single choke point for every mobile entry into a chat, so one mount covers them all.2. The first-run surface (
5268f9173d)Rendered in place, not behind a route of its own. The redirect is what forces desktop's detection to be certain before it decides; a swap under the same URL corrects itself when the list lands, and it keeps the nav drawer — which is how a keyless user still reaches Settings.
features/onboarding/homeSurface.ts— the three-way rule (loading/first-run/home), pure and tested. Keeps desktop's two real constraints: a failed fetch is never evidence of emptiness (it falls through to Home, the retryable surface), and an empty list that is still resolving holds a skeleton rather than flashing Home. A cached non-empty list short-circuits the hold, so a returning user never waits.features/onboarding/FirstRunScreen.tsx— the desktop hero's own words (lifted fromAgentChatEmptyState.tsx:14andagent-home/assets/constants.ts:67, copied rather than imported because/mmay not import the desktop app layer), the composer, and the shared create button for templates.features/onboarding/FirstRunComposer.tsx—HomeTaskComposercannot serve this: it runs a task with an agent that already exists and disables itself without one. So this binds the same underlyingChatComposerto the create path. Starter chips fill the input rather than send it — a phone keyboard makes "describe an agent" expensive, and an editable half-idea beats a blank box.useNewAgentAction.createFromPrompt— the first submit runs the SAME shared mint+commit core (@agenta/home-uiuseCreateAgent) that the blank and template creates run, then stashes what was typed against the session it minted and hands off to the chat.runnow reports whether the hand-off committed, because a caller cannot readerrorafter anawait— that flag belongs to the render it was read in.features/agents/agentHandoff.ts— the hand-off route, extracted pure and tested.The chain, end to end
FirstRunScreensubmit →createFromPrompt→ shareduseCreateAgentmints + commits (default name "New agent", the same default desktop onboarding uses) →invalidateWorkflowsListCache→ stash{agentId, text, parts}against the minted session →router.push(/sessions/<id>?agent=<appId>)→ChatScreen→LiveConversation→useAgentModelKeyStatus→ gate up on a keyless project →canSendPendingTaskreturns false, the task stays stashed,ConnectModelStripshows → user saves a key →useVaultSecretsetsproviderKeySetupDoneand the connections list grows →gateActivefalse → the effect re-runs and the parked message sends.Trade-off against desktop (deliberate)
Desktop mints an ephemeral, client-only agent (
useAgentOnboarding.ts:142) and commits it on send, so abandoning the hero leaves nothing behind. Here the first submit creates the real agent — which is exactly what this app's "New agent" button already does. Matching desktop would mean porting the ephemeral commit-in-place dance and its ~250 lines of playground state. Accepted for now; the visible cost is an abandoned "New agent" in the roster if the user submits and then leaves.3. M3: the first message to a new agent failed (
f2e1d963e8)Found in release QA on
oss.preview, and it predates this PR. From/m, the first message to afreshly created agent came back as a failed turn:
The same agent ran fine from the desktop playground a minute later, and from
/mon retry.Root cause.
buildAgentRequestreturnsnullwhen the workflow entity has not loaded itsdata.url/data.uriyet. That is a not yet, not a never: the create hand-off navigates,resolves the agent's latest revision over the network (
useAgentEntity), mounts the conversation,and fires the stashed first message as soon as the transcript settles — often before the entity
that carries the invocation URL has landed. The shared engine
(
useAgentConversation.prepareSendMessagesRequest) threw on the FIRST null, so that race became afailed run.
The desktop never hit this because
useAgentChatSessionhas bounded the same build since#6042 —
buildRequestWithinDeadline, whose owncomment names this exact case: "a navigation race, gone within moments". The shared hook
/mrunson never got that fix. The retry working on
/mis the same evidence: by then the entity is in thestore.
Fix — one copy for both surfaces.
boundedRequestmoves from the oss chat slice into@agenta/chat/assets, the shared engine wraps its build in it, and the desktop imports it from thepackage instead of its own relative path. A not-yet-loaded workflow now retries every 300 ms for up
to 15 s and then fails with a clear message, instead of failing instantly. The deadline also caps a
build that HANGS, which is what it was written for.
Regression test in
packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts: a buildthat returns
nulltwice before yielding a request still produces an answered turn.Follow-on, not fixed here. After the failed turn, a reload said "This session's earlier messages
are no longer stored" and dropped the turn. That is correct behavior for its inputs — the send never
reached the server, so the session has no durable records, and a reload loses the in-memory
"fresh session" marker. It stops happening once the first send succeeds. Telling a never-run session
apart from a pruned one needs a server signal, so it is left alone rather than papered over.
QA (brand-new account, on a phone or an emulated mobile UA)
First run, no provider key — the whole path:
/m. You should see "What do you want to build?" with a composer, not an empty Home. No flash of Home first.M3 — the first message to a new agent (the fix above):
11. On a project that already has a provider key, create a brand-new agent from
/mand send amessage immediately, without waiting. It must answer. It must NOT come back with "This agent
workflow has no invocation URL". Repeat it a few times: this was a race, so one pass proves less
than five.
12. Do the same from the
/mHome composer with the agent picker on "New agent" — the path releaseQA originally reproduced it on.
Regressions to check:
7. "Or start from a template" on the first-run screen creates the agent and opens the chat, same as before.
8. Reload
/mwith the agent now existing: normal Home, with its composer. The first-run screen must not come back.9. A project that already has a key: reload the chat a few times. The strip must never flash.
10. Kill the network and load
/m: the agents fetch fails, and you get Home (retryable), never the first-run hero.Verification
pnpm --filter @agenta/mobile testpnpm --filter @agenta/chat testtsc --noEmit(@agenta/mobile)tsc --noEmit(@agenta/oss)pnpm --filter @agenta/chat check(types + lint)pnpm --filter @agenta/mobile lintpnpm --filter @agenta/mobile buildprettier 3.7.4 --checkon touched filesNot run: live QA on a device — the steps above are for the release QA pass.