From 1e2ab3d59621b3cc6d42d570669ce4681c9eacdc Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 13:38:10 +0700 Subject: [PATCH] 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 --- .../browser/agent-message-submission.spec.ts | 24 +- .../agent-stream/bottom-anchor-controller.ts | 16 - .../app/src/agent-stream/strategy-native.tsx | 27 +- .../app/src/agent-stream/strategy-web.tsx | 12 +- .../use-stream-history-window.test.tsx | 13 +- .../agent-stream/use-stream-history-window.ts | 21 +- packages/app/src/agent-stream/view.tsx | 7 +- .../src/composer/draft/create-flow.test.ts | 11 - .../app/src/composer/draft/create-flow.ts | 43 +-- .../app/src/composer/draft/timeline.test.ts | 103 ++++++ packages/app/src/composer/draft/timeline.ts | 102 ++++++ .../src/composer/draft/workspace-tab-core.ts | 11 + .../app/src/composer/draft/workspace-tab.tsx | 322 +++++++----------- packages/app/src/panels/agent-panel.tsx | 252 +++++++++----- packages/app/src/panels/agent-tracks.tsx | 25 +- .../screens/workspace/workspace-screen.tsx | 6 +- 16 files changed, 556 insertions(+), 439 deletions(-) create mode 100644 packages/app/src/composer/draft/timeline.test.ts create mode 100644 packages/app/src/composer/draft/timeline.ts diff --git a/packages/app/e2e/browser/agent-message-submission.spec.ts b/packages/app/e2e/browser/agent-message-submission.spec.ts index dcf872f8be..24f26f1c44 100644 --- a/packages/app/e2e/browser/agent-message-submission.spec.ts +++ b/packages/app/e2e/browser/agent-message-submission.spec.ts @@ -235,6 +235,7 @@ async function beginTimelineRowStabilityCheck( lastTop: initialRow.getBoundingClientRect().top, largestDownwardShift: 0, sawMissing: false, + sawHidden: false, }; const windowState = window as unknown as Record; windowState.__messageSubmissionTimelineRowStability = state; @@ -245,6 +246,9 @@ async function beginTimelineRowStabilityCheck( requestAnimationFrame(checkFrame); return; } + if (!initialRow.checkVisibility({ opacityProperty: true, visibilityProperty: true })) { + state.sawHidden = true; + } const top = initialRow.getBoundingClientRect().top; state.largestDownwardShift = Math.max(state.largestDownwardShift, top - state.lastTop); state.lastTop = top; @@ -257,14 +261,24 @@ async function beginTimelineRowStabilityCheck( const result = await page.evaluate(() => { const windowState = window as unknown as Record; const state = windowState.__messageSubmissionTimelineRowStability as - | { active: boolean; largestDownwardShift: number; sawMissing: boolean } + | { + active: boolean; + largestDownwardShift: number; + sawMissing: boolean; + sawHidden: boolean; + } | undefined; if (!state) throw new Error("Timeline-row stability check was not started"); state.active = false; delete windowState.__messageSubmissionTimelineRowStability; - return { largestDownwardShift: state.largestDownwardShift, sawMissing: state.sawMissing }; + return { + largestDownwardShift: state.largestDownwardShift, + sawMissing: state.sawMissing, + sawHidden: state.sawHidden, + }; }); expect(result.sawMissing).toBe(false); + expect(result.sawHidden).toBe(false); expect(result.largestDownwardShift).toBeLessThanOrEqual(2); }; } @@ -1178,7 +1192,13 @@ test.describe("Agent message submission", () => { test.setTimeout(120_000); const toasts = await recordPanelToasts(page); const pending = await beginDraftCreateSubmission(page, draftCreateScenario); + // The created agent takes over the draft's timeline: the same row stays mounted and painted. + const finishTimelineRowStabilityCheck = await beginTimelineRowStabilityCheck( + page, + pending.userMessage, + ); await completeDraftCreateSubmission(page, draftCreateScenario, pending); + await finishTimelineRowStabilityCheck(); // A chat this client just created is current by construction; it is never out of date. await toasts.expectNeverShown("agent-updating-toast"); }); diff --git a/packages/app/src/agent-stream/bottom-anchor-controller.ts b/packages/app/src/agent-stream/bottom-anchor-controller.ts index 84535caaf5..f666481be8 100644 --- a/packages/app/src/agent-stream/bottom-anchor-controller.ts +++ b/packages/app/src/agent-stream/bottom-anchor-controller.ts @@ -65,7 +65,6 @@ interface BottomAnchorControllerDriver { pendingVerification: AttemptContext | null; blockedReason: BottomAnchorBlockedReason | null; }; - resetForAgent: () => void; applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void; requestLocalAnchor: (request: BottomAnchorLocalRequest) => void; beginUserScroll: () => void; @@ -480,17 +479,6 @@ function createBottomAnchorControllerDriver( blockedReason, }; }, - resetForAgent() { - lastRouteRequestKey = null; - pendingRequest = null; - blockedReason = null; - cancelPendingAttempt(); - stickyMeasurementRevision = 0; - lastVerifiedStickyMeasurementRevision = 0; - isUserScrollActive = false; - mode = "sticky-bottom"; - input.onModeChange("sticky-bottom"); - }, applyRouteRequest(request) { if (!request) { return; @@ -741,10 +729,6 @@ export function useBottomAnchorController(input: { }); } - useEffect(() => { - driverRef.current?.resetForAgent(); - }, [input.agentId]); - useEffect(() => { driverRef.current?.applyRouteRequest(input.routeRequest); }, [input.routeRequest]); diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index f894880d23..3288e2be25 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -137,9 +137,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat } = props; const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers; const listRef = useRef(null); - // LegendList places the rows of each agent once, behind a hidden reveal (`dataKey`). - const [placedAgentId, setPlacedAgentId] = useState(null); - const isListPlaced = placedAgentId === agentId; + // LegendList places the rows once, behind a hidden reveal, when the list mounts. + const [isListPlaced, setIsListPlaced] = useState(false); const isListPlacedRef = useRef(isListPlaced); isListPlacedRef.current = isListPlaced; const streamViewportMetricsRef = useRef({ @@ -384,23 +383,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat ); useEffect(() => { - streamViewportMetricsRef.current = { - containerKey: "native-virtualized", - contentHeight: 0, - viewportWidth: 0, - viewportHeight: 0, - offsetY: 0, - viewportMeasuredForKey: null, - contentMeasuredForKey: null, - }; - scrollOffsetYRef.current = 0; - isUserScrollActiveRef.current = false; - clearPendingUserScrollEnd(); - disclosureHold.cancel(); - historyStartReadyRef.current = false; - const initialHistoryStartState = createHistoryStartPaginationState(); - historyStartPaginationStateRef.current = initialHistoryStartState; - setHistoryStartPaginationState(initialHistoryStartState); const frame = requestAnimationFrame(() => { historyStartReadyRef.current = true; evaluateHistoryStart(); @@ -411,7 +393,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat historyStartSettleSchedulerRef.current?.cancel(); historyStartSettleSchedulerRef.current = null; }; - }, [agentId, clearPendingUserScrollEnd, disclosureHold, evaluateHistoryStart]); + }, [clearPendingUserScrollEnd, evaluateHistoryStart]); useEffect(() => { const handle: StreamViewportHandle = { @@ -623,7 +605,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const handleListPlaced = useStableEvent(() => { isListPlacedRef.current = true; - setPlacedAgentId(agentId); + setIsListPlaced(true); streamViewportMetricsRef.current = { ...streamViewportMetricsRef.current, contentMeasuredForKey: "native-virtualized", @@ -711,7 +693,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat onContentSizeChange={handleContentSizeChange} onItemSizeChanged={handleItemSizeChanged} onReady={handleListPlaced} - dataKey={agentId} maintainVisibleContentPosition={maintainVisibleContentPosition} renderScrollComponent={renderScrollComponent} recycleItems={false} diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 904ab9c157..b511ac72dd 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -839,10 +839,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ]); useEffect(() => { - const initialHistoryStartState = createHistoryStartPaginationState(); - historyStartPaginationStateRef.current = initialHistoryStartState; - setHistoryStartPaginationState(initialHistoryStartState); - readingAnchor.reset(); const frame = window.requestAnimationFrame(() => { historyStartReadyRef.current = true; evaluateHistoryStart(); @@ -853,7 +849,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool historyStartSettleSchedulerRef.current?.cancel(); historyStartSettleSchedulerRef.current = null; }; - }, [evaluateHistoryStart, props.agentId, readingAnchor]); + }, [evaluateHistoryStart]); useLayoutEffect(() => { if (!isActiveRef.current || !isActivationReady) { @@ -1395,11 +1391,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): StreamStrategy { return createStreamStrategy({ render: (renderInput) => ( - + ), orderTailReverse: false, orderHeadReverse: false, diff --git a/packages/app/src/agent-stream/use-stream-history-window.test.tsx b/packages/app/src/agent-stream/use-stream-history-window.test.tsx index 2d8f0b4326..1db49ecdfd 100644 --- a/packages/app/src/agent-stream/use-stream-history-window.test.tsx +++ b/packages/app/src/agent-stream/use-stream-history-window.test.tsx @@ -21,7 +21,7 @@ describe("useStreamHistoryWindow", () => { ]).flat(); const { result } = renderHook(() => - useStreamHistoryWindow({ agentId: "agent-1", items, loadRemoteOlder: vi.fn() }), + useStreamHistoryWindow({ items, loadRemoteOlder: vi.fn() }), ); expect(result.current.start).toBe(2); @@ -35,7 +35,6 @@ describe("useStreamHistoryWindow", () => { const loadRemoteOlder = vi.fn(async () => true); const { result } = renderHook(() => useStreamHistoryWindow({ - agentId: "agent-1", items: [ message("u1", "user_message"), message("a1", "assistant_message"), @@ -67,8 +66,7 @@ describe("useStreamHistoryWindow", () => { message("a2", "assistant_message"), ]; const { result, rerender } = renderHook( - ({ history }) => - useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }), + ({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }), { initialProps: { history: items } }, ); @@ -91,8 +89,7 @@ describe("useStreamHistoryWindow", () => { message("a2", "assistant_message"), ]; const { result, rerender } = renderHook( - ({ history }) => - useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }), + ({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }), { initialProps: { history: items } }, ); @@ -108,8 +105,7 @@ describe("useStreamHistoryWindow", () => { configurable: true, }); const { result, rerender } = renderHook( - ({ history }) => - useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }), + ({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }), { initialProps: { history: [ @@ -142,7 +138,6 @@ describe("useStreamHistoryWindow", () => { }); const { result } = renderHook(() => useStreamHistoryWindow({ - agentId: "agent-1", items: [ message("u1", "user_message"), message("a1", "assistant_message"), diff --git a/packages/app/src/agent-stream/use-stream-history-window.ts b/packages/app/src/agent-stream/use-stream-history-window.ts index 340ecb4da4..cc6d5425d6 100644 --- a/packages/app/src/agent-stream/use-stream-history-window.ts +++ b/packages/app/src/agent-stream/use-stream-history-window.ts @@ -28,22 +28,17 @@ function findEarlierHistoryWindowStart(input: { } interface HistoryWindowBoundary { - agentId: string; boundaryItemId: string | null; initialized: boolean; } function reconcileHistoryWindow(input: { current: HistoryWindowBoundary; - agentId: string; boundaryIndex: number; initialBoundaryItemId: string | null; hasItems: boolean; }): HistoryWindowBoundary { - const { current, agentId, boundaryIndex, initialBoundaryItemId, hasItems } = input; - if (current.agentId !== agentId) { - return { agentId, boundaryItemId: initialBoundaryItemId, initialized: hasItems }; - } + const { current, boundaryIndex, initialBoundaryItemId, hasItems } = input; if (!current.initialized && hasItems) { return { ...current, boundaryItemId: initialBoundaryItemId, initialized: true }; } @@ -54,28 +49,26 @@ function reconcileHistoryWindow(input: { } export function useStreamHistoryWindow(input: { - agentId: string; items: StreamItem[]; loadRemoteOlder: () => boolean | Promise; }) { - const { agentId, items, loadRemoteOlder } = input; + const { items, loadRemoteOlder } = input; const initialStart = useMemo( () => findMountedWindowStart({ items, minMountedCount: getMountedRecentStreamItems() }), [items], ); const initialBoundaryItemId = initialStart === 0 ? null : (items[initialStart]?.id ?? null); const [window, setWindow] = useState(() => ({ - agentId, boundaryItemId: initialBoundaryItemId, initialized: items.length > 0, })); - const boundaryItemId = window.agentId === agentId ? window.boundaryItemId : null; + const boundaryItemId = window.boundaryItemId; const boundaryIndex = useMemo( () => (boundaryItemId === null ? -1 : items.findIndex((item) => item.id === boundaryItemId)), [boundaryItemId, items], ); let start = initialStart; - if (window.agentId === agentId && window.initialized) { + if (window.initialized) { if (window.boundaryItemId === null) { start = 0; } else if (boundaryIndex >= 0) { @@ -87,13 +80,12 @@ export function useStreamHistoryWindow(input: { setWindow((current) => reconcileHistoryWindow({ current, - agentId, boundaryIndex, initialBoundaryItemId, hasItems: items.length > 0, }), ); - }, [agentId, boundaryIndex, initialBoundaryItemId, items.length]); + }, [boundaryIndex, initialBoundaryItemId, items.length]); const revealLoadedHistory = useCallback( (itemId?: string): boolean => { @@ -106,13 +98,12 @@ export function useStreamHistoryWindow(input: { return false; } setWindow({ - agentId, boundaryItemId: items[nextStart]?.id ?? null, initialized: true, }); return true; }, - [agentId, items, start], + [items, start], ); const loadOlder = useCallback(async (): Promise => { return revealLoadedHistory() || (await loadRemoteOlder()); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 15d9435fd7..ab9d3b8258 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -430,10 +430,6 @@ const AgentStreamViewComponent = forwardRef { - setExpandedInlineToolCallIds(new Set()); - setExpandedTurnHostIds(new Set()); - }, [agentId]); const isActive = useRetainedPanelActive(); const openInlineFile = useStableEvent( @@ -476,7 +472,7 @@ const AgentStreamViewComponent = forwardRef { if (!isActive) inlinePathOpener.cancel(); return () => inlinePathOpener.cancel(); - }, [inlinePathOpener, isActive, agentId]); + }, [inlinePathOpener, isActive]); const handleInlinePathPress = useStableEvent( (target: InlinePathTarget, disposition: OpenFileDisposition) => { if (isActive) void inlinePathOpener.open(target, disposition); @@ -551,7 +547,6 @@ const AgentStreamViewComponent = forwardRef { expect(result.current.isSubmitting).toBe(true); expect(result.current.draftAgent).toEqual({ currentAttempt: attempt }); - expect(result.current.submittedStreamItems).toEqual([ - { - kind: "user_message", - id: "msg-prepared", - clientMessageId: "msg-prepared", - text: "build this", - timestamp: attempt.timestamp, - images: [image], - attachments: [attachment], - }, - ]); expect(createRequest).not.toHaveBeenCalled(); await act(async () => { diff --git a/packages/app/src/composer/draft/create-flow.ts b/packages/app/src/composer/draft/create-flow.ts index bd4d0fe95c..28ab74d145 100644 --- a/packages/app/src/composer/draft/create-flow.ts +++ b/packages/app/src/composer/draft/create-flow.ts @@ -8,15 +8,8 @@ import { import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store"; import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer"; import { useSessionStore } from "@/stores/session-store"; -import { - createUserMessage, - type StreamItem, - type UserMessageImageAttachment, -} from "@/types/stream"; +import { createUserMessage, type UserMessageImageAttachment } from "@/types/stream"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; -import type { PendingMessageSubmission } from "@/composer/submission/model"; - -const EMPTY_STREAM_ITEMS: StreamItem[] = []; interface CreateAttempt { clientMessageId: string; @@ -153,38 +146,6 @@ export function useDraftAgentCreateFlow({ const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : ""; const isSubmitting = machine.tag === "creating"; - const submittedStreamItems = useMemo(() => { - if (machine.tag !== "creating") { - return EMPTY_STREAM_ITEMS; - } - - if ( - !machine.attempt.text && - (!machine.attempt.images || machine.attempt.images.length === 0) && - (!machine.attempt.attachments || machine.attempt.attachments.length === 0) - ) { - return EMPTY_STREAM_ITEMS; - } - - return [ - createUserMessage({ - clientMessageId: machine.attempt.clientMessageId, - text: machine.attempt.text, - timestamp: machine.attempt.timestamp, - images: machine.attempt.images, - attachments: machine.attempt.attachments, - }), - ]; - }, [machine]); - const pendingMessageSubmissions = useMemo(() => { - if (machine.tag !== "creating") return []; - return [ - { - clientMessageId: machine.attempt.clientMessageId, - }, - ]; - }, [machine]); - const draftAgent = machine.tag === "creating" ? machine.draftAgent : null; const startCreateAttempt = useCallback( (attempt: CreateAttempt) => { @@ -365,8 +326,6 @@ export function useDraftAgentCreateFlow({ machine, formErrorMessage, isSubmitting, - submittedStreamItems, - pendingMessageSubmissions, draftAgent, handleCreateFromInput, continueCreateFromAttempt, diff --git a/packages/app/src/composer/draft/timeline.test.ts b/packages/app/src/composer/draft/timeline.test.ts new file mode 100644 index 0000000000..a1344f8830 --- /dev/null +++ b/packages/app/src/composer/draft/timeline.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import type { AgentAttachment } from "@getpaseo/protocol/messages"; +import type { PendingCreateAttempt } from "@/stores/create-flow-store"; +import type { UserMessageImageAttachment } from "@/types/stream"; +import { DRAFT_CAPABILITIES, resolveDraftTimeline } from "./timeline"; + +const image: UserMessageImageAttachment = { + id: "image-1", + mimeType: "image/png", + storageType: "web-indexeddb", + storageKey: "image-key", + createdAt: 123, +}; +const attachment = { + type: "review", + cwd: "/repo", + summary: "Review", +} as unknown as AgentAttachment; + +function pendingAttempt(overrides: Partial = {}): PendingCreateAttempt { + return { + draftId: "draft-1", + serverId: "server-1", + agentId: null, + clientMessageId: "draft-1:initial-message", + text: "build this", + timestamp: Date.parse("2026-05-25T00:00:00.000Z"), + lifecycle: "active", + images: [image], + attachments: [attachment], + ...overrides, + }; +} + +describe("resolveDraftTimeline", () => { + it("shows the sent prompt pending with the turn working", () => { + const timeline = resolveDraftTimeline({ + pending: pendingAttempt(), + serverId: "server-1", + tabId: "tab-1", + cwd: "/repo", + }); + + expect(timeline?.agent).toEqual({ + serverId: "server-1", + id: "tab-1", + status: "running", + cwd: "/repo", + capabilities: DRAFT_CAPABILITIES, + }); + expect(timeline?.streamItems).toEqual([ + { + kind: "user_message", + id: "draft-1:initial-message", + clientMessageId: "draft-1:initial-message", + text: "build this", + timestamp: new Date("2026-05-25T00:00:00.000Z"), + images: [image], + attachments: [attachment], + }, + ]); + expect(timeline?.pendingMessageSubmissions).toEqual([ + { clientMessageId: "draft-1:initial-message" }, + ]); + expect(timeline?.turnPresentation.isActive).toBe(true); + }); + + it("keeps the prompt once the agent is created, until the tab retargets", () => { + const timeline = resolveDraftTimeline({ + pending: pendingAttempt({ lifecycle: "sent", agentId: "agent-1" }), + serverId: "server-1", + tabId: "tab-1", + cwd: "/repo", + }); + + expect(timeline?.streamItems).toHaveLength(1); + }); + + it("shows only the working turn for a prompt without content", () => { + const timeline = resolveDraftTimeline({ + pending: pendingAttempt({ text: "", images: undefined, attachments: undefined }), + serverId: "server-1", + tabId: "tab-1", + cwd: "/repo", + }); + + expect(timeline?.streamItems).toEqual([]); + expect(timeline?.turnPresentation.isActive).toBe(true); + }); + + it("shows no timeline before a prompt is sent, after it fails, or on another host", () => { + const base = { serverId: "server-1", tabId: "tab-1", cwd: "/repo" }; + + expect(resolveDraftTimeline({ ...base, pending: undefined })).toBeNull(); + expect( + resolveDraftTimeline({ ...base, pending: pendingAttempt({ lifecycle: "abandoned" }) }), + ).toBeNull(); + expect( + resolveDraftTimeline({ ...base, pending: pendingAttempt({ serverId: "server-2" }) }), + ).toBeNull(); + expect(resolveDraftTimeline({ ...base, cwd: null, pending: pendingAttempt() })).toBeNull(); + }); +}); diff --git a/packages/app/src/composer/draft/timeline.ts b/packages/app/src/composer/draft/timeline.ts new file mode 100644 index 0000000000..bbc0aecbce --- /dev/null +++ b/packages/app/src/composer/draft/timeline.ts @@ -0,0 +1,102 @@ +import { useMemo } from "react"; +import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types"; +import type { PendingMessageSubmission } from "@/composer/submission/model"; +import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine"; +import { type PendingCreateAttempt, useCreateFlowStore } from "@/stores/create-flow-store"; +import { useWorkspaceFields } from "@/stores/session-store-hooks"; +import { + resolveTurnPresentation, + TURN_LIVENESS_IDLE, + type TurnPresentation, +} from "@/timeline/turn-liveness"; +import { createUserMessage, type StreamItem } from "@/types/stream"; +import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model"; +import { resolveDraftWorkingDirectory } from "@/composer/draft/workspace-tab-core"; + +export const DRAFT_CAPABILITIES: AgentCapabilityFlags = { + supportsStreaming: true, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, +}; + +export interface DraftConversation { + tabId: string; + workspaceId: string; + draftId: string; + setup?: WorkspaceDraftTabSetup; +} + +/** The timeline a draft shows while its first prompt creates the agent. */ +export interface DraftTimeline { + agent: AgentScreenAgent; + streamItems: StreamItem[]; + pendingMessageSubmissions: readonly PendingMessageSubmission[]; + turnPresentation: TurnPresentation; +} + +const EMPTY_STREAM_ITEMS: StreamItem[] = []; + +/** + * The sent prompt, pending, with the turn working. The created agent's timeline carries the same + * row (same client message id), so the handoff only changes who owns it. + */ +export function resolveDraftTimeline(input: { + pending: PendingCreateAttempt | undefined; + serverId: string; + tabId: string; + cwd: string | null; +}): DraftTimeline | null { + const { pending, serverId, tabId, cwd } = input; + if (!pending || pending.serverId !== serverId || pending.lifecycle === "abandoned" || !cwd) { + return null; + } + const hasContent = + pending.text.length > 0 || + (pending.images?.length ?? 0) > 0 || + (pending.attachments?.length ?? 0) > 0; + return { + agent: { serverId, id: tabId, status: "running", cwd, capabilities: DRAFT_CAPABILITIES }, + streamItems: hasContent + ? [ + createUserMessage({ + clientMessageId: pending.clientMessageId, + text: pending.text, + timestamp: new Date(pending.timestamp), + images: pending.images, + attachments: pending.attachments, + }), + ] + : EMPTY_STREAM_ITEMS, + pendingMessageSubmissions: [{ clientMessageId: pending.clientMessageId }], + turnPresentation: resolveTurnPresentation(TURN_LIVENESS_IDLE, true), + }; +} + +export function useDraftTimeline(input: { + serverId: string; + draft: DraftConversation | null; +}): DraftTimeline | null { + const { serverId, draft } = input; + const pending = useCreateFlowStore((state) => + draft ? state.pendingByDraftId[draft.draftId] : undefined, + ); + const workspaceDirectory = useWorkspaceFields( + serverId, + draft?.workspaceId ?? null, + (workspace) => workspace.workspaceDirectory, + ); + const cwd = draft + ? resolveDraftWorkingDirectory({ + workspaceDirectory: workspaceDirectory || null, + initialSetup: draft.setup ?? null, + }) + : null; + const tabId = draft?.tabId ?? ""; + return useMemo( + () => resolveDraftTimeline({ pending, serverId, tabId, cwd }), + [cwd, pending, serverId, tabId], + ); +} diff --git a/packages/app/src/composer/draft/workspace-tab-core.ts b/packages/app/src/composer/draft/workspace-tab-core.ts index 26bf548064..89211571a2 100644 --- a/packages/app/src/composer/draft/workspace-tab-core.ts +++ b/packages/app/src/composer/draft/workspace-tab-core.ts @@ -1,4 +1,5 @@ import { resolveSubmissionReadiness } from "@/provider-selection/provider-selection"; +import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model"; export interface WorkspaceDraftAutoSubmitConfig { provider: string; @@ -50,3 +51,13 @@ export function validateDraftSubmission(input: { }); return readiness.ok ? null : (readiness.reason ?? null); } + +export function resolveDraftWorkingDirectory(input: { + workspaceDirectory: string | null; + initialSetup: WorkspaceDraftTabSetup | null; +}): string | null { + if (input.initialSetup) { + return input.initialSetup.cwd; + } + return input.workspaceDirectory; +} diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index c4ef45cb3f..2b99ac8c09 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -1,21 +1,17 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; -import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native"; +import { Keyboard, StyleSheet as RNStyleSheet, Text, View } from "react-native"; import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; -import { ComposerDock } from "@/composer/dock"; import { useContainerWidthBelow } from "@/hooks/use-container-width"; import invariant from "tiny-invariant"; import { Composer } from "@/composer"; -import { useComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session"; -import { FileDropZone } from "@/components/file-drop/file-drop-zone"; +import { type ComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session"; import { ComposerImportPill } from "@/composer/draft/import-pill"; import { COMPOSER_PILL_CLEARANCE } from "@/composer/pill-styles"; -import { AgentStreamView } from "@/agent-stream/view"; import { composerWorkspaceAttachment } from "@/composer/attachments/workspace"; import { useAgentInputDraft } from "@/composer/draft/input-draft"; import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "@/composer/draft/create-flow"; -import { resolveTurnPresentation, TURN_LIVENESS_IDLE } from "@/timeline/turn-liveness"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config"; import { buildDraftStoreKey } from "@/stores/draft-keys"; @@ -25,13 +21,13 @@ import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration"; import { encodeImages } from "@/utils/encode-images"; -import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus"; import { + resolveDraftWorkingDirectory, shouldAllowEmptyDraftText, validateDraftSubmission, } from "@/composer/draft/workspace-tab-core"; -import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types"; +import { DRAFT_CAPABILITIES } from "@/composer/draft/timeline"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { WorkspaceComposerAttachment } from "@/attachments/types"; @@ -50,16 +46,6 @@ import { import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view"; import { useSettings } from "@/hooks/use-settings"; -const EMPTY_PENDING_PERMISSIONS = new Map(); -const DRAFT_CAPABILITIES: AgentCapabilityFlags = { - supportsStreaming: true, - supportsSessionPersistence: false, - supportsDynamicModes: false, - supportsMcpServers: false, - supportsReasoningStream: false, - supportsToolInvocations: false, -}; - interface AutoSubmitConfig { provider: string; modeId: string | null; @@ -260,25 +246,15 @@ function buildDraftInitialValues(input: { }; } -function resolveDraftWorkingDirectory(input: { - workspaceDirectory: string | null; - initialSetup: WorkspaceDraftTabSetup | null; -}): string | null { - if (input.initialSetup) { - return input.initialSetup.cwd; - } - return input.workspaceDirectory; -} - -interface WorkspaceDraftAgentTabProps { +interface WorkspaceDraftComposerProps { serverId: string; workspaceId: string; tabId: string; draftId: string; initialSetup?: WorkspaceDraftTabSetup; isPaneFocused: boolean; + editingSession: ComposerEditingSession; onCreated: (snapshot: AgentSnapshotPayload) => void; - onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => void; onOpenImportSheet?: () => void; } @@ -292,17 +268,21 @@ function resolveImportPillPress( return onOpenImportSheet ?? null; } -export function WorkspaceDraftAgentTab({ +/** + * Creates the agent from the draft's first prompt. The conversation panel shows that prompt in + * the timeline from the create-flow store while the agent is created. + */ +export function WorkspaceDraftComposer({ serverId, workspaceId, tabId, draftId, initialSetup = undefined, isPaneFocused, + editingSession, onCreated, - onOpenWorkspaceFile, onOpenImportSheet, -}: WorkspaceDraftAgentTabProps) { +}: WorkspaceDraftComposerProps) { const { t } = useTranslation(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); @@ -387,7 +367,6 @@ export function WorkspaceDraftAgentTab({ }, [pendingCreateAttempt]); const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true; const isCompactFormFactor = useIsCompactFormFactor(); - const editingSession = useComposerEditingSession("chat"); const isComposerFullscreen = useComposerFullscreen(editingSession); const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow( COMPACT_CONTENT_WIDTH, @@ -421,82 +400,71 @@ export function WorkspaceDraftAgentTab({ [isCompactFormFactor, openInSidePane, serverId, workspaceId], ); - const { - formErrorMessage, - isSubmitting, - submittedStreamItems, - pendingMessageSubmissions, - draftAgent, - handleCreateFromInput, - continueCreateFromAttempt, - } = useDraftAgentCreateFlow({ - draftId, - getPendingServerId: () => serverId, - initialAttempt: initialCreateAttempt, - allowEmptyText: allowsEmptyAutoSubmit, - validateBeforeSubmit: ({ text, attachments }) => { - const allowsEmptyDraftText = shouldAllowEmptyDraftText({ - allowsEmptyAutoSubmit, - attachments, - }); - return validateDraftSubmission({ - text, - allowsEmptyAutoSubmit: allowsEmptyDraftText, - composerState, - autoSubmitConfig, - workspaceDirectory: draftWorkingDirectory, - isConnected, - }); - }, - onBeforeSubmit: async () => { - await composerState.persistFormPreferences(); - if (isWeb) { - (document.activeElement as HTMLElement | null)?.blur?.(); - } - Keyboard.dismiss(); - }, - buildDraftAgent: (attempt) => - buildDraftAgentSnapshot({ - attempt, - serverId, - tabId, - workspaceDirectory: draftWorkingDirectory, - autoSubmitConfig, - composerState, - selectModelMessage: t("workspaceSetup.errors.selectModel"), - }), - createRequest: async ({ attempt, text, images, attachments, cwd }) => { - if (pendingAutoSubmit?.agentCreation) { - const result = await pendingAutoSubmit.agentCreation.result; - return { agentId: result.id, result }; - } - return submitDraftCreateRequest({ - draftId, - attempt, - text, - images, - attachments, - cwd, - client, - workspaceDirectory: draftWorkingDirectory, - workspaceId: workspaceFields?.id ?? null, - autoSubmitConfig, - composerState, - hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), - selectModelMessage: t("workspaceSetup.errors.selectModel"), - }); - }, - onCreateSuccess: ({ result }) => { - clearDraftInput("sent"); - clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey }); - useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId }); - onCreated(result); - }, - }); - const turnPresentation = useMemo( - () => resolveTurnPresentation(TURN_LIVENESS_IDLE, pendingMessageSubmissions.length > 0), - [pendingMessageSubmissions], - ); + const { formErrorMessage, isSubmitting, handleCreateFromInput, continueCreateFromAttempt } = + useDraftAgentCreateFlow({ + draftId, + getPendingServerId: () => serverId, + initialAttempt: initialCreateAttempt, + allowEmptyText: allowsEmptyAutoSubmit, + validateBeforeSubmit: ({ text, attachments }) => { + const allowsEmptyDraftText = shouldAllowEmptyDraftText({ + allowsEmptyAutoSubmit, + attachments, + }); + return validateDraftSubmission({ + text, + allowsEmptyAutoSubmit: allowsEmptyDraftText, + composerState, + autoSubmitConfig, + workspaceDirectory: draftWorkingDirectory, + isConnected, + }); + }, + onBeforeSubmit: async () => { + await composerState.persistFormPreferences(); + if (isWeb) { + (document.activeElement as HTMLElement | null)?.blur?.(); + } + Keyboard.dismiss(); + }, + buildDraftAgent: (attempt) => + buildDraftAgentSnapshot({ + attempt, + serverId, + tabId, + workspaceDirectory: draftWorkingDirectory, + autoSubmitConfig, + composerState, + selectModelMessage: t("workspaceSetup.errors.selectModel"), + }), + createRequest: async ({ attempt, text, images, attachments, cwd }) => { + if (pendingAutoSubmit?.agentCreation) { + const result = await pendingAutoSubmit.agentCreation.result; + return { agentId: result.id, result }; + } + return submitDraftCreateRequest({ + draftId, + attempt, + text, + images, + attachments, + cwd, + client, + workspaceDirectory: draftWorkingDirectory, + workspaceId: workspaceFields?.id ?? null, + autoSubmitConfig, + composerState, + hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), + selectModelMessage: t("workspaceSetup.errors.selectModel"), + }); + }, + onCreateSuccess: ({ result }) => { + clearDraftInput("sent"); + clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey }); + useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId }); + onCreated(result); + }, + }); useAgentControlCommandCenterActions({ sourceId: `draft:${serverId}:${tabId}`, enabled: isPaneFocused && !isSubmitting, @@ -600,75 +568,48 @@ export function WorkspaceDraftAgentTab({ }), [composerState.agentControls, handleDropdownCloseFocus, isSubmitting], ); - const dockContent = ( - - {isSubmitting && draftAgent ? ( - - - - ) : ( - - - {formErrorMessage ? ( - - {formErrorMessage} - - ) : null} - - - )} - - ); - return ( - - - {dockContent} - - {importPillPress && !isComposerFullscreen ? ( - - - - - - ) : null} - + + {formErrorMessage ? ( + + + {formErrorMessage} + - - + ) : null} + {importPillPress && !isComposerFullscreen ? ( + + + + + + ) : null} + + ); } @@ -682,27 +623,10 @@ const animatedStaticStyles = RNStyleSheet.create({ }); const styles = StyleSheet.create((theme) => ({ - container: { - flex: 1, + formErrorRow: { width: "100%", - backgroundColor: theme.colors.surface0, - }, - contentContainer: { - flex: 1, - }, - streamContainer: { - flex: 1, - }, - scrollView: { - flex: 1, - }, - configScrollContent: { paddingHorizontal: theme.spacing[4], - paddingTop: theme.spacing[4], - paddingBottom: theme.spacing[6], - }, - configSection: { - gap: theme.spacing[3], + alignItems: "center", }, importPillRow: { width: "100%", @@ -722,8 +646,10 @@ const styles = StyleSheet.create((theme) => ({ maxWidth: theme.contentMaxWidth, flexDirection: "row", }, - errorContainer: { - marginTop: theme.spacing[2], + formError: { + width: "100%", + maxWidth: theme.contentMaxWidth, + marginBottom: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], borderRadius: theme.borderRadius.md, diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 32ad1aedbb..1373a5d283 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -78,7 +78,8 @@ import { deriveRouteBottomAnchorIntent, deriveRouteBottomAnchorRequest, } from "@/screens/agent/agent-ready-screen-bottom-anchor"; -import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab"; +import { WorkspaceDraftComposer } from "@/composer/draft/workspace-tab"; +import { type DraftConversation, useDraftTimeline } from "@/composer/draft/timeline"; import { AgentTracks } from "@/panels/agent-tracks"; import { ParentAgentCallout, useParentAgentId } from "@/subagents/parent"; import { useCreateFlowStore } from "@/stores/create-flow-store"; @@ -90,14 +91,13 @@ import { useSessionStore, } from "@/stores/session-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; -import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; +import { buildWorkspaceTabPersistenceKey, type WorkspaceTabTarget } from "@/workspace-tabs/model"; import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view"; import { useSettings } from "@/hooks/use-settings"; import type { Theme } from "@/styles/theme"; import type { PendingPermission } from "@/types/shared"; -import type { StreamItem, TodoEntry } from "@/types/stream"; +import type { StreamItem } from "@/types/stream"; import type { ViewedTimelineStatus, ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync"; -import { useArchiveFinishedSubagents, useSubagentsForParent } from "@/subagents"; import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces"; @@ -225,6 +225,29 @@ function AgentLoadErrorView({ message, onRetry }: { message: string; onRetry: () ); } +/** A draft has no agent to wait for; an agent shows its loading and error states until ready. */ +function renderConversationNonReadyView(args: { + draft: DraftConversation | null; + agentId: string | undefined; + cwd: string | null; + viewState: AgentScreenViewState; + effectiveAgent: AgentScreenAgent | null; + t: TFunction; + onRetryLoad: () => void; +}): React.ReactElement | null { + if (args.draft) { + return null; + } + const nonReadyView = renderChatAgentNonReadyView(args); + if (nonReadyView) { + return nonReadyView; + } + invariant(args.agentId, "agent id is defined when agent content is ready"); + invariant(args.effectiveAgent, "effectiveAgent is defined when the non-ready view is absent"); + invariant(args.cwd, "agent cwd is defined when agent content is ready"); + return null; +} + function renderChatAgentNonReadyView(args: { viewState: AgentScreenViewState; effectiveAgent: AgentScreenAgent | null; @@ -303,17 +326,31 @@ function storeFetchedAgentDetail(input: { return hydrated; } -function AgentPanel() { +type ConversationTarget = Extract; + +/** The agent a conversation shows; a draft has none until its first prompt creates one. */ +function resolveConversationAgentId(target: ConversationTarget): string | undefined { + return target.kind === "agent" ? target.agentId.trim() || undefined : undefined; +} + +/** + * Drafts and agents render through one chain so the timeline stays mounted when the draft's + * first prompt creates the agent and the tab retargets. The agent gates are idle for a draft. + */ +export function AgentConversationPanel() { const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext(); const { isInteractive } = usePaneFocus(); - invariant(target.kind === "agent", "AgentPanel requires agent target"); + invariant( + target.kind === "agent" || target.kind === "draft", + "AgentConversationPanel requires an agent or draft target", + ); return ( @@ -321,55 +358,6 @@ function AgentPanel() { ); } -function DraftPanel() { - const { - serverId, - workspaceId, - tabId, - target, - openFileInWorkspace, - openImportSheet, - retargetCurrentTab, - } = usePaneContext(); - const { isInteractive } = usePaneFocus(); - invariant(target.kind === "draft", "DraftPanel requires draft target"); - - const handleCreated = useCallback( - (agentSnapshot: Parameters[0]) => { - const normalized = normalizeAgentSnapshot(agentSnapshot, serverId); - const agent = applyLegacyDaemonWorkspaceOwnership({ serverId, agent: normalized }); - getHostRuntimeStore().acceptAgentSnapshot(serverId, agent); - retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id }); - }, - [retargetCurrentTab, serverId], - ); - - return ( - - ); -} - -export function AgentConversationPanel() { - const { target } = usePaneContext(); - if (target.kind === "draft") { - return ; - } - if (target.kind === "agent") { - return ; - } - invariant(false, "AgentConversationPanel requires an agent or draft target"); -} - export const agentPanelRegistration = definePanel("agent", { component: AgentConversationPanel, useDescriptor: useAgentDescriptor, @@ -444,18 +432,18 @@ type AgentLookupState = function AgentPanelContent({ serverId, workspaceId, - agentId, + target, isPaneFocused, onOpenWorkspaceFile, }: { serverId: string; workspaceId: string; - agentId: string; + target: ConversationTarget; isPaneFocused: boolean; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { const { t } = useTranslation(); - const resolvedAgentId = agentId.trim() || undefined; + const resolvedAgentId = resolveConversationAgentId(target); const resolvedServerId = serverId.trim() || undefined; const daemons = useHosts(); const runtimeServerId = resolvedServerId ?? ""; @@ -484,7 +472,7 @@ function AgentPanelContent({ : runtimeConnectionStatus; const lastConnectionError = runtimeLastError; - if (!resolvedServerId || (!runtimeClient && !hasCachedAgent)) { + if (target.kind === "agent" && (!resolvedServerId || (!runtimeClient && !hasCachedAgent))) { return ( ; isConnected: boolean; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { const { t } = useTranslation(); + const agentId = resolveConversationAgentId(target); const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent(); const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); const projectPlacement = useStoreWithEqualityFn( @@ -641,7 +630,7 @@ function AgentPanelBody({ } : null; - if (!agent) { + if (target.kind === "agent" && !agent) { return ( @@ -655,7 +644,7 @@ function AgentPanelBody({ ; isConnected: boolean; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { const { t } = useTranslation(); + const { tabId } = usePaneContext(); + const agentId = resolveConversationAgentId(target); const isPaneVisible = useRetainedPanelActive(); const { api: toastApi, toast: toastState, dismiss: dismissToast } = useToastHost(); const { isArchivingAgent } = useArchiveAgent(); @@ -988,17 +979,23 @@ function ChatAgentContent({ visibilityCatchUpStatus, ]); - const nonReadyView = renderChatAgentNonReadyView({ + const draft = useMemo( + () => + target.kind === "draft" + ? { tabId, workspaceId, draftId: target.draftId, setup: target.setup } + : null, + [tabId, target, workspaceId], + ); + const nonReadyView = renderConversationNonReadyView({ + draft, + agentId, + cwd: agentState.cwd, viewState, effectiveAgent, t, onRetryLoad: retryAgentLoad, }); if (nonReadyView) return nonReadyView; - invariant(agentId, "agent id is defined when agent content is ready"); - invariant(effectiveAgent, "effectiveAgent is defined when the non-ready view is absent"); - const agentCwd = agentState.cwd; - invariant(agentCwd, "agent cwd is defined when agent content is ready"); const showHistorySyncOverlay = viewState.tag === "ready" && viewState.sync.status === "catching_up" && @@ -1013,7 +1010,8 @@ function ChatAgentContent({ - state.sessions[serverId]?.agentTasks.get(agentId), - ); - const archiveFinishedSubagents = useArchiveFinishedSubagents({ - serverId, - parentAgentId: agentId, - rows: subagentRows, - }); const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent; const parentAgentId = useParentAgentId(serverId, agentId); // With a composer the parent is a pill above it; without one, the box takes the composer's place. @@ -1161,7 +1154,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ const parentCallout = parentAgentId ? ( ) : null; - const composerSection = showsComposer ? ( + const agentComposerSection = showsComposer ? ( + ) : ( + agentComposerSection + ); const streamContent = ( {showsComposer ? null : } @@ -1191,6 +1194,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ serverId={serverId} agentId={agentId} agent={effectiveAgent} + draft={draft} routeBottomAnchorRequest={routeBottomAnchorRequest} hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory} hasActiveComposer={hasActiveComposer} @@ -1198,16 +1202,12 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ onOpenWorkspaceFile={onOpenWorkspaceFile} /> - {hasActiveComposer ? ( + {hasActiveComposer && !draft ? ( ) : null} @@ -1263,7 +1263,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ ); return wrapAgentCommandProvider({ - enabled: showsComposer && hasActiveComposer, + enabled: showsComposer && hasActiveComposer && !draft, serverId, agentId, editingSession: composerEditingSession, @@ -1355,6 +1355,7 @@ const AgentStreamSection = memo(function AgentStreamSection({ serverId, agentId, agent, + draft, routeBottomAnchorRequest, hasAppliedAuthoritativeHistory, hasActiveComposer, @@ -1364,7 +1365,8 @@ const AgentStreamSection = memo(function AgentStreamSection({ streamViewRef: React.RefObject; serverId: string; agentId?: string; - agent: AgentScreenAgent; + agent: AgentScreenAgent | null; + draft: DraftConversation | null; routeBottomAnchorRequest: RouteBottomAnchorRequest; hasAppliedAuthoritativeHistory: boolean; hasActiveComposer: boolean; @@ -1422,7 +1424,31 @@ const AgentStreamSection = memo(function AgentStreamSection({ } return new Map(pendingPermissionList.map((permission) => [permission.key, permission])); }, [pendingPermissionList]); + // A draft's timeline exists only while its first prompt creates the agent. The created agent's + // timeline then replaces this input in the same mounted view. + const draftTimeline = useDraftTimeline({ serverId, draft }); + if (draftTimeline) { + return ( + + ); + } + if (!agent) { + return null; + } return ( [0]) => { + const normalized = normalizeAgentSnapshot(agentSnapshot, serverId); + const agent = applyLegacyDaemonWorkspaceOwnership({ serverId, agent: normalized }); + getHostRuntimeStore().acceptAgentSnapshot(serverId, agent); + retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id }); + }, + [retargetCurrentTab, serverId], + ); + + return ( + + ); +} + const AgentComposerSection = memo(function AgentComposerSection({ agentId, serverId, diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx index 433264eac9..ee1926565f 100644 --- a/packages/app/src/panels/agent-tracks.tsx +++ b/packages/app/src/panels/agent-tracks.tsx @@ -9,11 +9,11 @@ import { useSettings } from "@/hooks/use-settings"; import { PluginComposerPills } from "@/plugins"; import { useSessionStore } from "@/stores/session-store"; import { - type ArchiveFinishedStatus, + useArchiveFinishedSubagents, useArchiveSubagent, useDetachSubagent, useSubagentSheetTab, - type SubagentRow, + useSubagentsForParent, } from "@/subagents"; import { ParentAgentPill } from "@/subagents/parent"; import { SubagentsTrack } from "@/subagents/track"; @@ -35,20 +35,21 @@ export const AgentTracks = memo(function AgentTracks({ workspaceId, agentId, parentAgentId, - subagentRows, - tasks, - archiveFinishedStatus, - onArchiveFinished, }: { serverId: string; workspaceId: string; agentId: string; parentAgentId: string | null; - subagentRows: SubagentRow[]; - tasks: TodoEntry[] | undefined; - archiveFinishedStatus: ArchiveFinishedStatus; - onArchiveFinished: () => void; }): ReactElement { + const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId }); + const tasks = useSessionStore((state): TodoEntry[] | undefined => + state.sessions[serverId]?.agentTasks.get(agentId), + ); + const archiveFinishedSubagents = useArchiveFinishedSubagents({ + serverId, + parentAgentId: agentId, + rows: subagentRows, + }); const { tabId } = usePaneContext(); const isCompact = useIsCompactFormFactor(); const openInSidePane = useSettings((settings) => settings.openInSidePane); @@ -119,8 +120,8 @@ export const AgentTracks = memo(function AgentTracks({ onOpenProviderSubagent={handleOpenProviderSubagent} sheetTabFor={sheetTabFor} onArchiveSubagent={archiveSubagent} - onArchiveFinished={onArchiveFinished} - archiveFinishedStatus={archiveFinishedStatus} + onArchiveFinished={archiveFinishedSubagents.archiveFinished} + archiveFinishedStatus={archiveFinishedSubagents.status} onDetachSubagent={canDetachSubagents ? detachSubagent : undefined} />