Files
paseo/packages/app/src/composer/draft/create-flow.ts
T
Mohamed Boudra 1e2ab3d596 Keep the first prompt on screen when a new agent starts on Android (#8898)
* refactor(app): stop resetting the chat timeline when its agent id changes

A mounted timeline only ever shows one agent: every host keys its panel by tab id,
and a tab keeps its id across a target change only when a draft becomes the agent
it created. The per-agent resets inside the timeline therefore never had a different
agent to reset for. They only fired at that create handoff, where they hid and
re-placed a timeline that was already on screen.

Delete them: LegendList's dataKey and the placed agent id, the web viewport key,
the bottom anchor's resetForAgent, the history window's agent id, and the
expansion and path-opener resets in the stream view.

* fix(app): keep one chat timeline across the draft-to-agent handoff

A draft tab and the agent its first prompt creates rendered two different panels:
DraftPanel mounted its own dock and list, and the retarget swapped it for AgentPanel's.
On Android, with the LegendList timeline, the swap blanked the first turn and re-placed it.

Render both from the agent panel. A draft supplies its timeline from the pending create
in the create-flow store (composer/draft/timeline.ts) and its composer in the composer
slot. The retarget then re-renders the same stream view with the agent's timeline, so the
list instance and its measured rows stay mounted. Agent gating is unchanged.

Float the compact header over draft tabs as it already floats over agent tabs. The
header sat in flow above a draft and over an agent, so the list viewport grew 92 dp at
the retarget and the turn moved.

The handoff E2E now fails if the first row disappears, hides, or shifts down while the
draft becomes the agent.

* fix(app): load subagents and tasks only for an agent, not a draft tab
2026-11-06 13:38:10 +07:00

336 lines
11 KiB
TypeScript

import { useCallback, useMemo, useReducer } from "react";
import { useTranslation } from "react-i18next";
import type { ComposerAttachment } from "@/attachments/types";
import {
resolveComposerAttachmentSubmitFormat,
splitComposerAttachmentsForSubmit,
} from "@/composer/attachments/submit";
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer";
import { useSessionStore } from "@/stores/session-store";
import { createUserMessage, type UserMessageImageAttachment } from "@/types/stream";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
interface CreateAttempt {
clientMessageId: string;
text: string;
timestamp: Date;
images?: UserMessageImageAttachment[];
attachments?: AgentAttachment[];
}
type DraftAgentMachineState<TDraftAgent> =
| { tag: "draft"; errorMessage: string }
| { tag: "creating"; attempt: CreateAttempt; draftAgent: TDraftAgent };
type DraftAgentMachineEvent<TDraftAgent> =
| { type: "DRAFT_SET_ERROR"; message: string }
| { type: "SUBMIT"; attempt: CreateAttempt; draftAgent: TDraftAgent }
| { type: "CREATE_FAILED"; message: string };
function assertNever(value: never): never {
throw new Error(`Unhandled state: ${JSON.stringify(value)}`);
}
function reducer<TDraftAgent>(
state: DraftAgentMachineState<TDraftAgent>,
event: DraftAgentMachineEvent<TDraftAgent>,
): DraftAgentMachineState<TDraftAgent> {
switch (event.type) {
case "DRAFT_SET_ERROR": {
if (state.tag !== "draft") {
return state;
}
return { ...state, errorMessage: event.message };
}
case "SUBMIT": {
return { tag: "creating", attempt: event.attempt, draftAgent: event.draftAgent };
}
case "CREATE_FAILED": {
if (state.tag !== "creating") {
return state;
}
return { tag: "draft", errorMessage: event.message };
}
default:
return assertNever(event);
}
}
function prepareCreateAttempt<TDraftAgent>(
attempt: CreateAttempt,
buildDraftAgent: (attempt: CreateAttempt) => TDraftAgent,
): DraftAgentMachineState<TDraftAgent> {
try {
return { tag: "creating", attempt, draftAgent: buildDraftAgent(attempt) };
} catch (error) {
return { tag: "draft", errorMessage: error instanceof Error ? error.message : String(error) };
}
}
interface CreateRequestResult<TCreateResult> {
agentId: string | null;
result: TCreateResult;
}
interface SubmitContext {
text: string;
attachments: ComposerAttachment[];
cwd: string;
}
interface CreateRequestContext {
attempt: CreateAttempt;
text: string;
images?: UserMessageImageAttachment[];
attachments?: AgentAttachment[];
cwd: string;
}
interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
draftId: string;
getPendingServerId: () => string | null;
initialAttempt?: CreateAttempt | null;
allowEmptyText?: boolean;
validateBeforeSubmit?: (ctx: SubmitContext) => string | null;
onBeforeSubmit?: (ctx: CreateRequestContext) => Promise<void> | void;
onCreateStart?: () => void;
createRequest: (ctx: CreateRequestContext) => Promise<CreateRequestResult<TCreateResult>>;
buildDraftAgent: (attempt: CreateAttempt) => TDraftAgent;
onCreateSuccess: (ctx: { result: TCreateResult; attempt: CreateAttempt }) => Promise<void> | void;
onCreateError?: (error: Error) => void;
}
export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
draftId,
getPendingServerId,
initialAttempt = null,
allowEmptyText = false,
validateBeforeSubmit,
onBeforeSubmit,
onCreateStart,
createRequest,
buildDraftAgent,
onCreateSuccess,
onCreateError,
}: UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult>) {
const { t } = useTranslation();
const [localMachine, dispatch] = useReducer(
reducer<TDraftAgent>,
initialAttempt,
(attempt): DraftAgentMachineState<TDraftAgent> =>
attempt
? prepareCreateAttempt(attempt, buildDraftAgent)
: {
tag: "draft",
errorMessage: "",
},
);
const pending = useCreateFlowStore((state) => state.pendingByDraftId[draftId]);
// Remounts can precede model hydration. Rebuild the preview when its inputs
// arrive, and observe the original request's failure through shared state.
const machine = useMemo<DraftAgentMachineState<TDraftAgent>>(() => {
if (pending?.lifecycle === "abandoned") {
return { tag: "draft", errorMessage: pending.errorMessage ?? "" };
}
if (pending?.lifecycle === "active" && localMachine.tag === "draft" && initialAttempt) {
return prepareCreateAttempt(initialAttempt, buildDraftAgent);
}
return localMachine;
}, [pending, localMachine, initialAttempt, buildDraftAgent]);
const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending);
const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId);
const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle);
const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
const isSubmitting = machine.tag === "creating";
const draftAgent = machine.tag === "creating" ? machine.draftAgent : null;
const startCreateAttempt = useCallback(
(attempt: CreateAttempt) => {
const prepared = prepareCreateAttempt(attempt, buildDraftAgent);
if (prepared.tag === "draft") {
dispatch({ type: "DRAFT_SET_ERROR", message: prepared.errorMessage });
throw new Error(prepared.errorMessage);
}
dispatch({ type: "SUBMIT", attempt, draftAgent: prepared.draftAgent });
},
[buildDraftAgent],
);
const runCreateAttempt = useCallback(
async ({ attempt, cwd }: { attempt: CreateAttempt; cwd: string }) => {
const pendingServerId = getPendingServerId();
if (!pendingServerId) {
const error = new Error(t("composer.errors.noHostSelected"));
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error;
}
try {
await onBeforeSubmit?.({
attempt,
text: attempt.text,
images: attempt.images,
attachments: attempt.attachments,
cwd,
});
const createResult = await createRequest({
attempt,
text: attempt.text,
images: attempt.images,
attachments: attempt.attachments,
cwd,
});
if (createResult.agentId) {
updatePendingAgentId({ draftId, agentId: createResult.agentId });
handoffCreatedAgentMessageSubmission(
pendingServerId,
createResult.agentId,
createUserMessage({
clientMessageId: attempt.clientMessageId,
text: attempt.text,
timestamp: attempt.timestamp,
images: attempt.images,
attachments: attempt.attachments,
}),
);
markPendingCreateLifecycle({ draftId, lifecycle: "sent" });
}
await onCreateSuccess({ result: createResult.result, attempt });
} catch (error) {
const resolved =
error instanceof Error ? error : new Error(t("composer.errors.failedToCreateAgent"));
dispatch({ type: "CREATE_FAILED", message: resolved.message });
markPendingCreateLifecycle({
draftId,
lifecycle: "abandoned",
errorMessage: resolved.message,
});
onCreateError?.(resolved);
throw error;
}
},
[
createRequest,
draftId,
getPendingServerId,
markPendingCreateLifecycle,
onBeforeSubmit,
onCreateError,
onCreateSuccess,
t,
updatePendingAgentId,
],
);
const handleCreateFromInput = useCallback(
async ({ text, attachments, cwd }: SubmitContext) => {
const existing = useCreateFlowStore.getState().pendingByDraftId[draftId];
if (
isSubmitting ||
isActiveCreateFlowForDraft({ pending: existing, serverId: getPendingServerId(), draftId })
) {
throw new Error(t("composer.errors.alreadyLoading"));
}
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
const trimmedPrompt = text.trim();
const pendingServerId = getPendingServerId();
if (!pendingServerId) {
const error = new Error(t("composer.errors.noHostSelected"));
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error;
}
const supportsForgeSearch =
useSessionStore.getState().sessions[pendingServerId]?.serverInfo?.features?.forgeSearch ===
true;
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
format: resolveComposerAttachmentSubmitFormat({
supportsForgeAttachments: supportsForgeSearch,
}),
});
const images = wirePayload.images;
const hasAttachmentContent = images.length > 0 || wirePayload.attachments.length > 0;
if (!trimmedPrompt && !hasAttachmentContent && !allowEmptyText) {
const error = new Error(t("composer.errors.initialPromptRequired"));
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error;
}
const validationError = validateBeforeSubmit?.({
text: trimmedPrompt,
attachments,
cwd,
});
if (validationError) {
const error = new Error(validationError);
dispatch({ type: "DRAFT_SET_ERROR", message: validationError });
throw error;
}
const attempt: CreateAttempt = {
clientMessageId: `${draftId}:initial-message`,
text: trimmedPrompt,
timestamp: new Date(),
...(images.length > 0 ? { images } : {}),
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
};
startCreateAttempt(attempt);
setPendingCreateAttempt({
draftId,
serverId: pendingServerId,
agentId: null,
clientMessageId: attempt.clientMessageId,
text: attempt.text,
timestamp: attempt.timestamp.getTime(),
...(attempt.images && attempt.images.length > 0 ? { images: attempt.images } : {}),
...(attempt.attachments && attempt.attachments.length > 0
? { attachments: attempt.attachments }
: {}),
});
onCreateStart?.();
await runCreateAttempt({ attempt, cwd });
},
[
allowEmptyText,
draftId,
getPendingServerId,
isSubmitting,
onCreateStart,
runCreateAttempt,
setPendingCreateAttempt,
startCreateAttempt,
t,
validateBeforeSubmit,
],
);
const continueCreateFromAttempt = useCallback(
async ({ attempt, cwd }: { attempt: CreateAttempt; cwd: string }) => {
if (!isSubmitting) {
startCreateAttempt(attempt);
}
await runCreateAttempt({ attempt, cwd });
},
[isSubmitting, runCreateAttempt, startCreateAttempt],
);
return {
machine,
formErrorMessage,
isSubmitting,
draftAgent,
handleCreateFromInput,
continueCreateFromAttempt,
};
}
export type { CreateAttempt as DraftCreateAttempt };