diff --git a/packages/app/e2e/bottom-sheet-reopen.spec.ts b/packages/app/e2e/bottom-sheet-reopen.spec.ts index a0bbdda00..67ad8a42a 100644 --- a/packages/app/e2e/bottom-sheet-reopen.spec.ts +++ b/packages/app/e2e/bottom-sheet-reopen.spec.ts @@ -41,13 +41,23 @@ async function expectBottomSheetOpen(page: Page) { } async function closeBottomSheetWithBackdrop(page: Page) { - const box = await bottomSheetBackdrop(page).boundingBox(); - expect(box).not.toBeNull(); - await page.mouse.click(box!.x + box!.width / 2, box!.y + 24); - await expect(bottomSheetBackdrop(page)).not.toBeVisible({ timeout: 10_000 }); + const backdrop = bottomSheetBackdrop(page); + + for (let attempt = 0; attempt < 3; attempt += 1) { + if (!(await backdrop.isVisible().catch(() => false))) { + break; + } + await backdrop.click({ force: true }); + await page.keyboard.press("Escape").catch(() => undefined); + await expect(backdrop) + .not.toBeVisible({ timeout: 10_000 }) + .catch(() => undefined); + } + + await expect(backdrop).not.toBeVisible({ timeout: 10_000 }); // Guard against the regression where the sheet starts dismissing, then re-presents. await page.waitForTimeout(500); - await expect(bottomSheetBackdrop(page)).not.toBeVisible({ timeout: 1_000 }); + await expect(backdrop).not.toBeVisible({ timeout: 1_000 }); } async function openTabSwitcher(page: Page) { diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index f308f5de5..f475486de 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -66,6 +66,12 @@ import type { StreamItem } from "@/types/stream"; import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; + +import { + findPendingCreateUserMessageIndex, + mergePendingCreateImages, +} from "@/utils/pending-create-images"; + import { navigateToAgent } from "@/utils/navigate-to-agent"; import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; import { buildDraftAgentSetup, type ClientSlashCommand } from "@/client-slash-commands"; @@ -396,9 +402,29 @@ const EMPTY_PENDING_PERMISSIONS = new Map(); const EMPTY_PENDING_PERMISSION_LIST: PendingPermission[] = []; type RouteBottomAnchorRequest = ReturnType; +type PendingCreateByDraftId = ReturnType["pendingByDraftId"]; +type PendingCreateAttempt = PendingCreateByDraftId[string]; + +function findPendingCreateForPanel(input: { + pendingByDraftId: PendingCreateByDraftId; + serverId: string; + agentId?: string; +}): PendingCreateAttempt | null { + if (!input.agentId) { + return null; + } + return ( + Object.values(input.pendingByDraftId).find( + (entry) => + entry.lifecycle === "active" && + entry.serverId === input.serverId && + entry.agentId === input.agentId, + ) ?? null + ); +} function findActiveCreateHandoff(input: { - pendingByDraftId: ReturnType["pendingByDraftId"]; + pendingByDraftId: PendingCreateByDraftId; serverId: string; agentId?: string; }): boolean { @@ -712,6 +738,12 @@ function ChatAgentContent({ const historySyncGeneration = useSessionStore( (state) => state.sessions[serverId]?.historySyncGeneration ?? 0, ); + const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId); + const pendingCreate = useMemo( + () => findPendingCreateForPanel({ pendingByDraftId, serverId, agentId }), + [agentId, pendingByDraftId, serverId], + ); + const isPendingCreateForPanel = Boolean(pendingCreate); const hasAppliedAuthoritativeHistory = useSessionStore((state) => agentId ? state.sessions[serverId]?.agentAuthoritativeHistoryApplied?.get(agentId) === true @@ -732,6 +764,11 @@ function ChatAgentContent({ kind: "idle", }); + const shouldUseOptimisticStream = isPendingCreateForPanel; + const authoritativeStatus = agentState.status; + const isAuthoritativeBootstrapping = + authoritativeStatus === "initializing" || authoritativeStatus === "idle"; + const canFinalizePendingCreate = Boolean(authoritativeStatus) && !isAuthoritativeBootstrapping; const hasHydratedHistoryBefore = hasAppliedAuthoritativeHistory; const attentionController = useAgentAttentionClear({ @@ -1031,6 +1068,9 @@ function ChatAgentContent({ isArchivingCurrentAgent={isArchivingCurrentAgent} agentState={agentState} effectiveAgent={effectiveAgent} + pendingCreate={pendingCreate} + shouldUseOptimisticStream={shouldUseOptimisticStream} + canFinalizePendingCreate={canFinalizePendingCreate} routeBottomAnchorRequest={routeBottomAnchorRequest} hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory} panelToast={panelToast} @@ -1055,6 +1095,9 @@ function ChatAgentReadyContent({ isArchivingCurrentAgent, agentState, effectiveAgent, + pendingCreate, + shouldUseOptimisticStream, + canFinalizePendingCreate, routeBottomAnchorRequest, hasAppliedAuthoritativeHistory, panelToast, @@ -1075,6 +1118,9 @@ function ChatAgentReadyContent({ isArchivingCurrentAgent: boolean; agentState: ChatAgentSelectedState; effectiveAgent: AgentScreenAgent; + pendingCreate: PendingCreateAttempt | null; + shouldUseOptimisticStream: boolean; + canFinalizePendingCreate: boolean; routeBottomAnchorRequest: RouteBottomAnchorRequest; hasAppliedAuthoritativeHistory: boolean; panelToast: ReturnType; @@ -1108,6 +1154,9 @@ function ChatAgentReadyContent({ serverId={serverId} agentId={agentId} agent={effectiveAgent} + pendingCreate={pendingCreate} + shouldUseOptimisticStream={shouldUseOptimisticStream} + canFinalizePendingCreate={canFinalizePendingCreate} routeBottomAnchorRequest={routeBottomAnchorRequest} hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory} toast={panelToast.api} @@ -1163,6 +1212,9 @@ function AgentStreamSection({ serverId, agentId, agent, + pendingCreate, + shouldUseOptimisticStream, + canFinalizePendingCreate, routeBottomAnchorRequest, hasAppliedAuthoritativeHistory, toast, @@ -1172,6 +1224,9 @@ function AgentStreamSection({ serverId: string; agentId?: string; agent: AgentScreenAgent; + pendingCreate: PendingCreateAttempt | null; + shouldUseOptimisticStream: boolean; + canFinalizePendingCreate: boolean; routeBottomAnchorRequest: RouteBottomAnchorRequest; hasAppliedAuthoritativeHistory: boolean; toast: ReturnType["api"]; @@ -1208,13 +1263,115 @@ function AgentStreamSection({ return new Map(pendingPermissionList.map((permission) => [permission.key, permission])); }, [pendingPermissionList]); + const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); + const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle); + const clearPendingCreate = useCreateFlowStore((state) => state.clear); + + const optimisticStreamItems = useMemo(() => { + if (!shouldUseOptimisticStream || !pendingCreate) { + return EMPTY_STREAM_ITEMS; + } + return [ + { + kind: "user_message", + id: pendingCreate.clientMessageId, + text: pendingCreate.text, + timestamp: new Date(pendingCreate.timestamp), + optimistic: true, + ...(pendingCreate.images && pendingCreate.images.length > 0 + ? { images: pendingCreate.images } + : {}), + ...(pendingCreate.attachments && pendingCreate.attachments.length > 0 + ? { attachments: pendingCreate.attachments } + : {}), + }, + ]; + }, [pendingCreate, shouldUseOptimisticStream]); + + const pendingCreateUserMessageIndex = useMemo(() => { + if (!pendingCreate) { + return -1; + } + return findPendingCreateUserMessageIndex({ + streamItems, + clientMessageId: pendingCreate.clientMessageId, + text: pendingCreate.text, + }); + }, [pendingCreate, streamItems]); + + const mergedStreamItems = useMemo(() => { + if (optimisticStreamItems.length === 0) { + return streamItems; + } + const optimistic = optimisticStreamItems[0]; + if (!optimistic) { + return streamItems; + } + return pendingCreateUserMessageIndex >= 0 + ? streamItems + : [...optimisticStreamItems, ...streamItems]; + }, [optimisticStreamItems, pendingCreateUserMessageIndex, streamItems]); + + useEffect(() => { + if (!shouldUseOptimisticStream || !pendingCreate) { + return; + } + if (pendingCreateUserMessageIndex < 0 || !canFinalizePendingCreate) { + return; + } + + const pendingImages = pendingCreate.images; + const pendingAttachments = pendingCreate.attachments; + const hasPendingImages = Boolean(pendingImages && pendingImages.length > 0); + const hasPendingAttachments = Boolean(pendingAttachments && pendingAttachments.length > 0); + if (agentId && (hasPendingImages || hasPendingAttachments)) { + setAgentStreamTail(serverId, (previous) => { + const current = previous.get(agentId); + if (!current) { + return previous; + } + + const merged = mergePendingCreateImages({ + streamItems: current, + clientMessageId: pendingCreate.clientMessageId, + text: pendingCreate.text, + images: pendingImages, + attachments: pendingAttachments, + }); + if (merged === current) { + return previous; + } + + const next = new Map(previous); + next.set(agentId, merged); + return next; + }); + } + markPendingCreateLifecycle({ + draftId: pendingCreate.draftId, + lifecycle: "sent", + }); + clearPendingCreate({ draftId: pendingCreate.draftId }); + }, [ + agentId, + canFinalizePendingCreate, + clearPendingCreate, + markPendingCreateLifecycle, + pendingCreate, + pendingCreateUserMessageIndex, + serverId, + setAgentStreamTail, + shouldUseOptimisticStream, + streamItems, + ]); + return ( ; + attachments?: AgentAttachment[]; +}): StreamItem { + return { + kind: "user_message", + id: params.id, + text: params.text, + timestamp: new Date("2026-01-01T00:00:00Z"), + ...(params.images ? { images: params.images } : {}), + ...(params.attachments ? { attachments: params.attachments } : {}), + }; +} + +function buildImage(id: string) { + return [ + { + id, + storageType: "native-file" as const, + storageKey: `/tmp/${id}.jpg`, + mimeType: "image/jpeg", + createdAt: Date.now(), + }, + ]; +} + +function buildReviewAttachment(): AgentAttachment { + return { + type: "review", + mimeType: "application/paseo-review", + cwd: "/repo", + mode: "base", + comments: [], + }; +} + +describe("mergePendingCreateImages", () => { + it("returns same reference when pending images are absent", () => { + const streamItems = [userMessage({ id: "msg-1", text: "hello" })]; + const result = mergePendingCreateImages({ + streamItems, + clientMessageId: "msg-1", + images: [], + }); + expect(result).toBe(streamItems); + }); + + it("merges images by clientMessageId when matched message has none", () => { + const streamItems = [userMessage({ id: "msg-1", text: "hello" })]; + const images = buildImage("image-1"); + const result = mergePendingCreateImages({ + streamItems, + clientMessageId: "msg-1", + images, + }); + + expect(result).not.toBe(streamItems); + const updated = result[0]; + expect(updated?.kind).toBe("user_message"); + if (updated?.kind !== "user_message") { + throw new Error("Expected user_message item"); + } + expect(updated.images).toEqual(images); + }); + + it("merges attachments by clientMessageId when matched message has none", () => { + const streamItems = [userMessage({ id: "msg-1", text: "hello" })]; + const attachments = [buildReviewAttachment()]; + const result = mergePendingCreateImages({ + streamItems, + clientMessageId: "msg-1", + attachments, + }); + + expect(result).not.toBe(streamItems); + const updated = result[0]; + expect(updated?.kind).toBe("user_message"); + if (updated?.kind !== "user_message") { + throw new Error("Expected user_message item"); + } + expect(updated.attachments).toEqual(attachments); + }); + + it("does not merge when clientMessageId does not match", () => { + const streamItems = [userMessage({ id: "msg-1", text: "same text" })]; + const images = buildImage("image-2"); + const result = mergePendingCreateImages({ + streamItems, + clientMessageId: "missing-id", + images, + }); + + expect(result).toBe(streamItems); + }); + + it("merges into the first authoritative create message when the provider replaces its id", () => { + const streamItems = [userMessage({ id: "provider-id", text: "same text" })]; + const images = buildImage("image-provider-id"); + const result = mergePendingCreateImages({ + streamItems, + clientMessageId: "client-id", + text: "same text", + images, + }); + + expect(result).not.toBe(streamItems); + const updated = result[0]; + expect(updated?.kind).toBe("user_message"); + if (updated?.kind !== "user_message") { + throw new Error("Expected user_message item"); + } + expect(updated.images).toEqual(images); + }); + + it("does not overwrite existing user message images", () => { + const existingImages = buildImage("existing"); + const streamItems = [userMessage({ id: "msg-1", text: "hello", images: existingImages })]; + const result = mergePendingCreateImages({ + streamItems, + clientMessageId: "msg-1", + images: buildImage("new"), + }); + + expect(result).toBe(streamItems); + const unchanged = result[0]; + expect(unchanged?.kind).toBe("user_message"); + if (unchanged?.kind !== "user_message") { + throw new Error("Expected user_message item"); + } + expect(unchanged.images).toEqual(existingImages); + }); + + it("does not overwrite existing user message attachments", () => { + const existingAttachments = [buildReviewAttachment()]; + const streamItems = [ + userMessage({ id: "msg-1", text: "hello", attachments: existingAttachments }), + ]; + const result = mergePendingCreateImages({ + streamItems, + clientMessageId: "msg-1", + attachments: [ + { + type: "github_issue", + mimeType: "application/github-issue", + number: 7, + title: "Issue", + url: "https://github.com/getpaseo/paseo/issues/7", + }, + ], + }); + + expect(result).toBe(streamItems); + const unchanged = result[0]; + expect(unchanged?.kind).toBe("user_message"); + if (unchanged?.kind !== "user_message") { + throw new Error("Expected user_message item"); + } + expect(unchanged.attachments).toEqual(existingAttachments); + }); +}); + +describe("findPendingCreateUserMessageIndex", () => { + it("recognizes the provider-authored first message by pending create text", () => { + const streamItems = [userMessage({ id: "provider-id", text: "hello" })]; + + expect( + findPendingCreateUserMessageIndex({ + streamItems, + clientMessageId: "client-id", + text: "hello", + }), + ).toBe(0); + }); + + it("does not reconcile a repeated later message by text", () => { + const streamItems = [ + userMessage({ id: "provider-first", text: "different" }), + userMessage({ id: "provider-second", text: "hello" }), + ]; + + expect( + findPendingCreateUserMessageIndex({ + streamItems, + clientMessageId: "client-id", + text: "hello", + }), + ).toBe(-1); + }); +}); diff --git a/packages/app/src/utils/pending-create-images.ts b/packages/app/src/utils/pending-create-images.ts new file mode 100644 index 000000000..cc2cdc99c --- /dev/null +++ b/packages/app/src/utils/pending-create-images.ts @@ -0,0 +1,71 @@ +import type { StreamItem, UserMessageImageAttachment } from "@/types/stream"; +import type { AgentAttachment } from "@getpaseo/protocol/messages"; + +interface MergePendingCreateImagesParams { + streamItems: StreamItem[]; + clientMessageId: string; + text?: string; + images?: UserMessageImageAttachment[]; + attachments?: AgentAttachment[]; +} + +export function findPendingCreateUserMessageIndex({ + streamItems, + clientMessageId, + text, +}: Pick): number { + const clientMessageIndex = streamItems.findIndex( + (item) => item.kind === "user_message" && item.id === clientMessageId, + ); + if (clientMessageIndex >= 0 || text === undefined) { + return clientMessageIndex; + } + + const firstUserMessageIndex = streamItems.findIndex((item) => item.kind === "user_message"); + if (firstUserMessageIndex < 0) { + return -1; + } + + const firstUserMessage = streamItems[firstUserMessageIndex]; + return firstUserMessage.kind === "user_message" && firstUserMessage.text === text + ? firstUserMessageIndex + : -1; +} + +export function mergePendingCreateImages({ + streamItems, + clientMessageId, + text, + images, + attachments, +}: MergePendingCreateImagesParams): StreamItem[] { + const hasPendingImages = Boolean(images && images.length > 0); + const hasPendingAttachments = Boolean(attachments && attachments.length > 0); + if (!hasPendingImages && !hasPendingAttachments) { + return streamItems; + } + + const targetIndex = findPendingCreateUserMessageIndex({ streamItems, clientMessageId, text }); + if (targetIndex < 0) { + return streamItems; + } + + const target = streamItems[targetIndex]; + if (target.kind !== "user_message") { + return streamItems; + } + const shouldMergeImages = hasPendingImages && (!target.images || target.images.length === 0); + const shouldMergeAttachments = + hasPendingAttachments && (!target.attachments || target.attachments.length === 0); + if (!shouldMergeImages && !shouldMergeAttachments) { + return streamItems; + } + + const next = [...streamItems]; + next[targetIndex] = { + ...target, + ...(shouldMergeImages ? { images } : {}), + ...(shouldMergeAttachments ? { attachments } : {}), + }; + return next; +}