Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] f792ac8a4e chore: update panel scrolling branch from main 2026-11-06 07:49:20 +00:00
paseo-bot[bot] e8a9e2d699 chore(android): carry panel scrolling fix onto the current timeline 2026-11-06 07:48:53 +00:00
Mohamed Boudra e79e826e23 Stop re-rendering every chat message on each agent update (#8916)
* fix(app): keep unchanged agent fields when an agent update is accepted

* fix(app): share unchanged agent fields at the store write, not in the update policy
2026-11-06 14:48:10 +07:00
paseo-bot[bot]andluckxine 5feec6fa35 fix(android): contain nested panel motion in the current timeline
Identify the forward-order timeline by its existing native identity so panel edges stay still while ordinary parents retain Android handoff. Preserve the native content-position corrections.

Co-authored-by: luckxine <91582901+luckxine@users.noreply.github.com>
2026-11-06 07:46:51 +00:00
Mohamed Boudra 4928500e1f fix(app): keep chat rows their size while their tab is hidden on Android (#8911) 2026-11-06 14:08:09 +07:00
Mohamed Boudra 1e2ab3d596 Keep the first prompt on screen when a new agent starts on Android (#8898)
* refactor(app): stop resetting the chat timeline when its agent id changes

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

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

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

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

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

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

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

* fix(app): load subagents and tasks only for an agent, not a draft tab
2026-11-06 13:38:10 +07:00
paseo-bot[bot] 2716bd96de test(terminal): observe original process completion after exit (#8892)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-06 06:27:04 +00:00
paseo-bot[bot]andluckxine 138d9d63ab fix(android): keep panel overscroll inside inverted timelines
Android forwards a nested panel’s unconsumed scroll and fling to its inverted parent, moving the timeline in the opposite visual direction. Keep that motion inside the panel while preserving ordinary parent scrolling and nested-scroll registration.

Co-authored-by: luckxine <91582901+luckxine@users.noreply.github.com>
2026-11-01 14:32:01 +00:00
66 changed files with 1006 additions and 1561 deletions
+11 -2
View File
@@ -179,8 +179,17 @@ This is the Android counterpart of the iOS local-simulator flow in [development.
`modules/paseo-scroll` replaces React Native's vertical scroll view with `PaseoScrollView`, which
keeps React Native's scroll manager interface and corrects `maintainVisibleContentPosition` for the
chat timeline. [agent-stream-disclosure.md](agent-stream-disclosure.md#scroll-view-corrections)
lists the corrections and the React Native issues they work around. Its instrumentation test,
`VisibleContentPositionClampTest`, mounts through React Native's own mounting manager:
lists the content-position corrections and the React Native issues they work around.
The timeline's `nativeID` (`agent-chat-scroll-native-virtualized`) also identifies it to the native
scroll owner: a nested panel's edge overshoot and unconsumed fling must leave the timeline still.
Ordinary scroll parents retain Android's handoff. Keep that identity in sync with
`agent-stream/strategy-native.tsx`; the end pin is absent while reading history, so it cannot
identify the timeline for this rule.
`VisibleContentPositionClampTest` mounts through React Native's own mounting manager;
`PanelEdgeTest` covers both panel edges, flings, interior/direct scrolling, and ordinary-parent
handoff with the real compiled scroll views:
```bash
cd packages/app/android
@@ -235,6 +235,7 @@ async function beginTimelineRowStabilityCheck(
lastTop: initialRow.getBoundingClientRect().top,
largestDownwardShift: 0,
sawMissing: false,
sawHidden: false,
};
const windowState = window as unknown as Record<string, unknown>;
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<string, unknown>;
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");
});
@@ -1,18 +1,6 @@
import {
captureChatHistory,
expectRewoundHistoryIsPrivate,
rewindForkPrompt,
closeChatHistory,
followChatHistorySource,
openChatHistory,
sendChatHistoryFromAnotherClient,
setChatHistoryViewport,
} from "../support/helpers/chat-history";
import { expect, test as base } from "../support/fixtures";
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
import {
getActiveForkAgentId,
expectActiveForkAgent,
expectChatHistoryAttachment,
expectInFlightForkAvailable,
expectLiveAssistantText,
@@ -50,97 +38,6 @@ const test = base.extend<{
test.describe("Assistant fork menu", () => {
test.describe.configure({ timeout: 180_000 });
test("reads fork history before and after sending and opens its source after reload", async ({
page,
seedForkWorkspace,
}, testInfo) => {
const initialPrompt = "emit 1 coalesced agent stream updates for readable fork history.";
const session = await seedForkWorkspace({
repoPrefix: "assistant-fork-readable-",
title: "Original conversation",
initialPrompt,
model: "ten-second-stream",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
await awaitAssistantMessage(page);
await session.client.waitForFinish(session.agentId, 45_000);
await forkMostRecentAssistantTurnToNewTab(page);
await openChatHistory(page, "draft", [initialPrompt]);
await captureChatHistory(page, testInfo, "history-wide.png");
await closeChatHistory(page);
await submitMessage(page, "emit 1 coalesced agent stream updates for the fork.");
const forkAgentId = await getActiveForkAgentId(page);
await session.client.waitForFinish(forkAgentId, 45_000);
await openAgentRoute(page, { workspaceId: session.workspaceId, agentId: forkAgentId });
await page.reload();
await setChatHistoryViewport(page, "compact");
await openChatHistory(page, "sent", [initialPrompt]);
await captureChatHistory(page, testInfo, "history-compact.png");
await followChatHistorySource(page);
await setChatHistoryViewport(page, "wide");
await expectActiveForkAgent(page, session.agentId);
await openAgentRoute(page, { workspaceId: session.workspaceId, agentId: forkAgentId });
await expectActiveForkAgent(page, forkAgentId);
await forkMostRecentAssistantTurnToNewTab(page);
await openChatHistory(page, "draft", [initialPrompt, "updates for the fork."]);
await followChatHistorySource(page);
await expectActiveForkAgent(page, forkAgentId);
});
for (const prompt of ["", "Continue the fork"]) {
test(`retains inherited context when rewinding the first fork prompt (${prompt || "history only"})`, async ({
page,
seedForkWorkspace,
}) => {
const initialPrompt = "emit 1 coalesced agent stream updates for rewindable fork history.";
const session = await seedForkWorkspace({
repoPrefix: "fork-rewind-",
title: "Rewind source",
initialPrompt,
model: "ten-second-stream",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
await awaitAssistantMessage(page);
await session.client.waitForFinish(session.agentId, 45_000);
await forkMostRecentAssistantTurnToNewTab(page);
await submitMessage(page, prompt);
const forkId = await getActiveForkAgentId(page);
await session.client.waitForFinish(forkId, 45_000);
await page.reload();
await openChatHistory(page, "sent", [initialPrompt]);
await closeChatHistory(page);
await rewindForkPrompt(page);
await expectRewoundHistoryIsPrivate(page, session.agentId, forkId);
await openChatHistory(page, "draft", [initialPrompt]);
await closeChatHistory(page);
await submitMessage(page, prompt);
await session.client.waitForFinish(forkId, 45_000);
await page.reload();
await openChatHistory(page, "sent", [initialPrompt]);
});
}
test("shows history sent from another connected client without reloading", async ({
page,
seedForkWorkspace,
}) => {
const session = await seedForkWorkspace({
repoPrefix: "assistant-fork-live-history-",
title: "Live history recipient",
model: "ten-second-stream",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
const history = "[User] Context inherited on another device.";
await sendChatHistoryFromAnotherClient(session, history);
await openChatHistory(page, "sent", [history]);
await session.client.waitForFinish(session.agentId, 45_000);
});
test("forks a failed assistant turn that has no provider message id", async ({
page,
seedForkWorkspace,
@@ -112,21 +112,3 @@ export async function expectLiveAssistantText(page: Page, text: string): Promise
timeout: 60_000,
});
}
export async function getActiveForkAgentId(page: Page): Promise<string> {
const selectedTab = page
.getByTestId("workspace-tabs-row")
.getByRole("button")
.and(page.locator('[aria-selected="true"]'));
await expect(selectedTab).toHaveAttribute("data-testid", /^workspace-tab-agent_/);
const tabId = await selectedTab.getAttribute("data-testid");
if (!tabId) throw new Error("Expected a selected agent tab");
return tabId.replace("workspace-tab-agent_", "");
}
export async function expectActiveForkAgent(page: Page, agentId: string) {
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveAttribute(
"aria-selected",
"true",
);
}
@@ -1,82 +0,0 @@
import { expect, type Page, type TestInfo } from "@playwright/test";
import type { MockAgentWorkspace } from "./mock-agent";
import { getServerId } from "./server-id";
export function setChatHistoryViewport(page: Page, layout: "wide" | "compact") {
return page.setViewportSize(
layout === "wide" ? { width: 1280, height: 720 } : { width: 390, height: 844 },
);
}
export async function openChatHistory(page: Page, location: "draft" | "sent", contents: string[]) {
const testId =
location === "draft"
? "composer-chat-history-attachment-pill"
: "message-chat-history-attachment";
const attachment = page.getByTestId(testId).filter({ visible: true }).last();
await expect(attachment).toBeVisible({ timeout: 10_000 });
await attachment.click();
for (const text of contents) {
await expect(page.getByTestId("chat-history-content")).toContainText(text);
}
await expect(page.getByRole("button", { name: "Open source chat" })).toBeVisible();
}
export async function closeChatHistory(page: Page) {
await page
.getByTestId("chat-history-preview")
.getByRole("button", { name: "Close", exact: true })
.click();
}
export async function followChatHistorySource(page: Page) {
await page.getByRole("button", { name: "Open source chat" }).click();
await expect(page.getByTestId("chat-history-preview")).not.toBeVisible();
}
export async function captureChatHistory(page: Page, testInfo: TestInfo, name: string) {
// Follow the existing QA helpers: settle spring animations and hide Expo-only chrome.
await page.waitForTimeout(600);
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
await page.screenshot({ path: testInfo.outputPath(name), animations: "disabled" });
}
export async function sendChatHistoryFromAnotherClient(
session: MockAgentWorkspace,
history: string,
) {
const serverId = getServerId();
await session.client.sendAgentMessage(session.agentId, "", {
attachments: [
{
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
text: history,
chatHistorySource: { serverId, agentId: "source-agent", title: "Original conversation" },
},
],
});
}
export async function rewindForkPrompt(page: Page) {
const prompt = page.getByTestId("user-message").filter({ visible: true }).last();
await prompt.hover();
const trigger = prompt.getByRole("button", { name: "Rewind to this message", exact: true });
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
const option = page.getByRole("menuitem", { name: "Rewind conversation", exact: true });
await expect(option).toBeInViewport({ ratio: 1 });
await option.click();
await expect(option).not.toBeVisible();
}
export async function expectRewoundHistoryIsPrivate(page: Page, sourceId: string, forkId: string) {
await page.getByTestId(`workspace-tab-agent_${sourceId}`).click();
const history = page
.getByTestId("composer-chat-history-attachment-pill")
.filter({ visible: true });
await expect(history).toHaveCount(0);
await page.getByTestId(`workspace-tab-agent_${forkId}`).click();
await expect(history).toBeVisible();
}
@@ -157,10 +157,7 @@ export interface SeedDaemonClient {
sendAgentMessage(
agentId: string,
text: string,
options?: {
sourceAgentId?: string;
attachments?: import("@getpaseo/protocol/messages").AgentAttachment[];
},
options?: { sourceAgentId?: string },
): Promise<void>;
waitForFinish(
agentId: string,
@@ -0,0 +1,158 @@
package sh.paseo.scroll
import android.app.Activity
import android.content.Intent
import android.os.SystemClock
import android.view.View
import android.widget.FrameLayout
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.react.uimanager.DisplayMetricsHolder
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.views.scroll.ReactScrollView
import com.facebook.react.views.scroll.ReactScrollViewManager
import com.facebook.react.views.view.ReactViewGroup
import com.facebook.soloader.SoLoader
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/** Forward-order parent, as used by the current Android timeline, and a nested panel at its edge. */
@RunWith(AndroidJUnit4::class)
class PanelEdgeTest {
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private var activity: Activity? = null
private lateinit var manager: ReactScrollViewManager
@After fun finish() {
instrumentation.runOnMainSync { activity?.finish() }
}
@Test fun panelBottomEdgeLeavesTimelineStill() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
child.scrollTo(0, 600)
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.dispatchNestedScroll(0, 0, 0, 100, null)
child.stopNestedScroll()
assertEquals("Panel stays at its bottom", 600, child.scrollY)
assertEquals("Panel overshoot must leave timeline still", 400, parent.scrollY)
}
}
@Test fun panelTopEdgeLeavesTimelineStill() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
child.scrollTo(0, 0)
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.dispatchNestedScroll(0, 0, 0, -100, null)
child.stopNestedScroll()
assertEquals("Panel stays at its top", 0, child.scrollY)
assertEquals("Panel overshoot must leave timeline still", 400, parent.scrollY)
}
}
@Test fun panelFlingLeavesTimelineStill() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
child.scrollTo(0, 600)
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.dispatchNestedFling(0f, 1500f, false)
child.stopNestedScroll()
}
SystemClock.sleep(500)
instrumentation.runOnMainSync {
assertEquals("Panel fling must leave timeline still", 400, parent.scrollY)
}
}
@Test fun disablingChildNestedScrollPreventsHandoff() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
child.isNestedScrollingEnabled = false
assertEquals(false, child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.dispatchNestedScroll(0, 0, 0, 100, null)
assertEquals(400, parent.scrollY)
}
}
@Test fun ordinaryParentStillConsumesNestedOvershoot() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
manager.setNativeId(parent, "ordinary-scroll-parent")
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.dispatchNestedScroll(0, 0, 0, 100, null)
child.stopNestedScroll()
assertEquals("Ordinary parent retains framework handoff", 500, parent.scrollY)
}
}
@Test fun ordinaryParentStillConsumesUnconsumedFling() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
manager.setNativeId(parent, "ordinary-scroll-parent")
child.scrollTo(0, 600)
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.dispatchNestedFling(0f, 1500f, false)
child.stopNestedScroll()
}
SystemClock.sleep(500)
instrumentation.runOnMainSync { assertTrue(parent.scrollY > 400) }
}
@Test fun consumedPanelFlingDoesNotStartParentFling() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
assertEquals(false, child.dispatchNestedFling(0f, 1500f, true))
child.stopNestedScroll()
assertEquals(400, parent.scrollY)
}
}
@Test fun panelInteriorScrollingLeavesTimelineStill() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
child.scrollTo(0, 100)
assertEquals(100, child.scrollY)
assertEquals(400, parent.scrollY)
parent.scrollTo(0, 500)
assertEquals("Direct timeline scrolling remains available", 500, parent.scrollY)
}
}
private fun openPanel(): Pair<ReactScrollView, ReactScrollView> {
val started = instrumentation.startActivitySync(
Intent(instrumentation.targetContext, Activity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
activity = started
lateinit var parent: ReactScrollView
lateinit var child: ReactScrollView
instrumentation.runOnMainSync {
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(started)
SoLoader.init(started, OpenSourceMergedSoMapping)
val react = ScrollTestContext(started.applicationContext)
val context = ThemedReactContext(react, started)
manager = PaseoScrollPackage().createViewManager(react, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
parent = manager.createViewInstance(context)
manager.setNativeId(parent, "agent-chat-scroll-native-virtualized")
child = manager.createViewInstance(context)
child.isNestedScrollingEnabled = true
val content = ReactViewGroup(context)
content.addView(child, FrameLayout.LayoutParams(300, 200).apply { topMargin = 400 })
parent.addView(content)
val childContent = ReactViewGroup(context)
child.addView(childContent)
started.setContentView(FrameLayout(started).apply { addView(parent, FrameLayout.LayoutParams(300, 400)) })
parent.layout(0, 0, 300, 400)
content.layout(0, 0, 300, 2000)
child.layout(0, 400, 300, 600)
childContent.layout(0, 0, 300, 800)
parent.scrollTo(0, 400)
}
return Pair(parent, child)
}
}
@@ -0,0 +1,19 @@
package sh.paseo.scroll;
import android.content.Context;
import com.facebook.react.bridge.BridgeReactContext;
import com.facebook.react.uimanager.events.BlackHoleEventDispatcher;
import com.facebook.react.uimanager.events.EventDispatcher;
import com.facebook.react.uimanager.events.EventDispatcherProvider;
/** Native-only tests use RN's own event sink for a context without JavaScript. */
final class ScrollTestContext extends BridgeReactContext implements EventDispatcherProvider {
ScrollTestContext(Context context) {
super(context);
}
@Override
public EventDispatcher getEventDispatcher() {
return BlackHoleEventDispatcher.INSTANCE;
}
}
@@ -12,10 +12,13 @@ import com.facebook.react.views.scroll.ReactScrollView;
import com.facebook.react.views.scroll.ReactScrollViewHelper;
/**
* React Native's vertical scroll view with two corrections. Java, because the content-position
* React Native's vertical scroll view with timeline corrections. Java, because the content-position
* config type is internal to React Native's Kotlin module.
*/
final class PaseoScrollView extends ReactScrollView {
// The chat timeline, packages/app/src/agent-stream/strategy-native.tsx.
private static final String TIMELINE_NATIVE_ID = "agent-chat-scroll-native-virtualized";
// The chat timeline's end pin, packages/app/src/agent-stream/native-end-pin.tsx.
private static final String END_PIN_NATIVE_ID = "paseo-end-pin";
@@ -32,6 +35,26 @@ final class PaseoScrollView extends ReactScrollView {
super(context);
}
// A panel owns its gesture through its edges; remaining child motion must not move the chat
// behind it. The timeline's native identity stays present when its end pin is removed for
// reading history. Ordinary parents retain Android's nested-scroll handoff.
@Override
public void onNestedScroll(
View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
if (isTimeline()) return;
super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed);
}
@Override
public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) {
if (isTimeline() && !consumed) return true;
return super.onNestedFling(target, velocityX, velocityY, consumed);
}
private boolean isTimeline() {
return TIMELINE_NATIVE_ID.equals(getTag(R.id.view_tag_native_id));
}
@Override
public void setMaintainVisibleContentPosition(
@Nullable MaintainVisibleScrollPositionHelper.Config config) {
@@ -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]);
@@ -129,7 +129,8 @@ function mountList(heldRowId: string) {
}
return position - state.scroll;
};
return { render, scrollTo, heldRowTop };
const rowSize = (rowId: string) => listRef.current?.getState().sizes.get(rowId);
return { render, scrollTo, heldRowTop, rowSize };
}
it("anchors a data change on a held row whose top is above the viewport", async () => {
@@ -169,3 +170,21 @@ it("anchors a size change on a held row whose top is above the viewport", async
expect(list.heldRowTop()).toBe(-20);
});
it("keeps measured row sizes while the list is not laid out", async () => {
// A hidden retained tab is display: none. Its rows still render, and a row measured there
// reports 0 x 0. When the tab shows again, a row whose frame did not change gets no new layout
// event, so a 0 recorded while hidden would stay and the next row would overlap it.
const rows: Row[] = Array.from({ length: 3 }, (_, index) => ({ id: `row-${index}`, height: 40 }));
const list = mountList("row-0");
list.render(rows);
await wait(150);
expect(rows.map((row) => list.rowSize(row.id))).toEqual([40, 40, 40]);
host.style.display = "none";
await wait(150);
list.render(rows.map((row) => ({ ...row })));
await wait(150);
expect(rows.map((row) => list.rowSize(row.id))).toEqual([40, 40, 40]);
});
@@ -137,9 +137,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
} = props;
const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers;
const listRef = useRef<LegendListRef>(null);
// LegendList places the rows of each agent once, behind a hidden reveal (`dataKey`).
const [placedAgentId, setPlacedAgentId] = useState<string | null>(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",
@@ -693,6 +675,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
extraData={renderHistoryMountedRow}
keyExtractor={keyExtractor}
testID="agent-chat-scroll"
// The Android scroll owner identifies the timeline to contain nested panel gestures.
nativeID="agent-chat-scroll-native-virtualized"
ListHeaderComponent={historyStartContent}
ListFooterComponent={liveAuxiliaryContent ?? undefined}
@@ -711,7 +694,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
onContentSizeChange={handleContentSizeChange}
onItemSizeChanged={handleItemSizeChanged}
onReady={handleListPlaced}
dataKey={agentId}
maintainVisibleContentPosition={maintainVisibleContentPosition}
renderScrollComponent={renderScrollComponent}
recycleItems={false}
+2 -10
View File
@@ -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) => (
<WebStreamViewport
key={renderInput.agentId}
{...renderInput}
isMobileBreakpoint={input.isMobileBreakpoint}
/>
<WebStreamViewport {...renderInput} isMobileBreakpoint={input.isMobileBreakpoint} />
),
orderTailReverse: false,
orderHeadReverse: false,
@@ -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"),
@@ -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<boolean>;
}) {
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<boolean> => {
return revealLoadedHistory() || (await loadRemoteOlder());
+1 -6
View File
@@ -430,10 +430,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
loadOlder: historyPagination.onLoadOlder,
}
: agentHistoryPagination;
useEffect(() => {
setExpandedInlineToolCallIds(new Set());
setExpandedTurnHostIds(new Set());
}, [agentId]);
const isActive = useRetainedPanelActive();
const openInlineFile = useStableEvent(
@@ -476,7 +472,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
useEffect(() => {
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<AgentStreamViewHandle, AgentStreamVi
revealLoadedHistory,
loadOlder,
} = useStreamHistoryWindow({
agentId,
items: presentation.tail,
loadRemoteOlder,
});
@@ -49,7 +49,7 @@ function getTextAttachmentSubtitle(
t: TFunction,
): string {
if (attachment.contextKind === "chat_history") {
return attachment.chatHistorySource?.title ?? t("message.chatHistory.previousConversation");
return "Previous conversation";
}
return t("message.attachments.text");
}
@@ -87,13 +87,6 @@ export function getAgentAttachmentPillContent(
subtitle: `Issue #${attachment.number}`,
};
case "text":
if (attachment.contextKind === "chat_history") {
return {
icon: attachmentFileIcon,
title: t("message.chatHistory.title"),
subtitle: getTextAttachmentSubtitle(attachment, t),
};
}
if (attachment.externalResource) {
return {
icon: attachmentGithubIssueIcon,
@@ -1,125 +0,0 @@
import { generateMessageId } from "@/types/stream";
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
import type { ChatHistoryAttachment as HistoryAttachment } from "@getpaseo/protocol/messages";
import { extractChatHistoryBody } from "@getpaseo/protocol/chat-history";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ArrowUpRight } from "lucide-react-native";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content";
import { AttachmentFrame, AttachmentLabel, AttachmentPill } from "@/components/attachment-pill";
import { AdaptiveModalSheet, FULL_SHEET_SNAP_POINTS } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { navigateToAgent } from "@/utils/navigate-to-agent";
interface ChatHistoryAttachmentProps {
attachment: Extract<AgentAttachment, { type: "text" }>;
onRemove?: () => void;
disabled?: boolean;
testID?: string;
}
export function ChatHistoryAttachment({
attachment,
onRemove,
disabled = false,
testID,
}: ChatHistoryAttachmentProps) {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const open = useCallback(() => setVisible(true), []);
const close = useCallback(() => setVisible(false), []);
const source = attachment.chatHistorySource;
const openSource = useCallback(() => {
if (!source) return;
close();
navigateToAgent({ serverId: source.serverId, agentId: source.agentId });
}, [close, source]);
const header = useMemo(
() => ({
title: t("message.chatHistory.title"),
subtitle: source?.title ? (
<Text style={styles.description} numberOfLines={1}>
{source.title}
</Text>
) : undefined,
}),
[source?.title, t],
);
const footer = useMemo(
() =>
source ? (
<Button variant="outline" size="sm" leftIcon={ArrowUpRight} onPress={openSource}>
{t("message.chatHistory.openSource")}
</Button>
) : undefined,
[source, openSource, t],
);
const history = useMemo(() => extractChatHistoryBody(attachment.text), [attachment.text]);
const content = getAgentAttachmentPillContent(attachment, t);
const label = (
<AttachmentLabel icon={content.icon} title={content.title} subtitle={content.subtitle} />
);
const openLabel = t("message.chatHistory.open");
return (
<>
{onRemove ? (
<AttachmentPill
testID={testID}
onOpen={open}
onRemove={onRemove}
disabled={disabled}
openAccessibilityLabel={openLabel}
removeAccessibilityLabel={t("message.chatHistory.remove")}
>
{label}
</AttachmentPill>
) : (
<AttachmentFrame testID={testID} onPress={open} accessibilityLabel={openLabel}>
{label}
</AttachmentFrame>
)}
<AdaptiveModalSheet
visible={visible}
onClose={close}
header={header}
snapPoints={FULL_SHEET_SNAP_POINTS}
desktopMaxWidth={720}
desktopHeight="75%"
testID="chat-history-preview"
contentStyle={styles.content}
footer={footer}
>
<Text style={styles.description}>{t("message.chatHistory.description")}</Text>
<Text selectable testID="chat-history-content" style={styles.history}>
{history}
</Text>
</AdaptiveModalSheet>
</>
);
}
const styles = StyleSheet.create((theme) => ({
content: { gap: theme.spacing[4] },
description: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted },
history: {
fontSize: theme.fontSize.content,
lineHeight: theme.fontSize.content * 1.6,
color: theme.colors.foreground,
},
}));
export function restoreChatHistoryAttachment(scopeKey: string, attachment: HistoryAttachment) {
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({
scopeKey,
attachment: {
kind: "chat_history",
id: generateMessageId(),
attachment,
source: attachment.chatHistorySource,
},
});
}
+3 -2
View File
@@ -1,6 +1,5 @@
import type {
AgentAttachment,
ChatHistorySource,
ForgeSearchItem,
UploadedFileAttachment,
} from "@getpaseo/protocol/messages";
@@ -83,7 +82,9 @@ export interface ChatHistoryContextAttachment {
kind: "chat_history";
id: string;
attachment: Extract<AgentAttachment, { type: "text" }>;
source?: ChatHistorySource & {
source: {
serverId: string;
agentId: string;
boundaryMessageId?: string | null;
boundaryCursor?: { epoch: string; seq: number } | null;
itemCount?: number;
@@ -21,29 +21,6 @@ function contextAttachment(
}
describe("workspace attachment utilities", () => {
it("submits chat history with its source host and agent", () => {
const source = { serverId: "original-host", agentId: "original-agent", title: "Original" };
expect(
workspaceAttachmentToSubmitAttachment({
kind: "chat_history",
id: "history-draft",
attachment: {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
text: "Earlier context",
},
source,
}),
).toEqual({
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
text: "Earlier context",
chatHistorySource: source,
});
});
it("treats pull request context as a workspace attachment", () => {
expect(isWorkspaceAttachment(contextAttachment())).toBe(true);
});
@@ -62,12 +62,7 @@ export function workspaceAttachmentToSubmitAttachment(
};
}
if (attachment.kind === "chat_history") {
if (!attachment.source) return attachment.attachment;
const { serverId, agentId, title } = attachment.source;
return {
...attachment.attachment,
chatHistorySource: { serverId, agentId, ...(title ? { title } : {}) },
};
return attachment.attachment;
}
return attachment.kind === "review" ? attachment.attachment : null;
}
@@ -178,19 +178,6 @@ export function useWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScope):
}, [cwd, draftId, isDraftScope, serverId, workspaceId]);
}
export function useAgentAttachmentScopeKey(input: { serverId: string; agentId: string }): string {
const { serverId, agentId } = input;
return useMemo(
() =>
[
"agent-attachments",
`server=${encodeScopePart(serverId)}`,
`agent=${encodeScopePart(agentId)}`,
].join(":"),
[serverId, agentId],
);
}
export function useDraftWorkspaceAttachmentScopeKey(draftId: string | null | undefined): string {
const normalizedDraftId = useMemo(() => draftId?.trim() ?? "", [draftId]);
return useMemo(
+8 -29
View File
@@ -1,4 +1,3 @@
import { ChatHistoryAttachment } from "@/attachments/chat-history";
import { useDisclosureHold } from "@/agent-stream/disclosure";
import { MessagePreview, MessageSender } from "@/components/user-message";
import { useStableEvent } from "@/hooks/use-stable-event";
@@ -34,10 +33,7 @@ import { useTargetAgentTitle } from "@/tool-calls/use-target-agent-title";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import type { AgentCapabilityFlags, ToolCallDetail } from "@getpaseo/protocol/agent-types";
import type {
AgentAttachment,
ChatHistoryAttachment as ChatHistorySnapshot,
} from "@getpaseo/protocol/messages";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import MaskedView from "@react-native-masked-view/masked-view";
import {
Check,
@@ -423,12 +419,7 @@ export const UserMessage = memo(function UserMessage({
const hasText = message.trim().length > 0;
const hasImages = images.length > 0;
const hasAttachments = attachments.length > 0;
const chatHistory = attachments.find(
(attachment): attachment is ChatHistorySnapshot =>
attachment.type === "text" && attachment.contextKind === "chat_history",
);
const showTrailingRow =
!isPending && (hasText || Boolean(chatHistory)) && (isCompact || isNative || isHovered);
const showTrailingRow = !isPending && hasText && (isCompact || isNative || isHovered);
const formattedTimestamp = useMemo(
() => formatMessageTimestamp(new Date(timestamp)),
[timestamp],
@@ -437,12 +428,9 @@ export const UserMessage = memo(function UserMessage({
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const getMessageContent = useCallback(
() => message || chatHistory?.text || "",
[message, chatHistory],
);
const getMessageContent = useCallback(() => message, [message]);
const handleRewind = useCallback(
(input: { mode: RewindMode; rewoundText: string; chatHistory?: ChatHistorySnapshot }) => {
(input: { mode: RewindMode; rewoundText: string }) => {
return rewindMutation.rewindAgent(input);
},
[rewindMutation],
@@ -508,19 +496,11 @@ export const UserMessage = memo(function UserMessage({
{hasAttachments ? (
<View style={attachmentPreviewContainerStyle}>
{attachments.map((attachment, index) => {
const key = `${attachment.type}:${"number" in attachment ? attachment.number : index}`;
if (attachment.type === "text" && attachment.contextKind === "chat_history") {
return (
<ChatHistoryAttachment
key={key}
attachment={attachment}
testID="message-chat-history-attachment"
/>
);
}
const content = getAgentAttachmentPillContent(attachment, t);
return (
<AttachmentFrame key={key}>
<AttachmentFrame
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
>
<AttachmentLabel
icon={content.icon}
title={content.title}
@@ -538,7 +518,7 @@ export const UserMessage = memo(function UserMessage({
) : null}
</MessagePreview>
</View>
{hasText || chatHistory ? (
{hasText ? (
<View
style={trailingRowStyle}
pointerEvents={showTrailingRow ? "auto" : "none"}
@@ -552,7 +532,6 @@ export const UserMessage = memo(function UserMessage({
capabilities={capabilities}
isPending={rewindMutation.isPending}
rewoundText={message}
chatHistory={chatHistory}
onRewind={handleRewind}
/>
) : null}
@@ -1,45 +1,27 @@
import { describe, expect, test } from "vitest";
import { restoreComposerPromptIfEmpty } from "./composer-restore";
import { restoreComposerTextIfEmpty } from "./composer-restore";
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
describe("restoreComposerPromptIfEmpty", () => {
describe("restoreComposerTextIfEmpty", () => {
test("restores the rewound message when the composer is empty", () => {
expect(
restoreComposerPromptIfEmpty({
restoreComposerTextIfEmpty({
currentText: "",
rewoundPrompt: { text: "message before rewind" },
rewoundText: "message before rewind",
}),
).toEqual({ text: "message before rewind" });
).toBe("message before rewind");
});
test("preserves an existing composer draft", () => {
expect(
restoreComposerPromptIfEmpty({
restoreComposerTextIfEmpty({
currentText: "keep this draft",
rewoundPrompt: { text: "message before rewind" },
rewoundText: "message before rewind",
}),
).toEqual({ text: "keep this draft" });
).toBe("keep this draft");
});
});
test.each(["", "Continue here"])("restores inherited history with rewound prompt %s", (text) => {
const chatHistory = {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
text: "Earlier context",
} as const;
expect(
restoreComposerPromptIfEmpty({ currentText: "", rewoundPrompt: { text, chatHistory } }),
).toEqual({ text, chatHistory });
expect(
restoreComposerPromptIfEmpty({
currentText: "Existing draft",
rewoundPrompt: { text, chatHistory },
}),
).toEqual({ text: "Existing draft" });
});
describe("shouldRestoreComposerForRewindMode", () => {
test("restores only conversation-mutating rewind modes", () => {
expect(shouldRestoreComposerForRewindMode("conversation")).toBe(true);
@@ -1,14 +1,11 @@
import type { ChatHistoryAttachment } from "@getpaseo/protocol/messages";
import { restoreChatHistoryAttachment } from "@/attachments/chat-history";
import type { ComposerTextSource } from "@/composer/text-source";
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
interface RewindComposerRestoreContextValue {
completeRewind: (prompt: { text: string; chatHistory?: ChatHistoryAttachment }) => void;
completeRewind: (text: string) => void;
}
interface RewindComposerRestoreProviderProps {
attachmentScopeKey: string;
textSource: ComposerTextSource;
setText: (text: string) => void;
onRewindComplete: () => void;
@@ -17,34 +14,35 @@ interface RewindComposerRestoreProviderProps {
const RewindComposerRestoreContext = createContext<RewindComposerRestoreContextValue | null>(null);
export function restoreComposerPromptIfEmpty(input: {
export function restoreComposerTextIfEmpty(input: {
currentText: string;
rewoundPrompt: { text: string; chatHistory?: ChatHistoryAttachment };
}): { text: string; chatHistory?: ChatHistoryAttachment } {
if (input.currentText.length > 0) return { text: input.currentText };
return input.rewoundPrompt;
rewoundText: string;
}): string {
if (input.currentText.length > 0) {
return input.currentText;
}
return input.rewoundText;
}
export function RewindComposerRestoreProvider({
attachmentScopeKey,
textSource,
setText,
onRewindComplete,
children,
}: RewindComposerRestoreProviderProps) {
const completeRewind = useCallback(
(prompt: { text: string; chatHistory?: ChatHistoryAttachment }) => {
(rewoundText: string) => {
const currentText = textSource.getSnapshot();
const restored = restoreComposerPromptIfEmpty({ currentText, rewoundPrompt: prompt });
if (restored.chatHistory) {
restoreChatHistoryAttachment(attachmentScopeKey, restored.chatHistory);
}
if (restored.text !== currentText) {
setText(restored.text);
const nextText = restoreComposerTextIfEmpty({
currentText: currentText,
rewoundText,
});
if (nextText !== currentText) {
setText(nextText);
}
onRewindComplete();
},
[attachmentScopeKey, onRewindComplete, setText, textSource],
[onRewindComplete, setText, textSource],
);
const value = useMemo(() => ({ completeRewind }), [completeRewind]);
@@ -1,4 +1,3 @@
import type { ChatHistoryAttachment } from "@getpaseo/protocol/messages";
import { memo, useCallback, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
@@ -20,12 +19,7 @@ export type { RewindMode };
interface RewindMenuProps {
capabilities: AgentCapabilityFlags;
rewoundText: string;
chatHistory?: ChatHistoryAttachment;
onRewind: (input: {
mode: RewindMode;
rewoundText: string;
chatHistory?: ChatHistoryAttachment;
}) => Promise<void> | void;
onRewind: (input: { mode: RewindMode; rewoundText: string }) => Promise<void> | void;
isPending?: boolean;
testID?: string;
}
@@ -44,7 +38,6 @@ function getIcon(mode: RewindMode, color: string): ReactElement {
export const RewindMenu = memo(function RewindMenu({
capabilities,
rewoundText,
chatHistory,
onRewind,
isPending: isPendingProp = false,
testID = "rewind-menu",
@@ -77,7 +70,7 @@ export const RewindMenu = memo(function RewindMenu({
if (isLocked) return;
setPendingMode(mode);
try {
await onRewind({ mode, rewoundText, chatHistory });
await onRewind({ mode, rewoundText });
} catch {
// useRewindAgentMutation owns the toast; the menu only owns flow state.
} finally {
@@ -85,7 +78,7 @@ export const RewindMenu = memo(function RewindMenu({
setIsOpen(false);
}
},
[isLocked, onRewind, rewoundText, chatHistory],
[isLocked, onRewind, rewoundText],
);
const triggerStyle = useCallback(
@@ -1,4 +1,3 @@
import type { ChatHistoryAttachment } from "@getpaseo/protocol/messages";
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
@@ -21,7 +20,6 @@ interface UseRewindAgentMutationInput {
interface RewindAgentInput {
mode: RewindMode;
rewoundText: string;
chatHistory?: ChatHistoryAttachment;
}
export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
@@ -54,10 +52,7 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
if (!shouldRestoreComposerForRewindMode(variables.mode)) {
return;
}
composerRestore?.completeRewind({
text: variables.rewoundText,
chatHistory: variables.chatHistory,
});
composerRestore?.completeRewind(variables.rewoundText);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t("rewind.errors.failed"));
@@ -1,4 +1,3 @@
import { ChatHistoryAttachment } from "@/attachments/chat-history";
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import type {
@@ -256,28 +255,12 @@ function WorkspaceAttachmentPill({
}: WorkspaceAttachmentPillProps) {
const { t } = useTranslation();
const content = getWorkspaceAttachmentPillContent(attachment, t);
const chatHistory = useMemo(() => {
if (attachment.kind !== "chat_history") return null;
if (!attachment.source) return attachment.attachment;
const { serverId, agentId, title } = attachment.source;
return { ...attachment.attachment, chatHistorySource: { serverId, agentId, title } };
}, [attachment]);
const handleOpen = useCallback(() => {
onOpen(attachment);
}, [onOpen, attachment]);
const handleRemove = useCallback(() => {
onRemove(index);
}, [onRemove, index]);
if (chatHistory) {
return (
<ChatHistoryAttachment
attachment={chatHistory}
onRemove={handleRemove}
disabled={disabled}
testID={getPillTestID(attachment)}
/>
);
}
return (
<AttachmentPill
testID={getPillTestID(attachment)}
@@ -60,17 +60,6 @@ describe("useDraftAgentCreateFlow", () => {
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 () => {
@@ -242,10 +231,10 @@ describe("useDraftAgentCreateFlow", () => {
expect(createRequest).toHaveBeenCalledWith({
attempt: expect.objectContaining({
text: "",
attachments: [{ ...attachment.attachment, chatHistorySource: attachment.source }],
attachments: [attachment.attachment],
}),
text: "",
attachments: [{ ...attachment.attachment, chatHistorySource: attachment.source }],
attachments: [attachment.attachment],
cwd: "/repo",
});
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
+1 -42
View File
@@ -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<TDraftAgent, TCreateResult>({
const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
const isSubmitting = machine.tag === "creating";
const submittedStreamItems = useMemo<StreamItem[]>(() => {
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<readonly PendingMessageSubmission[]>(() => {
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<TDraftAgent, TCreateResult>({
machine,
formErrorMessage,
isSubmitting,
submittedStreamItems,
pendingMessageSubmissions,
draftAgent,
handleCreateFromInput,
continueCreateFromAttempt,
@@ -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> = {}): 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();
});
});
+102
View File
@@ -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],
);
}
@@ -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;
}
+124 -198
View File
@@ -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<Agent, AgentSnapshotPayload>({
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<Agent, AgentSnapshotPayload>({
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 = (
<View style={styles.contentContainer}>
{isSubmitting && draftAgent ? (
<View style={styles.streamContainer}>
<AgentStreamView
agentId={tabId}
serverId={serverId}
context={draftAgent}
streamItems={submittedStreamItems}
pendingMessageSubmissions={pendingMessageSubmissions}
turnPresentation={turnPresentation}
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</View>
) : (
<ScrollView style={styles.scrollView} contentContainerStyle={styles.configScrollContent}>
<View style={styles.configSection}>
{formErrorMessage ? (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{formErrorMessage}</Text>
</View>
) : null}
</View>
</ScrollView>
)}
</View>
);
return (
<FileDropZone style={styles.container}>
<ComposerDock fullscreen={isComposerFullscreen}>
{dockContent}
<View style={animatedStaticStyles.inputAreaWrapper} onLayout={onInputAreaLayout}>
{importPillPress && !isComposerFullscreen ? (
<View style={styles.importPillRow}>
<View style={styles.importPillContent}>
<ComposerImportPill onPress={importPillPress} />
</View>
</View>
) : null}
<Composer
agentId={tabId}
serverId={serverId}
workspaceId={workspaceId}
isPaneFocused={isPaneFocused}
onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting}
blurOnSubmit={true}
textSource={draftInput.textSource}
onChangeText={draftInput.editText}
textReplacement={draftInput.textReplacement}
attachments={draftInput.attachments}
attachmentScopeKeys={attachmentScopeKeys}
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
onChangeAttachments={draftInput.setAttachments}
cwd={composerState.workingDir}
clearDraft={draftInput.clear}
editingSession={editingSession}
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
onFocusInput={handleFocusInputCallback}
commandDraft={composerState.commandDraft}
agentControls={composerAgentControls}
isCompactLayout={isCompactComposerLayout}
/>
<View style={animatedStaticStyles.inputAreaWrapper} onLayout={onInputAreaLayout}>
{formErrorMessage ? (
<View style={styles.formErrorRow}>
<View style={styles.formError}>
<Text style={styles.errorText}>{formErrorMessage}</Text>
</View>
</View>
</ComposerDock>
</FileDropZone>
) : null}
{importPillPress && !isComposerFullscreen ? (
<View style={styles.importPillRow}>
<View style={styles.importPillContent}>
<ComposerImportPill onPress={importPillPress} />
</View>
</View>
) : null}
<Composer
agentId={tabId}
serverId={serverId}
workspaceId={workspaceId}
isPaneFocused={isPaneFocused}
onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting}
blurOnSubmit={true}
textSource={draftInput.textSource}
onChangeText={draftInput.editText}
textReplacement={draftInput.textReplacement}
attachments={draftInput.attachments}
attachmentScopeKeys={attachmentScopeKeys}
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
onChangeAttachments={draftInput.setAttachments}
cwd={composerState.workingDir}
clearDraft={draftInput.clear}
editingSession={editingSession}
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
onFocusInput={handleFocusInputCallback}
commandDraft={composerState.commandDraft}
agentControls={composerAgentControls}
isCompactLayout={isCompactComposerLayout}
/>
</View>
);
}
@@ -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,
-3
View File
@@ -68,7 +68,6 @@ export interface UseForkAgentInput {
function buildChatHistoryAttachment(input: {
draftId: string;
title: string | null;
serverId: string;
agentId: string;
payload: Awaited<ReturnType<DaemonClient["buildAgentForkContext"]>>;
@@ -84,7 +83,6 @@ function buildChatHistoryAttachment(input: {
source: {
serverId: input.serverId,
agentId: input.agentId,
...(input.title ? { title: input.title } : {}),
boundaryMessageId: input.payload.boundaryMessageId,
boundaryCursor: input.payload.boundaryCursor,
itemCount: input.payload.itemCount,
@@ -149,7 +147,6 @@ export function useForkAgent(
const payload = await client.buildAgentForkContext(agentId, boundary);
const attachment = buildChatHistoryAttachment({
draftId,
title: useSessionStore.getState().sessions[serverId]?.agents.get(agentId)?.title ?? null,
serverId,
agentId,
payload,
-8
View File
@@ -342,14 +342,6 @@ export const ar: TranslationResources = {
openFile: "افتح الملف",
copied: "منقول",
},
chatHistory: {
title: "سجل المحادثة",
previousConversation: "المحادثة السابقة",
open: "فتح مرفق سجل المحادثة",
remove: "إزالة مرفق سجل المحادثة",
openSource: "فتح المحادثة الأصلية",
description: "السياق المنسوخ عند تفريع هذه المحادثة.",
},
attachments: {
dismissImage: "تجاهل الصورة",
closeImage: "إغلاق الصورة",
-8
View File
@@ -339,14 +339,6 @@ export const en = {
openFile: "Open file",
copied: "Copied",
},
chatHistory: {
title: "Chat history",
previousConversation: "Previous conversation",
open: "Open chat history attachment",
remove: "Remove chat history attachment",
openSource: "Open source chat",
description: "Context copied when this chat was forked.",
},
attachments: {
dismissImage: "Dismiss image",
closeImage: "Close image",
-8
View File
@@ -347,14 +347,6 @@ export const es: TranslationResources = {
openFile: "Abrir archivo",
copied: "Copiado",
},
chatHistory: {
title: "Historial del chat",
previousConversation: "Conversación anterior",
open: "Abrir el historial adjunto",
remove: "Quitar el historial adjunto",
openSource: "Abrir el chat de origen",
description: "Contexto copiado al bifurcar este chat.",
},
attachments: {
dismissImage: "Descartar imagen",
closeImage: "Cerrar imagen",
-8
View File
@@ -347,14 +347,6 @@ export const fr: TranslationResources = {
openFile: "Ouvrir le fichier",
copied: "Copié",
},
chatHistory: {
title: "Historique du chat",
previousConversation: "Conversation précédente",
open: "Ouvrir l’historique joint",
remove: "Retirer l’historique joint",
openSource: "Ouvrir le chat d’origine",
description: "Contexte copié lors de la bifurcation de ce chat.",
},
attachments: {
dismissImage: "Ignorer l’image",
closeImage: "Fermer l’image",
-8
View File
@@ -347,14 +347,6 @@ export const ja: TranslationResources = {
openFile: "ファイルを開く",
copied: "コピーしました",
},
chatHistory: {
title: "チャット履歴",
previousConversation: "以前の会話",
open: "チャット履歴の添付を開く",
remove: "チャット履歴の添付を削除",
openSource: "元のチャットを開く",
description: "このチャットを分岐した時点でコピーされたコンテキストです。",
},
attachments: {
dismissImage: "画像を閉じる",
closeImage: "画像を閉じる",
-8
View File
@@ -343,14 +343,6 @@ export const ko: TranslationResources = {
openFile: "파일 열기",
copied: "복사됨",
},
chatHistory: {
title: "채팅 기록",
previousConversation: "이전 대화",
open: "채팅 기록 첨부 열기",
remove: "채팅 기록 첨부 제거",
openSource: "원본 채팅 열기",
description: "이 채팅을 분기할 때 복사된 컨텍스트입니다.",
},
attachments: {
dismissImage: "이미지 닫기",
closeImage: "이미지 닫기",
-8
View File
@@ -346,14 +346,6 @@ export const ptBR: TranslationResources = {
openFile: "Abrir arquivo",
copied: "Copiado",
},
chatHistory: {
title: "Histórico do chat",
previousConversation: "Conversa anterior",
open: "Abrir histórico anexado",
remove: "Remover histórico anexado",
openSource: "Abrir chat de origem",
description: "Contexto copiado ao ramificar este chat.",
},
attachments: {
dismissImage: "Dispensar imagem",
closeImage: "Fechar imagem",
-8
View File
@@ -346,14 +346,6 @@ export const ru: TranslationResources = {
openFile: "Открыть файл",
copied: "Скопировано",
},
chatHistory: {
title: "История чата",
previousConversation: "Предыдущий разговор",
open: "Открыть вложенную историю чата",
remove: "Удалить вложенную историю чата",
openSource: "Открыть исходный чат",
description: "Контекст, скопированный при создании этой ветки чата.",
},
attachments: {
dismissImage: "Закрыть изображение",
closeImage: "Закрыть изображение",
-8
View File
@@ -342,14 +342,6 @@ export const zhCN: TranslationResources = {
openFile: "打开文件",
copied: "已复制",
},
chatHistory: {
title: "聊天历史",
previousConversation: "之前的对话",
open: "打开聊天历史附件",
remove: "移除聊天历史附件",
openSource: "打开来源对话",
description: "创建此分叉时复制的上下文。",
},
attachments: {
dismissImage: "关闭图片",
closeImage: "关闭图片",
+161 -103
View File
@@ -43,10 +43,7 @@ import { RewindComposerRestoreProvider } from "@/components/rewind/composer-rest
import { useAgentDescriptor } from "@/agent-presentation";
import { useToastHost, type ToastApi, type ToastState } from "@/components/toast-host";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import {
useWorkspaceAttachmentScopeKey,
useAgentAttachmentScopeKey,
} from "@/attachments/workspace-attachments-store";
import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import { COMPACT_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
@@ -81,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";
@@ -93,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";
@@ -228,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;
@@ -306,17 +326,31 @@ function storeFetchedAgentDetail(input: {
return hydrated;
}
function AgentPanel() {
type ConversationTarget = Extract<WorkspaceTabTarget, { kind: "agent" } | { kind: "draft" }>;
/** 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 (
<RetainedChatContent>
<AgentPanelContent
serverId={serverId}
workspaceId={workspaceId}
agentId={target.agentId}
target={target}
isPaneFocused={isInteractive}
onOpenWorkspaceFile={openFileInWorkspace}
/>
@@ -324,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<typeof normalizeAgentSnapshot>[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 (
<WorkspaceDraftAgentTab
serverId={serverId}
workspaceId={workspaceId}
tabId={tabId}
draftId={target.draftId}
initialSetup={target.setup}
isPaneFocused={isInteractive}
onOpenWorkspaceFile={openFileInWorkspace}
onCreated={handleCreated}
onOpenImportSheet={openImportSheet}
/>
);
}
export function AgentConversationPanel() {
const { target } = usePaneContext();
if (target.kind === "draft") {
return <DraftPanel />;
}
if (target.kind === "agent") {
return <AgentPanel />;
}
invariant(false, "AgentConversationPanel requires an agent or draft target");
}
export const agentPanelRegistration = definePanel("agent", {
component: AgentConversationPanel,
useDescriptor: useAgentDescriptor,
@@ -447,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 ?? "";
@@ -487,7 +472,7 @@ function AgentPanelContent({
: runtimeConnectionStatus;
const lastConnectionError = runtimeLastError;
if (!resolvedServerId || (!runtimeClient && !hasCachedAgent)) {
if (target.kind === "agent" && (!resolvedServerId || (!runtimeClient && !hasCachedAgent))) {
return (
<AgentSessionUnavailableState
serverLabel={serverLabel}
@@ -501,9 +486,9 @@ function AgentPanelContent({
return (
<AgentPanelBody
serverId={resolvedServerId}
serverId={resolvedServerId ?? serverId}
workspaceId={workspaceId}
agentId={resolvedAgentId}
target={target}
isPaneFocused={isPaneFocused}
client={runtimeClient}
isConnected={runtimeIsConnected}
@@ -515,7 +500,7 @@ function AgentPanelContent({
function AgentPanelBody({
serverId,
workspaceId,
agentId,
target,
isPaneFocused,
client,
isConnected,
@@ -523,13 +508,14 @@ function AgentPanelBody({
}: {
serverId: string;
workspaceId: string;
agentId?: string;
target: ConversationTarget;
isPaneFocused: boolean;
client: ReturnType<typeof useHostRuntimeClient>;
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(
@@ -644,7 +630,7 @@ function AgentPanelBody({
}
: null;
if (!agent) {
if (target.kind === "agent" && !agent) {
return (
<View style={styles.container} testID="agent-loading">
<View style={styles.errorContainer}>
@@ -658,7 +644,7 @@ function AgentPanelBody({
<ChatAgentContent
serverId={serverId}
workspaceId={workspaceId}
agentId={agentId}
target={target}
isPaneFocused={isPaneFocused}
client={client}
isConnected={isConnected}
@@ -670,7 +656,7 @@ function AgentPanelBody({
function ChatAgentContent({
serverId,
workspaceId,
agentId,
target,
isPaneFocused,
client,
isConnected,
@@ -678,13 +664,15 @@ function ChatAgentContent({
}: {
serverId: string;
workspaceId: string;
agentId?: string;
target: ConversationTarget;
isPaneFocused: boolean;
client: ReturnType<typeof useHostRuntimeClient>;
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();
@@ -991,17 +979,23 @@ function ChatAgentContent({
visibilityCatchUpStatus,
]);
const nonReadyView = renderChatAgentNonReadyView({
const draft = useMemo<DraftConversation | null>(
() =>
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" &&
@@ -1016,7 +1010,8 @@ function ChatAgentContent({
<ChatAgentReadyContent
serverId={serverId}
workspaceId={workspaceId}
agentId={agentId}
agentId={agentId ?? tabId}
draft={draft}
isPaneFocused={isPaneFocused}
isArchivingCurrentAgent={isArchivingCurrentAgent}
agentState={agentState}
@@ -1034,7 +1029,7 @@ function ChatAgentContent({
showHistorySyncOverlay={showHistorySyncOverlay}
showHistorySyncError={showHistorySyncError}
isRetryingHistorySync={isRetryingHistorySync}
cwd={agentCwd}
cwd={agentState.cwd ?? ""}
retryTimelineSync={retryTimelineSync}
onAttentionInputFocus={attentionController.clearOnInputFocus}
onAttentionPromptSend={attentionController.clearOnPromptSend}
@@ -1047,6 +1042,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
serverId,
workspaceId,
agentId,
draft,
isPaneFocused,
isArchivingCurrentAgent,
agentState,
@@ -1072,11 +1068,13 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
}: {
serverId: string;
workspaceId: string;
/** The agent's id, or the tab id while a draft has no agent yet. */
agentId: string;
draft: DraftConversation | null;
isPaneFocused: boolean;
isArchivingCurrentAgent: boolean;
agentState: ChatAgentSelectedState;
effectiveAgent: AgentScreenAgent;
effectiveAgent: AgentScreenAgent | null;
routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean;
toastApi: ToastApi;
@@ -1098,24 +1096,15 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
}) {
const { t } = useTranslation();
// The workspace sheet is for looking; Open as tab brings the agent back with its composer.
const showsComposer = usePaneContext().host !== "sheet";
const paneHost = usePaneContext().host;
const showsComposer = draft !== null || paneHost !== "sheet";
// The dock lays out fullscreen for the same session the composer edits in.
const composerEditingSession = useComposerEditingSession("chat");
const isComposerFullscreen = useComposerFullscreen(composerEditingSession);
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 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.
const parentPillAgentId = showsComposer ? parentAgentId : null;
const rewindAttachmentScopeKey = useAgentAttachmentScopeKey({ serverId, agentId });
const rawAgentInputDraft = useAgentInputDraft({
draftKey: buildDraftStoreKey({
serverId,
@@ -1165,7 +1154,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
const parentCallout = parentAgentId ? (
<ParentAgentCallout serverId={serverId} parentAgentId={parentAgentId} />
) : null;
const composerSection = showsComposer ? (
const agentComposerSection = showsComposer ? (
<RenderProfile id={`AgentComposerSection:${agentId}`}>
<AgentComposerSection
agentId={agentId}
@@ -1186,6 +1175,16 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
) : (
parentCallout
);
const composerSection = draft ? (
<DraftComposerSection
serverId={serverId}
draft={draft}
isPaneFocused={isPaneFocused}
editingSession={composerEditingSession}
/>
) : (
agentComposerSection
);
const streamContent = (
<View style={animatedStaticStyles.content}>
{showsComposer ? null : <SheetAgentFacts serverId={serverId} agentId={agentId} />}
@@ -1195,6 +1194,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
serverId={serverId}
agentId={agentId}
agent={effectiveAgent}
draft={draft}
routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
hasActiveComposer={hasActiveComposer}
@@ -1202,16 +1202,12 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</RenderProfile>
{hasActiveComposer ? (
{hasActiveComposer && !draft ? (
<AgentTracks
serverId={serverId}
workspaceId={workspaceId}
agentId={agentId}
parentAgentId={parentPillAgentId}
subagentRows={subagentRows}
tasks={tasks}
archiveFinishedStatus={archiveFinishedSubagents.status}
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
/>
) : null}
</View>
@@ -1249,7 +1245,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
const content = (
<RewindComposerRestoreProvider
attachmentScopeKey={rewindAttachmentScopeKey}
textSource={agentInputDraft.textSource}
setText={agentInputDraft.replaceText}
onRewindComplete={handleRewindComplete}
@@ -1268,7 +1263,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
</RewindComposerRestoreProvider>
);
return wrapAgentCommandProvider({
enabled: showsComposer && hasActiveComposer,
enabled: showsComposer && hasActiveComposer && !draft,
serverId,
agentId,
editingSession: composerEditingSession,
@@ -1360,6 +1355,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
serverId,
agentId,
agent,
draft,
routeBottomAnchorRequest,
hasAppliedAuthoritativeHistory,
hasActiveComposer,
@@ -1369,7 +1365,8 @@ const AgentStreamSection = memo(function AgentStreamSection({
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
serverId: string;
agentId?: string;
agent: AgentScreenAgent;
agent: AgentScreenAgent | null;
draft: DraftConversation | null;
routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean;
hasActiveComposer: boolean;
@@ -1427,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 (
<AgentStreamView
ref={streamViewRef}
agentId={draftTimeline.agent.id}
serverId={serverId}
context={draftTimeline.agent}
streamItems={draftTimeline.streamItems}
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
bottomOverlayTailClearance={bottomOverlayTailClearance}
bottomOverlayControlClearance={bottomOverlayControlClearance}
toast={toast}
pendingMessageSubmissions={draftTimeline.pendingMessageSubmissions}
turnPresentation={draftTimeline.turnPresentation}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
);
}
if (!agent) {
return null;
}
return (
<AgentStreamView
ref={streamViewRef}
@@ -1448,6 +1469,44 @@ const AgentStreamSection = memo(function AgentStreamSection({
);
});
/** The draft's composer, which creates the agent and retargets this tab to it. */
function DraftComposerSection({
serverId,
draft,
isPaneFocused,
editingSession,
}: {
serverId: string;
draft: DraftConversation;
isPaneFocused: boolean;
editingSession: ComposerEditingSession;
}) {
const { openImportSheet, retargetCurrentTab } = usePaneContext();
const handleCreated = useCallback(
(agentSnapshot: Parameters<typeof normalizeAgentSnapshot>[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 (
<WorkspaceDraftComposer
serverId={serverId}
workspaceId={draft.workspaceId}
tabId={draft.tabId}
draftId={draft.draftId}
initialSetup={draft.setup}
isPaneFocused={isPaneFocused}
editingSession={editingSession}
onCreated={handleCreated}
onOpenImportSheet={openImportSheet}
/>
);
}
const AgentComposerSection = memo(function AgentComposerSection({
agentId,
serverId,
@@ -1546,10 +1605,9 @@ function ActiveAgentComposer({
cwd,
workspaceId,
});
const agentAttachmentScopeKey = useAgentAttachmentScopeKey({ serverId, agentId });
const attachmentScopeKeys = useMemo(
() => [workspaceAttachmentScopeKey, agentAttachmentScopeKey],
[workspaceAttachmentScopeKey, agentAttachmentScopeKey],
() => [workspaceAttachmentScopeKey],
[workspaceAttachmentScopeKey],
);
const handleOpenWorkspaceAttachment = useCallback(
(attachment: WorkspaceComposerAttachment) => {
+13 -12
View File
@@ -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}
/>
<PluginComposerPills
@@ -390,4 +390,39 @@ describe("AgentDirectoryReplica", () => {
expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(true);
store.clearSession(serverId);
});
it("keeps unchanged agent fields when an update changes only the status", () => {
const serverId = "agent-replica-unchanged-fields";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new AgentDirectoryReplica(
serverId,
() => undefined,
() => undefined,
);
const agentInStore = () => useSessionStore.getState().sessions[serverId]?.agents.get("agent");
replica.commitSnapshot([entry(payload("agent"))], []);
const before = agentInStore();
replica.applyDelta({
kind: "upsert",
agent: { ...payload("agent"), status: "running", updatedAt: "2026-07-17T00:02:00.000Z" },
project: entry(payload("agent")).project,
});
const afterDelta = agentInStore();
replica.commitSnapshot(
[entry({ ...payload("agent"), status: "idle", updatedAt: "2026-07-17T00:03:00.000Z" })],
[],
);
const afterSnapshot = agentInStore();
expect(afterDelta?.status).toBe("running");
expect(afterSnapshot?.status).toBe("idle");
for (const agent of [afterDelta, afterSnapshot]) {
expect(agent?.capabilities).toBe(before?.capabilities);
expect(agent?.availableModes).toBe(before?.availableModes);
expect(agent?.labels).toBe(before?.labels);
expect(agent?.projectPlacement).toBe(before?.projectPlacement);
}
store.clearSession(serverId);
});
});
@@ -21,6 +21,26 @@ function withoutAgent(agentId: string) {
};
}
/**
* Every update arrives as a whole new agent, so each nested value (capabilities, modes, labels) is
* a new object even when it did not change. Keep the stored value for every field that is equal,
* and the stored agent when nothing changed, so a reader of one field sees a new value only when
* that field changes.
*/
function keepUnchangedFields(stored: Agent, incoming: Agent): Agent {
const next = { ...incoming };
let changed = Object.keys(stored).length !== Object.keys(next).length;
for (const key of Object.keys(next) as (keyof Agent)[]) {
if (next[key] === stored[key]) continue;
if (key in stored && equal(next[key], stored[key])) {
Object.assign(next, { [key]: stored[key] });
} else {
changed = true;
}
}
return changed ? next : stored;
}
function mergeSnapshotTurn(previous: Agent | undefined, incoming: Agent): Agent {
if (!previous) return incoming;
const activeTurn =
@@ -59,7 +79,8 @@ export class AgentStoreProjection {
let accepted = agent;
useSessionStore.getState().setAgents(this.serverId, (current) => {
const previous = current.get(agent.id);
accepted = acceptAgentDirectoryUpdate(previous, mergeSnapshotTurn(previous, agent));
const winner = acceptAgentDirectoryUpdate(previous, mergeSnapshotTurn(previous, agent));
accepted = previous ? keepUnchangedFields(previous, winner) : winner;
if (accepted === previous) return current;
const next = new Map(current);
next.set(agent.id, accepted);
@@ -117,7 +138,7 @@ export class AgentStoreProjection {
for (const [agentId, fetchedAgent] of fetched) {
const existing = current.get(agentId);
const merged = mergeSnapshotTurn(existing, fetchedAgent);
agents.set(agentId, existing && equal(existing, merged) ? existing : merged);
agents.set(agentId, existing ? keepUnchangedFields(existing, merged) : merged);
if (fetchedAgent.archivedAt) {
clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId });
}
@@ -870,8 +870,12 @@ function WorkspaceDocumentTitleEffectSlot({
);
}
/**
* The header floats over a conversation's timeline. A draft and the agent it creates share one
* timeline, so their surfaces match and the timeline does not move when the tab retargets.
*/
function floatsHeaderOverContent(activeTab: WorkspaceTabDescriptor | null): boolean {
return supportsStreamTopOverlay && activeTab?.kind === "agent";
return supportsStreamTopOverlay && (activeTab?.kind === "agent" || activeTab?.kind === "draft");
}
/** A fullscreen composer covers the compact header; the column lays out without it. */
-99
View File
@@ -9,7 +9,6 @@ import {
hydrateStreamState,
mergeToolCallDetail,
reduceStreamUpdate,
replaceWithCanonicalStream,
streamTimelineItemIdentity,
type AgentToolCallItem,
type StreamItem,
@@ -159,104 +158,6 @@ describe("plugin timeline rows", () => {
});
describe("user message identity", () => {
it.each(["", "Continue here"])(
"restores fork history onto a cached user message (%s)",
(text) => {
const cached = createUserMessage({
clientMessageId: "fork-client",
messageId: "fork-provider",
text,
timestamp: new Date(0),
timelineCursor: { epoch: "timeline", seq: 1 },
});
const history = {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
text: "[User] Earlier question\n[Assistant] Earlier answer",
} as const;
const canonical = createUserMessage({
...cached,
attachments: [history],
});
const result = replaceWithCanonicalStream({
canonical: [canonical],
previousTail: [cached],
previousHead: [],
sendingClientMessageIds: [],
preserveContinuity: true,
canonicalCoverage: { epoch: "timeline", endSeq: 1 },
});
expect(result.tail).toHaveLength(1);
expect(result.tail[0]).toMatchObject({ text, attachments: [history] });
},
);
it.each(["", "Continue from here"])(
"hydrates fork history without optimistic state: %s",
(text) => {
const chatHistory = {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
text: "[User] Earlier question\n[Assistant] Earlier answer",
chatHistorySource: { serverId: "source-host", agentId: "source-agent", title: "Original" },
} as const;
const result = reduceStreamUpdate(
[],
{
type: "timeline",
provider: "codex",
item: { type: "user_message", text, messageId: "fork-prompt", chatHistory },
},
new Date(0),
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ kind: "user_message", text, attachments: [chatHistory] });
},
);
it.each([
{ text: "", insert: "tail" },
{ text: "Continue", insert: "tail" },
{ text: "", insert: "head" },
{ text: "Continue", insert: "head" },
] as const)("delivers fork history to a live viewer ($insert, $text)", ({ text, insert }) => {
const chatHistory = {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
text: "[User] Original question",
chatHistorySource: { serverId: "source-host", agentId: "source-agent" },
} as const;
const event = {
type: "timeline",
provider: "codex",
item: { type: "user_message", text, messageId: "fork-message", chatHistory },
} as const;
const first = applyStreamEvent({
tail: [],
head: [],
event,
timestamp: new Date(0),
unmatchedUserMessageInsert: insert,
});
expect(first[insert]).toHaveLength(1);
expect(first[insert][0]).toMatchObject({
kind: "user_message",
text,
attachments: [chatHistory],
});
const repeated = applyStreamEvent({
...first,
event,
timestamp: new Date(0),
unmatchedUserMessageInsert: insert,
});
expect([...repeated.tail, ...repeated.head]).toHaveLength(1);
expect([...repeated.tail, ...repeated.head][0]).toMatchObject({ attachments: [chatHistory] });
});
it("replaces provisional optimistic turn membership with canonical membership", () => {
const optimistic = createUserMessage({
clientMessageId: "hello-client",
+2 -19
View File
@@ -263,7 +263,6 @@ function produceUserMessage(
clientMessageId: incoming.clientMessageId ?? existing.clientMessageId,
messageId: incoming.messageId ?? existing.messageId,
timelineCursor: incoming.timelineCursor ?? existing.timelineCursor,
attachments: presentation.attachments ?? incoming.attachments,
});
if (
existing.id === merged.id &&
@@ -892,18 +891,6 @@ export function handoffCreatedAgentUserMessageToStream(params: {
});
}
function normalizeUserMessageContent(
text: string,
chatHistory?: Extract<AgentTimelineItem, { type: "user_message" }>["chatHistory"],
) {
const normalized = normalizeChunk(text);
return {
...normalized,
hasContent: normalized.hasContent || chatHistory !== undefined,
attachments: chatHistory ? [chatHistory] : undefined,
};
}
function appendUserMessage(
state: StreamItem[],
text: string,
@@ -913,9 +900,8 @@ function appendUserMessage(
clientMessageId?: string,
timelineCursor?: TimelinePosition,
turnId?: string,
chatHistory?: Extract<AgentTimelineItem, { type: "user_message" }>["chatHistory"],
): StreamItem[] {
const { chunk, hasContent, attachments } = normalizeUserMessageContent(text, chatHistory);
const { chunk, hasContent } = normalizeChunk(text);
if (!hasContent) {
return state;
}
@@ -928,7 +914,6 @@ function appendUserMessage(
timelineCursor,
turnId,
text: chunk,
attachments,
timestamp,
});
return upsertUserMessage(state, nextItem);
@@ -1538,7 +1523,6 @@ function reduceTimelineEvent(
item.clientMessageId,
timelineCursor,
event.turnId,
item.chatHistory,
);
case "assistant_message":
return appendAssistantMessage(
@@ -1890,7 +1874,7 @@ function applyCanonicalUserMessageEvent(params: {
}): ApplyStreamEventResult | null {
const { tail, head, event, timestamp, timelineCursor, unmatchedInsert = "tail" } = params;
if (event.type !== "timeline" || event.item.type !== "user_message") return null;
const normalized = normalizeUserMessageContent(event.item.text, event.item.chatHistory);
const normalized = normalizeChunk(event.item.text);
const flushedTail = head.length > 0 ? flushHeadToTail(tail, head) : tail;
const flushedHead = head.length > 0 ? [] : head;
@@ -1903,7 +1887,6 @@ function applyCanonicalUserMessageEvent(params: {
turnId: event.turnId,
timelineCursor,
text: normalized.chunk,
attachments: normalized.attachments,
timestamp,
});
if (unmatchedInsert === "head") {
@@ -126,6 +126,23 @@ describe("agent directory reconciliation", () => {
}).toEqual({ title: "newer page", status: "running", projectName: "repo" });
});
it("takes the project of a newer buffered upsert", () => {
const snapshotEntry = entry("agent", "idle");
const newerProject = { ...snapshotEntry.project, projectName: "renamed project" };
const result = reconcileAgentDirectory({
snapshot: [snapshotEntry],
deltas: [
{
kind: "upsert",
agent: { ...snapshot("agent", "idle"), updatedAt: "2026-07-12T11:00:00.000Z" },
project: newerProject,
},
],
});
expect(result[0]?.project.projectName).toBe("renamed project");
});
it("clears a snapshot stop when a newer buffered upsert is running", () => {
const result = reconcileAgentDirectory({
snapshot: [entry("agent", "idle")],
+2 -8
View File
@@ -1,5 +1,5 @@
import type { AgentMessage } from "./agent-message.js";
import type { AgentAttachment, ChatHistoryAttachment } from "./messages.js";
import type { AgentAttachment } from "./messages.js";
export type AgentProvider = string;
@@ -373,13 +373,7 @@ export interface AgentTaskItem {
}
export type AgentTimelineItem =
| {
type: "user_message";
text: string;
messageId?: string;
clientMessageId?: string;
chatHistory?: ChatHistoryAttachment;
}
| { type: "user_message"; text: string; messageId?: string; clientMessageId?: string }
| { type: "assistant_message"; text: string; messageId?: string }
| { type: "reasoning"; text: string }
| ToolCallTimelineItem
-9
View File
@@ -1,9 +0,0 @@
/** Remove the fork prompt envelope while preserving the snapshot body verbatim. */
export function extractChatHistoryBody(text: string): string {
const bodyStart = text.indexOf("\n\n");
return text.startsWith("<chat-history-summary>\n") &&
text.endsWith("\n</chat-history-summary>") &&
bodyStart !== -1
? text.slice(bodyStart + 2, -"\n</chat-history-summary>".length)
: text;
}
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import {
AgentTimelineItemPayloadSchema,
AgentForkContextRequestMessageSchema,
AgentForkContextResponseMessageSchema,
CreateAgentRequestMessageSchema,
@@ -10,24 +9,6 @@ import {
} from "./messages.js";
describe("shared messages attachments", () => {
it("round-trips fork history on a timeline row and accepts rows without it", () => {
const row = {
type: "user_message",
text: "",
messageId: "fork-prompt",
chatHistory: {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
text: "Earlier context",
chatHistorySource: { serverId: "source-host", agentId: "source-agent", title: "Original" },
},
};
expect(AgentTimelineItemPayloadSchema.parse(row)).toEqual(row);
const legacy = { type: "user_message", text: "Earlier message" };
expect(AgentTimelineItemPayloadSchema.parse(legacy)).toEqual(legacy);
});
it("preserves an optional timeline cursor on fork-context messages", () => {
const boundaryCursor = { epoch: "timeline-1", seq: 42 };
const request = AgentForkContextRequestMessageSchema.parse({
@@ -288,29 +269,6 @@ describe("shared messages attachments", () => {
]);
});
it("preserves the source of a fork across message submission", () => {
const attachment = {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
title: "Chat history",
text: "Earlier context",
chatHistorySource: {
serverId: "source-host",
agentId: "source-agent",
title: "Original conversation",
},
};
const parsed = SendAgentMessageRequestSchema.parse({
type: "send_agent_message_request",
requestId: "req-fork-source",
agentId: "fork-agent",
text: "Continue",
attachments: [attachment],
});
expect(parsed.attachments).toEqual([attachment]);
});
it("keeps known text attachment context kinds and ignores future ones", () => {
const parsed = SendAgentMessageRequestSchema.parse({
type: "send_agent_message_request",
-21
View File
@@ -712,25 +712,6 @@ const ToolCallTimelineItemPayloadSchema: z.ZodType<ToolCallTimelineItem, unknown
ToolCallCanceledPayloadSchema,
]);
export const ChatHistorySourceSchema = z.object({
serverId: z.string(),
agentId: z.string(),
title: z.string().optional(),
});
export type ChatHistorySource = z.infer<typeof ChatHistorySourceSchema>;
export const ChatHistoryAttachmentSchema = z.object({
type: z.literal("text"),
mimeType: z.literal("text/plain"),
contextKind: z.literal("chat_history"),
title: z.string().nullable().optional(),
text: z.string(),
chatHistorySource: ChatHistorySourceSchema.optional(),
});
export type ChatHistoryAttachment = z.infer<typeof ChatHistoryAttachmentSchema>;
// zod-aot 0.20.4 miscompiles this as a nested discriminated union by omitting
// the inner tool_call branch from the generated outer dispatch.
export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem, unknown> = z.union([
@@ -739,7 +720,6 @@ export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem, unknow
text: z.string(),
messageId: z.string().optional(),
clientMessageId: z.string().optional(),
chatHistory: ChatHistoryAttachmentSchema.optional(),
}),
z.object({
type: z.literal("assistant_message"),
@@ -1200,7 +1180,6 @@ export const TextAttachmentSchema = z
title: z.string().nullable().optional(),
text: z.string(),
externalResource: ExternalResourceAttachmentMetadataSchema.optional(),
chatHistorySource: ChatHistorySourceSchema.optional(),
})
.transform(({ contextKind, ...attachment }) => ({
...attachment,
@@ -572,57 +572,3 @@ it("preserves every message in fork history below the character budget", () => {
`<chat-history-summary>\nChat history from a previous Paseo agent.\n\n[User] ${text}\n</chat-history-summary>`,
);
});
it.each(["", "Continue in B"])("carries inherited history through repeated forks (%s)", (text) => {
const original = buildAgentForkContextAttachment({
rows: [
row(1, { type: "user_message", text: "Question in A" }),
row(2, { type: "assistant_message", text: "Answer in A" }),
],
});
const child = buildAgentForkContextAttachment({
rows: [
row(1, {
type: "user_message",
text,
chatHistory: { ...original.attachment, contextKind: "chat_history" },
}),
row(2, { type: "assistant_message", text: "Answer in B" }),
],
});
const grandchild = buildAgentForkContextAttachment({
rows: [
row(1, {
type: "user_message",
text: "Continue in C",
chatHistory: { ...child.attachment, contextKind: "chat_history" },
}),
row(2, { type: "assistant_message", text: "Answer in C" }),
],
});
expect(grandchild.attachment.text).toContain("[User] Question in A\n[Assistant] Answer in A");
expect(grandchild.attachment.text).toContain("[Assistant] Answer in B");
expect(grandchild.attachment.text).toContain("[User] Continue in C\n[Assistant] Answer in C");
expect(grandchild.attachment.text.match(/<chat-history-summary>/g)).toHaveLength(1);
});
it("keeps the fork budget when inherited history exceeds the remaining space", () => {
const result = buildAgentForkContextAttachment({
rows: [
row(1, {
type: "user_message",
text: "Current question",
chatHistory: {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
text: "Old ".repeat(30_000),
},
}),
row(2, { type: "assistant_message", text: "Latest answer" }),
],
});
expect(result.attachment.text.length).toBeLessThanOrEqual(100_000);
expect(result.attachment.text).toContain("[Earlier chat history omitted]");
expect(result.attachment.text).toContain("[User] Current question\n[Assistant] Latest answer");
});
@@ -1,4 +1,3 @@
import { extractChatHistoryBody } from "@getpaseo/protocol/chat-history";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
@@ -22,7 +21,6 @@ const FORK_CONTEXT_KINDS: readonly AgentTimelineItem["type"][] = [
];
interface ActivityCuratorOptions {
includeChatHistory?: boolean;
maxItems?: number;
labelAssistantMessages?: boolean;
includeKinds?: readonly AgentTimelineItem["type"][];
@@ -39,17 +37,6 @@ function activityEntry(text: string): ActivityEntry {
return { text };
}
function appendUserEntry(
entries: ActivityEntry[],
item: Extract<AgentTimelineItem, { type: "user_message" }>,
options?: ActivityCuratorOptions,
) {
if (options?.includeChatHistory && item.chatHistory) {
entries.push(activityEntry(extractChatHistoryBody(item.chatHistory.text)));
}
entries.push(activityEntry(`[User] ${item.text.trim()}`));
}
function appendTextEntry(
entries: ActivityEntry[],
item: Extract<AgentTimelineItem, { type: "assistant_message" | "reasoning" }>,
@@ -171,7 +158,7 @@ function curateProjectedActivityEntries(
switch (item.type) {
case "user_message":
appendUserEntry(entries, item, options);
entries.push(activityEntry(`[User] ${item.text.trim()}`));
break;
case "assistant_message":
case "reasoning":
@@ -374,7 +361,6 @@ export function buildAgentForkContextAttachment(input: {
});
const entries = curateProjectedActivityEntries(selected.items, {
maxItems: 0,
includeChatHistory: true,
labelAssistantMessages: true,
includeKinds: FORK_CONTEXT_KINDS,
includeExternalToolInput: false,
@@ -729,159 +729,6 @@ test("uses an injected timeline store without making it a production requirement
}
});
test.each([true, false])(
"fork history survives storage-backed replay (provider echo: %s)",
async (echo) => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-fork-history-"));
const storagePath = join(workdir, "agents");
const attachment = {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
text: "<chat-history-summary>\nChat history from a previous Paseo agent.\n\n[User] Original question\n[Assistant] Original answer\n</chat-history-summary>",
chatHistorySource: { serverId: "source-host", agentId: "source-agent", title: "Original" },
} as const;
const clientMessageId = "fork-prompt";
let hasHistory = false;
let includeFork = true;
class HistorySession extends TestAgentSession {
override async startTurn(): Promise<{ turnId: string }> {
hasHistory = true;
if (echo)
this.pushEvent({
type: "timeline",
provider: "codex",
item: {
type: "user_message",
text: attachment.text,
messageId: "native-fork",
clientMessageId,
},
});
return super.startTurn();
}
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
if (!hasHistory) return;
if (includeFork)
yield {
type: "timeline",
provider: "codex",
item: { type: "user_message", text: attachment.text, messageId: "native-fork" },
};
yield {
type: "timeline",
provider: "codex",
item: { type: "user_message", text: attachment.text, messageId: "native-later" },
};
}
}
class HistoryClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new HistorySession(config);
}
override async resumeSession(): Promise<AgentSession> {
return new HistorySession({ provider: "codex", cwd: workdir });
}
}
const client = new HistoryClient();
let storage = new AgentStorage(storagePath, logger);
let manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
onTestFinished(async () => {
await manager.closeAgent(agent.id);
await manager.flush();
rmSync(workdir, { recursive: true, force: true });
});
await drainAsyncGenerator(manager.streamAgent(agent.id, [attachment], { clientMessageId }));
expect((await manager.getTimelineRows(agent.id))[0]?.item).toMatchObject({
type: "user_message",
text: "",
chatHistory: attachment,
});
await manager.closeAgent(agent.id);
await manager.flush();
storage = new AgentStorage(storagePath, logger);
manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
await ensureAgentLoaded(agent.id, { agentManager: manager, agentStorage: storage, logger });
await manager.hydrateTimelineFromProvider(agent.id);
expect((await manager.getTimelineRows(agent.id)).map((row) => row.item)).toEqual([
{ type: "user_message", text: "", messageId: "native-fork", chatHistory: attachment },
{ type: "user_message", text: attachment.text, messageId: "native-later" },
]);
// A provider rewind must not recreate a removed fork prompt.
includeFork = false;
await manager.hydrateTimelineFromProvider(agent.id, { force: true });
expect((await manager.getTimelineRows(agent.id)).map((row) => row.item)).toEqual([
{ type: "user_message", text: attachment.text, messageId: "native-later" },
]);
},
);
test.each([undefined, "native-replay-id"])(
"restores fork history after a local submission echo and replay ID %s",
async (replayId) => {
const workdir = mkdtempSync(join(tmpdir(), "fork-local-echo-"));
const clientMessageId = "local-fork-prompt";
const attachment = {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
text: "[User] Original question",
} as const;
class LocalEchoSession extends TestAgentSession {
override async startTurn(): Promise<{ turnId: string }> {
this.pushEvent({
type: "timeline",
provider: "codex",
item: {
type: "user_message",
text: attachment.text,
messageId: clientMessageId,
clientMessageId,
},
});
return super.startTurn();
}
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
yield {
type: "timeline",
provider: "codex",
item: {
type: "user_message",
text: attachment.text,
...(replayId ? { messageId: replayId } : {}),
},
};
}
}
class LocalEchoClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new LocalEchoSession(config);
}
}
const manager = new AgentManager({ clients: { codex: new LocalEchoClient() }, logger });
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
try {
await drainAsyncGenerator(manager.streamAgent(agent.id, [attachment], { clientMessageId }));
await manager.hydrateTimelineFromProvider(agent.id, { force: true });
expect((await manager.getTimelineRows(agent.id))[0]?.item).toMatchObject({
type: "user_message",
text: "",
chatHistory: attachment,
});
} finally {
await manager.closeAgent(agent.id);
await manager.flush();
rmSync(workdir, { recursive: true, force: true });
}
},
);
test("refreshing an agent replaces the injected timeline store instead of appending a second copy", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-rehydrate-"));
const store = new RecordingTimelineStore();
@@ -441,7 +441,6 @@ interface HandleStreamEventOptions {
}
interface ManagedAgentBase {
forkContext?: StoredAgentRecord["forkContext"];
id: string;
provider: AgentProvider;
cwd: string;
@@ -2702,15 +2701,11 @@ export class AgentManager {
if (event.type === "timeline") {
this.touchUpdatedAt(agent);
const row = this.recordTimeline(agent.id, event.item);
this.dispatchStream(
agent.id,
{ ...event, item: row.item },
{
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
},
);
this.dispatchStream(agent.id, event, {
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
});
return;
}
this.dispatchStream(agent.id, event, { timestamp: new Date().toISOString() });
@@ -3924,8 +3919,6 @@ export class AgentManager {
options,
});
await this.restoreForkContext(managed);
// Read history before publishing the agent: a provider failure must leave the
// session unregistered so the registration catch closes it.
const startupHistory: AgentStreamEvent[] = [];
@@ -4498,15 +4491,11 @@ export class AgentManager {
event.timestamp ? { timestamp: event.timestamp } : undefined,
);
if (broadcastTimeline) {
this.dispatchStream(
agent.id,
{ ...event, item: row.item },
{
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
},
);
this.dispatchStream(agent.id, event, {
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
});
}
}
this.touchUpdatedAt(agent);
@@ -4571,17 +4560,13 @@ export class AgentManager {
event.timestamp ? { timestamp: event.timestamp } : undefined,
);
if (deferredBroadcast) {
timelineEvents.push({ event: { ...event, item: row.item }, row });
timelineEvents.push({ event, row });
} else if (broadcast) {
this.dispatchStream(
agent.id,
{ ...event, item: row.item },
{
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
},
);
this.dispatchStream(agent.id, event, {
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
});
}
}
agent.historyPrimed = true;
@@ -5116,7 +5101,7 @@ export class AgentManager {
const row = this.recordTimeline(agentId, item, { ...options, turnId });
const event: AgentStreamEvent = {
type: "timeline",
item: row.item,
item,
provider,
...(turnId !== undefined ? { turnId } : {}),
};
@@ -5148,25 +5133,9 @@ export class AgentManager {
outcome: "accepted" | "rejected",
options?: { messageId?: string; providerMessageId?: string; turnId?: string },
): void {
const text = submittedPromptText(prompt);
if (typeof prompt !== "string") {
const attachment = prompt.find(
(block) =>
block.type === "text" && "contextKind" in block && block.contextKind === "chat_history",
);
if (attachment?.type === "text" && "mimeType" in attachment) {
agent.forkContext = {
clientMessageId,
providerMessageId:
options?.providerMessageId !== clientMessageId ? options?.providerMessageId : undefined,
text,
attachment: { ...attachment, contextKind: "chat_history" },
};
}
}
const items = projectAgentMessages({
type: "user_message",
text,
text: submittedPromptText(prompt),
clientMessageId,
...(options?.messageId ? { messageId: options.messageId } : {}),
});
@@ -5214,10 +5183,6 @@ export class AgentManager {
}
if (!existing || existing.item.type !== "user_message") return null;
if (messageId) {
if (agent.forkContext?.clientMessageId === clientMessageId && messageId !== clientMessageId) {
agent.forkContext.providerMessageId = messageId;
this.enqueueBackgroundPersist(agent);
}
const enriched = this.timelineStore.enrichSubmittedUserMessage(
agent.id,
clientMessageId,
@@ -5282,10 +5247,6 @@ export class AgentManager {
return parts.join("\n\n");
}
private async restoreForkContext(agent: ManagedAgent): Promise<void> {
agent.forkContext = (await this.registry?.get(agent.id))?.forkContext;
}
private recordTimeline(
agentId: string,
item: AgentTimelineItem,
@@ -5295,29 +5256,6 @@ export class AgentManager {
turnId?: string;
},
): AgentTimelineRow {
const agent = this.agents.get(agentId);
const context = agent?.forkContext;
if (
context &&
item.type === "user_message" &&
(item.clientMessageId === context.clientMessageId ||
(context.providerMessageId
? item.messageId === context.providerMessageId
: context.attachment.text.length > 0 && item.text.includes(context.attachment.text)))
) {
// Providers without client IDs can be matched once by the exact snapshot.
// Remember their native ID so a later quotation is not mistaken for the fork.
if (
agent &&
!context.providerMessageId &&
item.messageId &&
item.messageId !== context.clientMessageId
) {
context.providerMessageId = item.messageId;
this.enqueueBackgroundPersist(agent);
}
item = { ...item, text: context.text, chatHistory: context.attachment };
}
item = limitAgentTimelineItemContent(item);
const row = this.timelineStore.append(agentId, item, options);
this.enqueueDurableTimelineAppend(agentId, row);
@@ -92,7 +92,6 @@ export function toStoredAgentRecord(
lastUserMessageAt: agent.lastUserMessageAt ? agent.lastUserMessageAt.toISOString() : null,
title: options?.title ?? null,
labels: agent.labels,
forkContext: agent.forkContext,
lastStatus: agent.lifecycle,
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
config: config ?? null,
@@ -9,7 +9,7 @@ import type {
ToolPolicy,
} from "@getpaseo/protocol/agent-types";
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import type { AgentAttachment, ChatHistoryAttachment } from "@getpaseo/protocol/messages";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { PaseoToolCatalog } from "./tools/types.js";
export type {
@@ -398,13 +398,7 @@ export interface PluginTimelineItem {
}
export type AgentTimelineItem =
| {
type: "user_message";
text: string;
messageId?: string;
clientMessageId?: string;
chatHistory?: ChatHistoryAttachment;
}
| { type: "user_message"; text: string; messageId?: string; clientMessageId?: string }
| { type: "assistant_message"; text: string; messageId?: string }
| { type: "reasoning"; text: string }
| ToolCallTimelineItem
@@ -4,7 +4,7 @@ import { z } from "zod";
import type { Logger } from "pino";
import { writeJsonFileAtomic } from "../atomic-file.js";
import { AgentFeatureSchema, AgentStatusSchema, ChatHistoryAttachmentSchema } from "../messages.js";
import { AgentFeatureSchema, AgentStatusSchema } from "../messages.js";
import { toStoredAgentRecord } from "./agent-projections.js";
import type { ManagedAgent } from "./agent-manager.js";
import type { AgentSessionConfig } from "./agent-sdk-types.js";
@@ -53,15 +53,6 @@ const STORED_AGENT_SCHEMA = z.object({
lastUserMessageAt: z.string().nullable().optional(),
title: z.string().nullable().optional(),
labels: z.record(z.string(), z.string()).default({}),
// Provider transcripts retain the rendered prompt, not attachment metadata.
forkContext: z
.object({
clientMessageId: z.string(),
providerMessageId: z.string().optional(),
text: z.string(),
attachment: ChatHistoryAttachmentSchema,
})
.optional(),
lastStatus: AgentStatusSchema.default("closed"),
resumeOnStartup: z.boolean().optional(),
lastModeId: z.string().nullable().optional(),
@@ -270,6 +270,16 @@ describe.skipIf(isPlatform("win32"))("terminal worker reaper", () => {
await reapTerminalWorkers({ paseoHome: home, inspector, gracefulMs: 0 });
expect(isAlive(unrelated.pid!)).toBe(true);
// Completion means the recorded process is gone, even if its PID is occupied.
await expect
.poll(
async () =>
!isAlive(original.pid) ||
classifyTerminalProgramProcess(original, await inspector.inspect(original.pid)).status ===
"unrelated",
{ timeout: 10000 },
)
.toBe(true);
expect(existsSync(recordPath)).toBe(false);
expect(
classifyTerminalProgramProcess(original, await inspector.inspect(original.pid)),
@@ -867,7 +867,18 @@ it.each([false, true])(
writeFileSync(exitRequestPath, "");
await waitForCondition(() => first.getTerminal(ending.id) === undefined, 10000);
expect(existsSync(exitObservedPath)).toBe(true);
expect(isProcessAlive(endingPid)).toBe(false);
const originalEnding = record!.terminals.find((entry) => entry.id === ending.id)!;
// Exit delivery does not reserve the PID. Wait for the original process to
// disappear, allowing Windows to reuse its PID just as in the restart check below.
await expect
.poll(
async () =>
!isProcessAlive(endingPid) ||
classifyTerminalProgramProcess(originalEnding, await inspector.inspect(endingPid))
.status === "unrelated",
{ timeout: 10000 },
)
.toBe(true);
} finally {
if (recordFd !== null) closeSync(recordFd);
}
+47 -20
View File
@@ -25,6 +25,9 @@
# 10. A size change keeps only a row shouldRestorePosition accepts in place (prepareMVCP), as a
# data change already does, so a disclosure that only grows (a todo card) holds the cut-off
# row instead of the first row in view.
# 11. A 0 x 0 measurement is not a size (updateItemSizesBatch): rows of a hidden retained tab
# (display: none) still render and measure 0 x 0. Recording it collapsed the row, and a row
# whose frame was unchanged when the tab showed again got no layout event to correct it.
#
# Upgrading: drop each hunk whose fix the new version ships, regenerate with
# `npx patch-package @legendapp/list`, and copy this header back on top.
@@ -45,7 +48,7 @@ index 983e937..970ba51 100644
* Returns a stable item type used for pooling and size estimation.
*/
diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js
index eeb3fb0..b1689ad 100644
index eeb3fb0..5fe940d 100644
--- a/node_modules/@legendapp/list/react-native.js
+++ b/node_modules/@legendapp/list/react-native.js
@@ -1497,7 +1497,15 @@ function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, no
@@ -205,7 +208,19 @@ index eeb3fb0..b1689ad 100644
}
// src/core/updateItemSizes.ts
@@ -5414,7 +5458,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
@@ -5291,6 +5335,11 @@ function updateItemSizesBatch(ctx, measurements) {
const result = {};
const previousTotalSize = state.totalSize;
for (const measurement of measurements) {
+ // Paseo patch: a view inside a display: none ancestor measures 0 x 0. That is no measurement: a
+ // laid-out row is as wide as the list even when it has no height. Recording 0 there collapsed
+ // the row, and when the list showed again a row whose frame had not changed got no layout event
+ // to correct it, so the next row overlapped it.
+ if (measurement.size.width === 0 && measurement.size.height === 0) continue;
const ownsMeasuredItem = measurement.containerId === void 0 || isContainerItemCurrent(ctx, measurement.containerId, measurement.itemKey);
if (ownsMeasuredItem) {
const index = state.indexByKey.get(measurement.itemKey);
@@ -5414,7 +5463,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
state.fixedItemSizeKeys.add(itemKey);
}
const prevSizeKnown = sizesKnown.get(itemKey);
@@ -214,7 +229,7 @@ index eeb3fb0..b1689ad 100644
return 0;
}
const size = Platform.OS === "web" ? Math.round(rawSize) : roundSize(rawSize);
@@ -6283,6 +6327,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
@@ -6283,6 +6332,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
// src/components/Containers.native.tsx
var useCommittedMinSize = IsNewArchitecture ? () => useArr$(["totalSize"])[0] : () => void 0;
@@ -225,7 +240,7 @@ index eeb3fb0..b1689ad 100644
var ContainersLayer = typedMemo(function ContainersLayer2({
children,
freshDataTransitionEpoch,
@@ -6290,7 +6338,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
@@ -6290,7 +6343,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
}) {
const ctx = useStateContext();
const columnWrapperStyle = ctx.columnWrapperStyle;
@@ -234,7 +249,7 @@ index eeb3fb0..b1689ad 100644
const minSize = useCommittedMinSize();
const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]);
const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch);
@@ -6513,6 +6561,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
@@ -6513,6 +6566,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
null
);
});
@@ -249,7 +264,7 @@ index eeb3fb0..b1689ad 100644
var ListComponent = typedMemo(function ListComponent2({
canRender,
freshDataTransitionEpoch,
@@ -6651,7 +6707,7 @@ var ListComponent = typedMemo(function ListComponent2({
@@ -6651,7 +6712,7 @@ var ListComponent = typedMemo(function ListComponent2({
style: autoOtherAxisStyle ? [autoOtherAxisStyle, style] : style
},
/* @__PURE__ */ React2__namespace.createElement(ScrollAdjust, null),
@@ -258,7 +273,7 @@ index eeb3fb0..b1689ad 100644
ListEmptyComponent && getComponent(ListEmptyComponent),
shouldRenderAlignItemsAtEndSpacer && /* @__PURE__ */ React2__namespace.createElement(AlignItemsAtEndSpacer, { horizontal }),
canRender && !ListEmptyComponent && /* @__PURE__ */ React2__namespace.createElement(
@@ -6665,7 +6721,7 @@ var ListComponent = typedMemo(function ListComponent2({
@@ -6665,7 +6726,7 @@ var ListComponent = typedMemo(function ListComponent2({
stickyHeaderConfig
}
),
@@ -267,7 +282,7 @@ index eeb3fb0..b1689ad 100644
IS_DEV && ENABLE_DEVMODE
);
});
@@ -7886,7 +7942,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
@@ -7886,7 +7947,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
const state = ctx.state;
if (stylePaddingTop !== void 0) {
const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0;
@@ -279,7 +294,7 @@ index eeb3fb0..b1689ad 100644
let prevTotalSize = peek$(ctx, "totalSize") || 0;
set$(ctx, "totalSize", prevTotalSize + prevStylePaddingTop);
state.timeoutSetPaddingTop = setTimeout(() => {
@@ -7989,6 +8048,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
@@ -7989,6 +8053,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
estimatedListSize,
extraData,
getFixedItemSize,
@@ -287,7 +302,7 @@ index eeb3fb0..b1689ad 100644
getItemType,
horizontal,
rtl,
@@ -8282,6 +8342,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
@@ -8282,6 +8347,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
dataVersion,
drawDistance,
estimatedItemSize,
@@ -296,7 +311,7 @@ index eeb3fb0..b1689ad 100644
getItemType: useWrapIfItem(getItemType),
hasExternalScroll,
diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs
index fe5f415..e61945e 100644
index fe5f415..7f1b9dd 100644
--- a/node_modules/@legendapp/list/react-native.mjs
+++ b/node_modules/@legendapp/list/react-native.mjs
@@ -1476,7 +1476,15 @@ function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, no
@@ -456,7 +471,19 @@ index fe5f415..e61945e 100644
}
// src/core/updateItemSizes.ts
@@ -5393,7 +5437,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
@@ -5270,6 +5314,11 @@ function updateItemSizesBatch(ctx, measurements) {
const result = {};
const previousTotalSize = state.totalSize;
for (const measurement of measurements) {
+ // Paseo patch: a view inside a display: none ancestor measures 0 x 0. That is no measurement: a
+ // laid-out row is as wide as the list even when it has no height. Recording 0 there collapsed
+ // the row, and when the list showed again a row whose frame had not changed got no layout event
+ // to correct it, so the next row overlapped it.
+ if (measurement.size.width === 0 && measurement.size.height === 0) continue;
const ownsMeasuredItem = measurement.containerId === void 0 || isContainerItemCurrent(ctx, measurement.containerId, measurement.itemKey);
if (ownsMeasuredItem) {
const index = state.indexByKey.get(measurement.itemKey);
@@ -5393,7 +5442,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
state.fixedItemSizeKeys.add(itemKey);
}
const prevSizeKnown = sizesKnown.get(itemKey);
@@ -465,7 +492,7 @@ index fe5f415..e61945e 100644
return 0;
}
const size = Platform.OS === "web" ? Math.round(rawSize) : roundSize(rawSize);
@@ -6262,6 +6306,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
@@ -6262,6 +6311,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
// src/components/Containers.native.tsx
var useCommittedMinSize = IsNewArchitecture ? () => useArr$(["totalSize"])[0] : () => void 0;
@@ -476,7 +503,7 @@ index fe5f415..e61945e 100644
var ContainersLayer = typedMemo(function ContainersLayer2({
children,
freshDataTransitionEpoch,
@@ -6269,7 +6317,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
@@ -6269,7 +6322,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
}) {
const ctx = useStateContext();
const columnWrapperStyle = ctx.columnWrapperStyle;
@@ -485,7 +512,7 @@ index fe5f415..e61945e 100644
const minSize = useCommittedMinSize();
const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]);
const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch);
@@ -6492,6 +6540,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
@@ -6492,6 +6545,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
null
);
});
@@ -500,7 +527,7 @@ index fe5f415..e61945e 100644
var ListComponent = typedMemo(function ListComponent2({
canRender,
freshDataTransitionEpoch,
@@ -6630,7 +6686,7 @@ var ListComponent = typedMemo(function ListComponent2({
@@ -6630,7 +6691,7 @@ var ListComponent = typedMemo(function ListComponent2({
style: autoOtherAxisStyle ? [autoOtherAxisStyle, style] : style
},
/* @__PURE__ */ React2.createElement(ScrollAdjust, null),
@@ -509,7 +536,7 @@ index fe5f415..e61945e 100644
ListEmptyComponent && getComponent(ListEmptyComponent),
shouldRenderAlignItemsAtEndSpacer && /* @__PURE__ */ React2.createElement(AlignItemsAtEndSpacer, { horizontal }),
canRender && !ListEmptyComponent && /* @__PURE__ */ React2.createElement(
@@ -6644,7 +6700,7 @@ var ListComponent = typedMemo(function ListComponent2({
@@ -6644,7 +6705,7 @@ var ListComponent = typedMemo(function ListComponent2({
stickyHeaderConfig
}
),
@@ -518,7 +545,7 @@ index fe5f415..e61945e 100644
IS_DEV && ENABLE_DEVMODE
);
});
@@ -7865,7 +7921,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
@@ -7865,7 +7926,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
const state = ctx.state;
if (stylePaddingTop !== void 0) {
const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0;
@@ -530,7 +557,7 @@ index fe5f415..e61945e 100644
let prevTotalSize = peek$(ctx, "totalSize") || 0;
set$(ctx, "totalSize", prevTotalSize + prevStylePaddingTop);
state.timeoutSetPaddingTop = setTimeout(() => {
@@ -7968,6 +8027,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
@@ -7968,6 +8032,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
estimatedListSize,
extraData,
getFixedItemSize,
@@ -538,7 +565,7 @@ index fe5f415..e61945e 100644
getItemType,
horizontal,
rtl,
@@ -8261,6 +8321,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
@@ -8261,6 +8326,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
dataVersion,
drawDistance,
estimatedItemSize,