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} />