From e4aa2d510f6d1c42da61275fa5ffa31e3311f851 Mon Sep 17 00:00:00 2001 From: Christian-Sidak <61099993+Christian-Sidak@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:25:55 -0700 Subject: [PATCH] fix(mobile): show held message as user bubble when model gate is up On a keyless account, a message typed in the /m composer was stashed in pendingTasksAtom and held correctly, but nothing rendered it. The screen showed only the AgentIntroCard below the connect-model strip, so the person could not see what they had typed. This matches the desktop behavior: the desktop renders the held seed as a visible user turn above the connect-model banner. Mobile now matches that. Changes: - Read pendingTasksAtom non-destructively (without consuming the entry) in LiveConversation to get the held task text. - Render it as a right-aligned user bubble in the transcript whenever heldTaskText is set, placed above the AgentIntroCard. The bubble disappears automatically once the gate drops and the send effect fires (takePendingTaskAtom removes the entry from the atom). - Pass 'Connect a model to start chatting...' as the composer placeholder when modelBlocked is true, matching the desktop disabled-state copy. - Thread a placeholder prop through Composer to ChatComposer so the host can override the default idle placeholder. Fixes #6209 Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Christian-Sidak <61099993+Christian-Sidak@users.noreply.github.com> --- web/mobile/src/features/chat/Composer.tsx | 4 ++ .../src/features/chat/LiveConversation.tsx | 45 +++++++++++++++++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/web/mobile/src/features/chat/Composer.tsx b/web/mobile/src/features/chat/Composer.tsx index 126aa8e953f..baa65e33d51 100644 --- a/web/mobile/src/features/chat/Composer.tsx +++ b/web/mobile/src/features/chat/Composer.tsx @@ -34,6 +34,7 @@ export const Composer = ({ streaming = false, onStop, inputRef, + placeholder, }: { sessionId: string onSend: (input: {text: string; parts?: FileUIPart[]}) => void | Promise @@ -46,6 +47,8 @@ export const Composer = ({ onStop?: () => void /** Lets the host write into the input — a rewind puts the rewound message back to edit. */ inputRef?: MutableRefObject + /** Full placeholder override — used when the composer is gated (no model key). */ + placeholder?: string }) => { const attachments = useComposerAttachments({sessionId}) const ownInputRef = useRef(null) @@ -137,6 +140,7 @@ export const Composer = ({ disabled={disabled} composerDisabled={disabled} dictating={dictating} + placeholder={placeholder} waitingOnUser={waitingOnUser} streaming={streaming} onStop={onStop} diff --git a/web/mobile/src/features/chat/LiveConversation.tsx b/web/mobile/src/features/chat/LiveConversation.tsx index 932404826f9..d6c32f1195a 100644 --- a/web/mobile/src/features/chat/LiveConversation.tsx +++ b/web/mobile/src/features/chat/LiveConversation.tsx @@ -14,14 +14,20 @@ import {useAgentConversation, useAgentModelKeyStatus, useConnectionDock} from "@ import {getPendingApprovals, type TurnViewModel} from "@agenta/chat/model" import {AgentIntroCard} from "@agenta/entity-ui/agent" import {modal} from "@agenta/ui/app-message" -import {ChatJumpToLatest} from "@agenta/ui/components/presentational" +import { + ChatBubble, + ChatBubbleAvatar, + ChatJumpToLatest, + turnRowClass, +} from "@agenta/ui/components/presentational" import type {RichChatInputHandle} from "@agenta/ui/rich-chat-input" -import {useSetAtom} from "jotai" +import {useAtomValue, useSetAtom} from "jotai" +import {User} from "lucide-react" import {ContentRail} from "@/components/ContentRail" import {ScreenScaffold} from "@/components/ScreenScaffold" -import {takePendingTaskAtom} from "../home/pendingTask" +import {pendingTasksAtom, takePendingTaskAtom} from "../home/pendingTask" import {AppShell} from "../nav/AppShell" import {ApprovalDock} from "./ApprovalDock" @@ -114,6 +120,12 @@ export const LiveConversation = ({ // vault says one already exists). The guard holds the SESSION it // fired for, not a bare flag: this component survives a session switch, and a flag would // swallow the next session's stashed task. + + // Peek at the parked task WITHOUT consuming it — used only for display while the gate holds. + // `takePendingTaskAtom` removes the entry; this read leaves it in place for the send effect. + const pendingTasks = useAtomValue(pendingTasksAtom) + const heldTaskText = pendingTasks[sessionId]?.text ?? null + const takePendingTask = useSetAtom(takePendingTaskAtom) const sentPendingTaskFor = useRef(null) const [pendingTaskError, setPendingTaskError] = useState(null) @@ -246,6 +258,30 @@ export const LiveConversation = ({ } else { body = ( + {/* A task typed before any provider key exists is held in `pendingTasksAtom` + (not yet sent — the gate is up). Render it as a user bubble so the person + can see what they wrote, matching desktop parity: the desktop shows the + held seed above the connect-model banner. Cleared the moment the gate + drops and the send effect fires (`takePendingTaskAtom` removes the entry). */} + {heldTaskText ? ( +
+ } />} + className="min-w-0 max-w-[85%]" + classNames={{ + content: "min-w-0 max-w-full overflow-hidden text-xs", + body: "min-w-0 max-w-full overflow-hidden", + }} + content={ + + {heldTaskText} + + } + /> +
+ ) : null} {conversation.isEmpty ? ( // The SAME card the desktop shows a conversation with no messages: who you are // about to talk to. A blank session is not an error state — /m rendered nothing @@ -364,6 +400,9 @@ export const LiveConversation = ({ sessionId={sessionId} onSend={({text, parts}) => conversation.send({text, parts})} disabled={conversation.isHydrating || modelBlocked} + placeholder={ + modelBlocked ? "Connect a model to start chatting…" : undefined + } waitingOnUser={conversation.hitlPending} streaming={streamingHere} onStop={conversation.stop}