Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 47691eee91 fix(app): keep rewound history in its own chat
A restored fork snapshot belongs to the rewound agent. Give it an agent attachment scope so switching chats cannot send or consume that context elsewhere in the workspace.
2026-11-06 06:31:46 +00:00
paseo-bot[bot] 15c8795cc6 test(app): expect source metadata on submitted fork history
The creation flow now preserves the history source in both the pending attempt and submitted attachment.
2026-11-06 06:20:59 +00:00
paseo-bot[bot] 42cbec5bd8 fix(app): preserve inherited context through rewind and replay
Restore fork history alongside the editable prompt after conversation rewind, including history-only prompts. Keep locally assigned submission IDs distinct from provider IDs so replay without that local ID can recover the snapshot.
2026-11-06 06:18:06 +00:00
paseo-bot[bot]andwengshihao 16425915e5 fix(app): keep fork history readable after restoring cached messages
Cached user messages omit attachments. Reconciliation preserved that incomplete presentation over the canonical snapshot, so a reload removed the history card. Preserve canonical attachments when the cached presentation has none.

Co-authored-by: wengshihao <45631373+wengshihao@users.noreply.github.com>
2026-11-06 05:56:08 +00:00
wengshihao 5800a626b9 fix: preserve inherited fork context and live history attachments 2026-11-06 13:24:38 +08:00
wengshihao 5d71206b72 test(app): wait for the active fork when tracing its source 2026-11-06 12:09:49 +08:00
wengshihao 0a0f443691 fix(app): make forked chat history readable 2026-11-06 12:07:29 +08:00
66 changed files with 1560 additions and 1005 deletions
+2 -11
View File
@@ -179,17 +179,8 @@ 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 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:
lists the corrections and the React Native issues they work around. Its instrumentation test,
`VisibleContentPositionClampTest`, mounts through React Native's own mounting manager:
```bash
cd packages/app/android
@@ -235,7 +235,6 @@ 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;
@@ -246,9 +245,6 @@ 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;
@@ -261,24 +257,14 @@ 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;
sawHidden: boolean;
}
| { active: boolean; largestDownwardShift: number; sawMissing: 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,
sawHidden: state.sawHidden,
};
return { largestDownwardShift: state.largestDownwardShift, sawMissing: state.sawMissing };
});
expect(result.sawMissing).toBe(false);
expect(result.sawHidden).toBe(false);
expect(result.largestDownwardShift).toBeLessThanOrEqual(2);
};
}
@@ -1192,13 +1178,7 @@ 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,6 +1,18 @@
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,
@@ -38,6 +50,97 @@ 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,3 +112,21 @@ 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",
);
}
@@ -0,0 +1,82 @@
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,7 +157,10 @@ export interface SeedDaemonClient {
sendAgentMessage(
agentId: string,
text: string,
options?: { sourceAgentId?: string },
options?: {
sourceAgentId?: string;
attachments?: import("@getpaseo/protocol/messages").AgentAttachment[];
},
): Promise<void>;
waitForFinish(
agentId: string,
@@ -1,158 +0,0 @@
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)
}
}
@@ -1,19 +0,0 @@
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,13 +12,10 @@ import com.facebook.react.views.scroll.ReactScrollView;
import com.facebook.react.views.scroll.ReactScrollViewHelper;
/**
* React Native's vertical scroll view with timeline corrections. Java, because the content-position
* React Native's vertical scroll view with two 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";
@@ -35,26 +32,6 @@ 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,6 +65,7 @@ interface BottomAnchorControllerDriver {
pendingVerification: AttemptContext | null;
blockedReason: BottomAnchorBlockedReason | null;
};
resetForAgent: () => void;
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
beginUserScroll: () => void;
@@ -479,6 +480,17 @@ 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;
@@ -729,6 +741,10 @@ export function useBottomAnchorController(input: {
});
}
useEffect(() => {
driverRef.current?.resetForAgent();
}, [input.agentId]);
useEffect(() => {
driverRef.current?.applyRouteRequest(input.routeRequest);
}, [input.routeRequest]);
@@ -129,8 +129,7 @@ function mountList(heldRowId: string) {
}
return position - state.scroll;
};
const rowSize = (rowId: string) => listRef.current?.getState().sizes.get(rowId);
return { render, scrollTo, heldRowTop, rowSize };
return { render, scrollTo, heldRowTop };
}
it("anchors a data change on a held row whose top is above the viewport", async () => {
@@ -170,21 +169,3 @@ 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,8 +137,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
} = props;
const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers;
const listRef = useRef<LegendListRef>(null);
// LegendList places the rows once, behind a hidden reveal, when the list mounts.
const [isListPlaced, setIsListPlaced] = useState(false);
// LegendList places the rows of each agent once, behind a hidden reveal (`dataKey`).
const [placedAgentId, setPlacedAgentId] = useState<string | null>(null);
const isListPlaced = placedAgentId === agentId;
const isListPlacedRef = useRef(isListPlaced);
isListPlacedRef.current = isListPlaced;
const streamViewportMetricsRef = useRef({
@@ -383,6 +384,23 @@ 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();
@@ -393,7 +411,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null;
};
}, [clearPendingUserScrollEnd, evaluateHistoryStart]);
}, [agentId, clearPendingUserScrollEnd, disclosureHold, evaluateHistoryStart]);
useEffect(() => {
const handle: StreamViewportHandle = {
@@ -605,7 +623,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const handleListPlaced = useStableEvent(() => {
isListPlacedRef.current = true;
setIsListPlaced(true);
setPlacedAgentId(agentId);
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
contentMeasuredForKey: "native-virtualized",
@@ -675,7 +693,6 @@ 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}
@@ -694,6 +711,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
onContentSizeChange={handleContentSizeChange}
onItemSizeChanged={handleItemSizeChanged}
onReady={handleListPlaced}
dataKey={agentId}
maintainVisibleContentPosition={maintainVisibleContentPosition}
renderScrollComponent={renderScrollComponent}
recycleItems={false}
+10 -2
View File
@@ -839,6 +839,10 @@ 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();
@@ -849,7 +853,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null;
};
}, [evaluateHistoryStart]);
}, [evaluateHistoryStart, props.agentId, readingAnchor]);
useLayoutEffect(() => {
if (!isActiveRef.current || !isActivationReady) {
@@ -1391,7 +1395,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): StreamStrategy {
return createStreamStrategy({
render: (renderInput) => (
<WebStreamViewport {...renderInput} isMobileBreakpoint={input.isMobileBreakpoint} />
<WebStreamViewport
key={renderInput.agentId}
{...renderInput}
isMobileBreakpoint={input.isMobileBreakpoint}
/>
),
orderTailReverse: false,
orderHeadReverse: false,
@@ -21,7 +21,7 @@ describe("useStreamHistoryWindow", () => {
]).flat();
const { result } = renderHook(() =>
useStreamHistoryWindow({ items, loadRemoteOlder: vi.fn() }),
useStreamHistoryWindow({ agentId: "agent-1", items, loadRemoteOlder: vi.fn() }),
);
expect(result.current.start).toBe(2);
@@ -35,6 +35,7 @@ describe("useStreamHistoryWindow", () => {
const loadRemoteOlder = vi.fn(async () => true);
const { result } = renderHook(() =>
useStreamHistoryWindow({
agentId: "agent-1",
items: [
message("u1", "user_message"),
message("a1", "assistant_message"),
@@ -66,7 +67,8 @@ describe("useStreamHistoryWindow", () => {
message("a2", "assistant_message"),
];
const { result, rerender } = renderHook(
({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }),
({ history }) =>
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
{ initialProps: { history: items } },
);
@@ -89,7 +91,8 @@ describe("useStreamHistoryWindow", () => {
message("a2", "assistant_message"),
];
const { result, rerender } = renderHook(
({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }),
({ history }) =>
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
{ initialProps: { history: items } },
);
@@ -105,7 +108,8 @@ describe("useStreamHistoryWindow", () => {
configurable: true,
});
const { result, rerender } = renderHook(
({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }),
({ history }) =>
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
{
initialProps: {
history: [
@@ -138,6 +142,7 @@ describe("useStreamHistoryWindow", () => {
});
const { result } = renderHook(() =>
useStreamHistoryWindow({
agentId: "agent-1",
items: [
message("u1", "user_message"),
message("a1", "assistant_message"),
@@ -28,17 +28,22 @@ 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, boundaryIndex, initialBoundaryItemId, hasItems } = input;
const { current, agentId, boundaryIndex, initialBoundaryItemId, hasItems } = input;
if (current.agentId !== agentId) {
return { agentId, boundaryItemId: initialBoundaryItemId, initialized: hasItems };
}
if (!current.initialized && hasItems) {
return { ...current, boundaryItemId: initialBoundaryItemId, initialized: true };
}
@@ -49,26 +54,28 @@ function reconcileHistoryWindow(input: {
}
export function useStreamHistoryWindow(input: {
agentId: string;
items: StreamItem[];
loadRemoteOlder: () => boolean | Promise<boolean>;
}) {
const { items, loadRemoteOlder } = input;
const { agentId, 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.boundaryItemId;
const boundaryItemId = window.agentId === agentId ? window.boundaryItemId : null;
const boundaryIndex = useMemo(
() => (boundaryItemId === null ? -1 : items.findIndex((item) => item.id === boundaryItemId)),
[boundaryItemId, items],
);
let start = initialStart;
if (window.initialized) {
if (window.agentId === agentId && window.initialized) {
if (window.boundaryItemId === null) {
start = 0;
} else if (boundaryIndex >= 0) {
@@ -80,12 +87,13 @@ export function useStreamHistoryWindow(input: {
setWindow((current) =>
reconcileHistoryWindow({
current,
agentId,
boundaryIndex,
initialBoundaryItemId,
hasItems: items.length > 0,
}),
);
}, [boundaryIndex, initialBoundaryItemId, items.length]);
}, [agentId, boundaryIndex, initialBoundaryItemId, items.length]);
const revealLoadedHistory = useCallback(
(itemId?: string): boolean => {
@@ -98,12 +106,13 @@ export function useStreamHistoryWindow(input: {
return false;
}
setWindow({
agentId,
boundaryItemId: items[nextStart]?.id ?? null,
initialized: true,
});
return true;
},
[items, start],
[agentId, items, start],
);
const loadOlder = useCallback(async (): Promise<boolean> => {
return revealLoadedHistory() || (await loadRemoteOlder());
+6 -1
View File
@@ -430,6 +430,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
loadOlder: historyPagination.onLoadOlder,
}
: agentHistoryPagination;
useEffect(() => {
setExpandedInlineToolCallIds(new Set());
setExpandedTurnHostIds(new Set());
}, [agentId]);
const isActive = useRetainedPanelActive();
const openInlineFile = useStableEvent(
@@ -472,7 +476,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
useEffect(() => {
if (!isActive) inlinePathOpener.cancel();
return () => inlinePathOpener.cancel();
}, [inlinePathOpener, isActive]);
}, [inlinePathOpener, isActive, agentId]);
const handleInlinePathPress = useStableEvent(
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
if (isActive) void inlinePathOpener.open(target, disposition);
@@ -547,6 +551,7 @@ 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 "Previous conversation";
return attachment.chatHistorySource?.title ?? t("message.chatHistory.previousConversation");
}
return t("message.attachments.text");
}
@@ -87,6 +87,13 @@ 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,
@@ -0,0 +1,125 @@
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,
},
});
}
+2 -3
View File
@@ -1,5 +1,6 @@
import type {
AgentAttachment,
ChatHistorySource,
ForgeSearchItem,
UploadedFileAttachment,
} from "@getpaseo/protocol/messages";
@@ -82,9 +83,7 @@ export interface ChatHistoryContextAttachment {
kind: "chat_history";
id: string;
attachment: Extract<AgentAttachment, { type: "text" }>;
source: {
serverId: string;
agentId: string;
source?: ChatHistorySource & {
boundaryMessageId?: string | null;
boundaryCursor?: { epoch: string; seq: number } | null;
itemCount?: number;
@@ -21,6 +21,29 @@ 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,7 +62,12 @@ export function workspaceAttachmentToSubmitAttachment(
};
}
if (attachment.kind === "chat_history") {
return attachment.attachment;
if (!attachment.source) return attachment.attachment;
const { serverId, agentId, title } = attachment.source;
return {
...attachment.attachment,
chatHistorySource: { serverId, agentId, ...(title ? { title } : {}) },
};
}
return attachment.kind === "review" ? attachment.attachment : null;
}
@@ -178,6 +178,19 @@ 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(
+29 -8
View File
@@ -1,3 +1,4 @@
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";
@@ -33,7 +34,10 @@ 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 } from "@getpaseo/protocol/messages";
import type {
AgentAttachment,
ChatHistoryAttachment as ChatHistorySnapshot,
} from "@getpaseo/protocol/messages";
import MaskedView from "@react-native-masked-view/masked-view";
import {
Check,
@@ -419,7 +423,12 @@ export const UserMessage = memo(function UserMessage({
const hasText = message.trim().length > 0;
const hasImages = images.length > 0;
const hasAttachments = attachments.length > 0;
const showTrailingRow = !isPending && hasText && (isCompact || isNative || isHovered);
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 formattedTimestamp = useMemo(
() => formatMessageTimestamp(new Date(timestamp)),
[timestamp],
@@ -428,9 +437,12 @@ export const UserMessage = memo(function UserMessage({
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const getMessageContent = useCallback(() => message, [message]);
const getMessageContent = useCallback(
() => message || chatHistory?.text || "",
[message, chatHistory],
);
const handleRewind = useCallback(
(input: { mode: RewindMode; rewoundText: string }) => {
(input: { mode: RewindMode; rewoundText: string; chatHistory?: ChatHistorySnapshot }) => {
return rewindMutation.rewindAgent(input);
},
[rewindMutation],
@@ -496,11 +508,19 @@ 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={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
>
<AttachmentFrame key={key}>
<AttachmentLabel
icon={content.icon}
title={content.title}
@@ -518,7 +538,7 @@ export const UserMessage = memo(function UserMessage({
) : null}
</MessagePreview>
</View>
{hasText ? (
{hasText || chatHistory ? (
<View
style={trailingRowStyle}
pointerEvents={showTrailingRow ? "auto" : "none"}
@@ -532,6 +552,7 @@ export const UserMessage = memo(function UserMessage({
capabilities={capabilities}
isPending={rewindMutation.isPending}
rewoundText={message}
chatHistory={chatHistory}
onRewind={handleRewind}
/>
) : null}
@@ -1,27 +1,45 @@
import { describe, expect, test } from "vitest";
import { restoreComposerTextIfEmpty } from "./composer-restore";
import { restoreComposerPromptIfEmpty } from "./composer-restore";
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
describe("restoreComposerTextIfEmpty", () => {
describe("restoreComposerPromptIfEmpty", () => {
test("restores the rewound message when the composer is empty", () => {
expect(
restoreComposerTextIfEmpty({
restoreComposerPromptIfEmpty({
currentText: "",
rewoundText: "message before rewind",
rewoundPrompt: { text: "message before rewind" },
}),
).toBe("message before rewind");
).toEqual({ text: "message before rewind" });
});
test("preserves an existing composer draft", () => {
expect(
restoreComposerTextIfEmpty({
restoreComposerPromptIfEmpty({
currentText: "keep this draft",
rewoundText: "message before rewind",
rewoundPrompt: { text: "message before rewind" },
}),
).toBe("keep this draft");
).toEqual({ text: "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,11 +1,14 @@
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: (text: string) => void;
completeRewind: (prompt: { text: string; chatHistory?: ChatHistoryAttachment }) => void;
}
interface RewindComposerRestoreProviderProps {
attachmentScopeKey: string;
textSource: ComposerTextSource;
setText: (text: string) => void;
onRewindComplete: () => void;
@@ -14,35 +17,34 @@ interface RewindComposerRestoreProviderProps {
const RewindComposerRestoreContext = createContext<RewindComposerRestoreContextValue | null>(null);
export function restoreComposerTextIfEmpty(input: {
export function restoreComposerPromptIfEmpty(input: {
currentText: string;
rewoundText: string;
}): string {
if (input.currentText.length > 0) {
return input.currentText;
}
return input.rewoundText;
rewoundPrompt: { text: string; chatHistory?: ChatHistoryAttachment };
}): { text: string; chatHistory?: ChatHistoryAttachment } {
if (input.currentText.length > 0) return { text: input.currentText };
return input.rewoundPrompt;
}
export function RewindComposerRestoreProvider({
attachmentScopeKey,
textSource,
setText,
onRewindComplete,
children,
}: RewindComposerRestoreProviderProps) {
const completeRewind = useCallback(
(rewoundText: string) => {
(prompt: { text: string; chatHistory?: ChatHistoryAttachment }) => {
const currentText = textSource.getSnapshot();
const nextText = restoreComposerTextIfEmpty({
currentText: currentText,
rewoundText,
});
if (nextText !== currentText) {
setText(nextText);
const restored = restoreComposerPromptIfEmpty({ currentText, rewoundPrompt: prompt });
if (restored.chatHistory) {
restoreChatHistoryAttachment(attachmentScopeKey, restored.chatHistory);
}
if (restored.text !== currentText) {
setText(restored.text);
}
onRewindComplete();
},
[onRewindComplete, setText, textSource],
[attachmentScopeKey, onRewindComplete, setText, textSource],
);
const value = useMemo(() => ({ completeRewind }), [completeRewind]);
@@ -1,3 +1,4 @@
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";
@@ -19,7 +20,12 @@ export type { RewindMode };
interface RewindMenuProps {
capabilities: AgentCapabilityFlags;
rewoundText: string;
onRewind: (input: { mode: RewindMode; rewoundText: string }) => Promise<void> | void;
chatHistory?: ChatHistoryAttachment;
onRewind: (input: {
mode: RewindMode;
rewoundText: string;
chatHistory?: ChatHistoryAttachment;
}) => Promise<void> | void;
isPending?: boolean;
testID?: string;
}
@@ -38,6 +44,7 @@ function getIcon(mode: RewindMode, color: string): ReactElement {
export const RewindMenu = memo(function RewindMenu({
capabilities,
rewoundText,
chatHistory,
onRewind,
isPending: isPendingProp = false,
testID = "rewind-menu",
@@ -70,7 +77,7 @@ export const RewindMenu = memo(function RewindMenu({
if (isLocked) return;
setPendingMode(mode);
try {
await onRewind({ mode, rewoundText });
await onRewind({ mode, rewoundText, chatHistory });
} catch {
// useRewindAgentMutation owns the toast; the menu only owns flow state.
} finally {
@@ -78,7 +85,7 @@ export const RewindMenu = memo(function RewindMenu({
setIsOpen(false);
}
},
[isLocked, onRewind, rewoundText],
[isLocked, onRewind, rewoundText, chatHistory],
);
const triggerStyle = useCallback(
@@ -1,3 +1,4 @@
import type { ChatHistoryAttachment } from "@getpaseo/protocol/messages";
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
@@ -20,6 +21,7 @@ interface UseRewindAgentMutationInput {
interface RewindAgentInput {
mode: RewindMode;
rewoundText: string;
chatHistory?: ChatHistoryAttachment;
}
export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
@@ -52,7 +54,10 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
if (!shouldRestoreComposerForRewindMode(variables.mode)) {
return;
}
composerRestore?.completeRewind(variables.rewoundText);
composerRestore?.completeRewind({
text: variables.rewoundText,
chatHistory: variables.chatHistory,
});
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t("rewind.errors.failed"));
@@ -1,3 +1,4 @@
import { ChatHistoryAttachment } from "@/attachments/chat-history";
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import type {
@@ -255,12 +256,28 @@ 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,6 +60,17 @@ 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 () => {
@@ -231,10 +242,10 @@ describe("useDraftAgentCreateFlow", () => {
expect(createRequest).toHaveBeenCalledWith({
attempt: expect.objectContaining({
text: "",
attachments: [attachment.attachment],
attachments: [{ ...attachment.attachment, chatHistorySource: attachment.source }],
}),
text: "",
attachments: [attachment.attachment],
attachments: [{ ...attachment.attachment, chatHistorySource: attachment.source }],
cwd: "/repo",
});
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
+42 -1
View File
@@ -8,8 +8,15 @@ import {
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer";
import { useSessionStore } from "@/stores/session-store";
import { createUserMessage, type UserMessageImageAttachment } from "@/types/stream";
import {
createUserMessage,
type StreamItem,
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;
@@ -146,6 +153,38 @@ 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) => {
@@ -326,6 +365,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
machine,
formErrorMessage,
isSubmitting,
submittedStreamItems,
pendingMessageSubmissions,
draftAgent,
handleCreateFromInput,
continueCreateFromAttempt,
@@ -1,103 +0,0 @@
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
@@ -1,102 +0,0 @@
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,5 +1,4 @@
import { resolveSubmissionReadiness } from "@/provider-selection/provider-selection";
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
export interface WorkspaceDraftAutoSubmitConfig {
provider: string;
@@ -51,13 +50,3 @@ 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;
}
+197 -123
View File
@@ -1,17 +1,21 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Keyboard, StyleSheet as RNStyleSheet, Text, View } from "react-native";
import { Keyboard, ScrollView, 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 { type ComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session";
import { useComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
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";
@@ -21,13 +25,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 { DRAFT_CAPABILITIES } from "@/composer/draft/timeline";
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
@@ -46,6 +50,16 @@ 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;
@@ -246,15 +260,25 @@ function buildDraftInitialValues(input: {
};
}
interface WorkspaceDraftComposerProps {
function resolveDraftWorkingDirectory(input: {
workspaceDirectory: string | null;
initialSetup: WorkspaceDraftTabSetup | null;
}): string | null {
if (input.initialSetup) {
return input.initialSetup.cwd;
}
return input.workspaceDirectory;
}
interface WorkspaceDraftAgentTabProps {
serverId: string;
workspaceId: string;
tabId: string;
draftId: string;
initialSetup?: WorkspaceDraftTabSetup;
isPaneFocused: boolean;
editingSession: ComposerEditingSession;
onCreated: (snapshot: AgentSnapshotPayload) => void;
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => void;
onOpenImportSheet?: () => void;
}
@@ -268,21 +292,17 @@ function resolveImportPillPress(
return onOpenImportSheet ?? null;
}
/**
* 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({
export function WorkspaceDraftAgentTab({
serverId,
workspaceId,
tabId,
draftId,
initialSetup = undefined,
isPaneFocused,
editingSession,
onCreated,
onOpenWorkspaceFile,
onOpenImportSheet,
}: WorkspaceDraftComposerProps) {
}: WorkspaceDraftAgentTabProps) {
const { t } = useTranslation();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -367,6 +387,7 @@ export function WorkspaceDraftComposer({
}, [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,
@@ -400,71 +421,82 @@ export function WorkspaceDraftComposer({
[isCompactFormFactor, openInSidePane, serverId, workspaceId],
);
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);
},
});
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],
);
useAgentControlCommandCenterActions({
sourceId: `draft:${serverId}:${tabId}`,
enabled: isPaneFocused && !isSubmitting,
@@ -568,49 +600,76 @@ export function WorkspaceDraftComposer({
}),
[composerState.agentControls, handleDropdownCloseFocus, isSubmitting],
);
return (
<View style={animatedStaticStyles.inputAreaWrapper} onLayout={onInputAreaLayout}>
{formErrorMessage ? (
<View style={styles.formErrorRow}>
<View style={styles.formError}>
<Text style={styles.errorText}>{formErrorMessage}</Text>
</View>
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>
) : null}
{importPillPress && !isComposerFullscreen ? (
<View style={styles.importPillRow}>
<View style={styles.importPillContent}>
<ComposerImportPill onPress={importPillPress} />
) : (
<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>
</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}
/>
</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>
</ComposerDock>
</FileDropZone>
);
}
const animatedStaticStyles = RNStyleSheet.create({
@@ -623,10 +682,27 @@ const animatedStaticStyles = RNStyleSheet.create({
});
const styles = StyleSheet.create((theme) => ({
formErrorRow: {
container: {
flex: 1,
width: "100%",
backgroundColor: theme.colors.surface0,
},
contentContainer: {
flex: 1,
},
streamContainer: {
flex: 1,
},
scrollView: {
flex: 1,
},
configScrollContent: {
paddingHorizontal: theme.spacing[4],
alignItems: "center",
paddingTop: theme.spacing[4],
paddingBottom: theme.spacing[6],
},
configSection: {
gap: theme.spacing[3],
},
importPillRow: {
width: "100%",
@@ -646,10 +722,8 @@ const styles = StyleSheet.create((theme) => ({
maxWidth: theme.contentMaxWidth,
flexDirection: "row",
},
formError: {
width: "100%",
maxWidth: theme.contentMaxWidth,
marginBottom: theme.spacing[2],
errorContainer: {
marginTop: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md,
+3
View File
@@ -68,6 +68,7 @@ export interface UseForkAgentInput {
function buildChatHistoryAttachment(input: {
draftId: string;
title: string | null;
serverId: string;
agentId: string;
payload: Awaited<ReturnType<DaemonClient["buildAgentForkContext"]>>;
@@ -83,6 +84,7 @@ 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,
@@ -147,6 +149,7 @@ 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,6 +342,14 @@ export const ar: TranslationResources = {
openFile: "افتح الملف",
copied: "منقول",
},
chatHistory: {
title: "سجل المحادثة",
previousConversation: "المحادثة السابقة",
open: "فتح مرفق سجل المحادثة",
remove: "إزالة مرفق سجل المحادثة",
openSource: "فتح المحادثة الأصلية",
description: "السياق المنسوخ عند تفريع هذه المحادثة.",
},
attachments: {
dismissImage: "تجاهل الصورة",
closeImage: "إغلاق الصورة",
+8
View File
@@ -339,6 +339,14 @@ 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,6 +347,14 @@ 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,6 +347,14 @@ 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,6 +347,14 @@ export const ja: TranslationResources = {
openFile: "ファイルを開く",
copied: "コピーしました",
},
chatHistory: {
title: "チャット履歴",
previousConversation: "以前の会話",
open: "チャット履歴の添付を開く",
remove: "チャット履歴の添付を削除",
openSource: "元のチャットを開く",
description: "このチャットを分岐した時点でコピーされたコンテキストです。",
},
attachments: {
dismissImage: "画像を閉じる",
closeImage: "画像を閉じる",
+8
View File
@@ -343,6 +343,14 @@ export const ko: TranslationResources = {
openFile: "파일 열기",
copied: "복사됨",
},
chatHistory: {
title: "채팅 기록",
previousConversation: "이전 대화",
open: "채팅 기록 첨부 열기",
remove: "채팅 기록 첨부 제거",
openSource: "원본 채팅 열기",
description: "이 채팅을 분기할 때 복사된 컨텍스트입니다.",
},
attachments: {
dismissImage: "이미지 닫기",
closeImage: "이미지 닫기",
+8
View File
@@ -346,6 +346,14 @@ 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,6 +346,14 @@ export const ru: TranslationResources = {
openFile: "Открыть файл",
copied: "Скопировано",
},
chatHistory: {
title: "История чата",
previousConversation: "Предыдущий разговор",
open: "Открыть вложенную историю чата",
remove: "Удалить вложенную историю чата",
openSource: "Открыть исходный чат",
description: "Контекст, скопированный при создании этой ветки чата.",
},
attachments: {
dismissImage: "Закрыть изображение",
closeImage: "Закрыть изображение",
+8
View File
@@ -342,6 +342,14 @@ export const zhCN: TranslationResources = {
openFile: "打开文件",
copied: "已复制",
},
chatHistory: {
title: "聊天历史",
previousConversation: "之前的对话",
open: "打开聊天历史附件",
remove: "移除聊天历史附件",
openSource: "打开来源对话",
description: "创建此分叉时复制的上下文。",
},
attachments: {
dismissImage: "关闭图片",
closeImage: "关闭图片",
+103 -161
View File
@@ -43,7 +43,10 @@ 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 } from "@/attachments/workspace-attachments-store";
import {
useWorkspaceAttachmentScopeKey,
useAgentAttachmentScopeKey,
} 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";
@@ -78,8 +81,7 @@ import {
deriveRouteBottomAnchorIntent,
deriveRouteBottomAnchorRequest,
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
import { WorkspaceDraftComposer } from "@/composer/draft/workspace-tab";
import { type DraftConversation, useDraftTimeline } from "@/composer/draft/timeline";
import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab";
import { AgentTracks } from "@/panels/agent-tracks";
import { ParentAgentCallout, useParentAgentId } from "@/subagents/parent";
import { useCreateFlowStore } from "@/stores/create-flow-store";
@@ -91,13 +93,14 @@ import {
useSessionStore,
} from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey, type WorkspaceTabTarget } from "@/workspace-tabs/model";
import { buildWorkspaceTabPersistenceKey } 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 } from "@/types/stream";
import type { StreamItem, TodoEntry } from "@/types/stream";
import type { ViewedTimelineStatus, ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync";
import { useArchiveFinishedSubagents, useSubagentsForParent } from "@/subagents";
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
@@ -225,29 +228,6 @@ 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;
@@ -326,31 +306,17 @@ function storeFetchedAgentDetail(input: {
return hydrated;
}
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() {
function AgentPanel() {
const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext();
const { isInteractive } = usePaneFocus();
invariant(
target.kind === "agent" || target.kind === "draft",
"AgentConversationPanel requires an agent or draft target",
);
invariant(target.kind === "agent", "AgentPanel requires agent target");
return (
<RetainedChatContent>
<AgentPanelContent
serverId={serverId}
workspaceId={workspaceId}
target={target}
agentId={target.agentId}
isPaneFocused={isInteractive}
onOpenWorkspaceFile={openFileInWorkspace}
/>
@@ -358,6 +324,55 @@ export function AgentConversationPanel() {
);
}
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,
@@ -432,18 +447,18 @@ type AgentLookupState =
function AgentPanelContent({
serverId,
workspaceId,
target,
agentId,
isPaneFocused,
onOpenWorkspaceFile,
}: {
serverId: string;
workspaceId: string;
target: ConversationTarget;
agentId: string;
isPaneFocused: boolean;
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) {
const { t } = useTranslation();
const resolvedAgentId = resolveConversationAgentId(target);
const resolvedAgentId = agentId.trim() || undefined;
const resolvedServerId = serverId.trim() || undefined;
const daemons = useHosts();
const runtimeServerId = resolvedServerId ?? "";
@@ -472,7 +487,7 @@ function AgentPanelContent({
: runtimeConnectionStatus;
const lastConnectionError = runtimeLastError;
if (target.kind === "agent" && (!resolvedServerId || (!runtimeClient && !hasCachedAgent))) {
if (!resolvedServerId || (!runtimeClient && !hasCachedAgent)) {
return (
<AgentSessionUnavailableState
serverLabel={serverLabel}
@@ -486,9 +501,9 @@ function AgentPanelContent({
return (
<AgentPanelBody
serverId={resolvedServerId ?? serverId}
serverId={resolvedServerId}
workspaceId={workspaceId}
target={target}
agentId={resolvedAgentId}
isPaneFocused={isPaneFocused}
client={runtimeClient}
isConnected={runtimeIsConnected}
@@ -500,7 +515,7 @@ function AgentPanelContent({
function AgentPanelBody({
serverId,
workspaceId,
target,
agentId,
isPaneFocused,
client,
isConnected,
@@ -508,14 +523,13 @@ function AgentPanelBody({
}: {
serverId: string;
workspaceId: string;
target: ConversationTarget;
agentId?: string;
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(
@@ -630,7 +644,7 @@ function AgentPanelBody({
}
: null;
if (target.kind === "agent" && !agent) {
if (!agent) {
return (
<View style={styles.container} testID="agent-loading">
<View style={styles.errorContainer}>
@@ -644,7 +658,7 @@ function AgentPanelBody({
<ChatAgentContent
serverId={serverId}
workspaceId={workspaceId}
target={target}
agentId={agentId}
isPaneFocused={isPaneFocused}
client={client}
isConnected={isConnected}
@@ -656,7 +670,7 @@ function AgentPanelBody({
function ChatAgentContent({
serverId,
workspaceId,
target,
agentId,
isPaneFocused,
client,
isConnected,
@@ -664,15 +678,13 @@ function ChatAgentContent({
}: {
serverId: string;
workspaceId: string;
target: ConversationTarget;
agentId?: string;
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();
@@ -979,23 +991,17 @@ function ChatAgentContent({
visibilityCatchUpStatus,
]);
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,
const nonReadyView = renderChatAgentNonReadyView({
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" &&
@@ -1010,8 +1016,7 @@ function ChatAgentContent({
<ChatAgentReadyContent
serverId={serverId}
workspaceId={workspaceId}
agentId={agentId ?? tabId}
draft={draft}
agentId={agentId}
isPaneFocused={isPaneFocused}
isArchivingCurrentAgent={isArchivingCurrentAgent}
agentState={agentState}
@@ -1029,7 +1034,7 @@ function ChatAgentContent({
showHistorySyncOverlay={showHistorySyncOverlay}
showHistorySyncError={showHistorySyncError}
isRetryingHistorySync={isRetryingHistorySync}
cwd={agentState.cwd ?? ""}
cwd={agentCwd}
retryTimelineSync={retryTimelineSync}
onAttentionInputFocus={attentionController.clearOnInputFocus}
onAttentionPromptSend={attentionController.clearOnPromptSend}
@@ -1042,7 +1047,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
serverId,
workspaceId,
agentId,
draft,
isPaneFocused,
isArchivingCurrentAgent,
agentState,
@@ -1068,13 +1072,11 @@ 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 | null;
effectiveAgent: AgentScreenAgent;
routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean;
toastApi: ToastApi;
@@ -1096,15 +1098,24 @@ 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 paneHost = usePaneContext().host;
const showsComposer = draft !== null || paneHost !== "sheet";
const showsComposer = usePaneContext().host !== "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,
@@ -1154,7 +1165,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
const parentCallout = parentAgentId ? (
<ParentAgentCallout serverId={serverId} parentAgentId={parentAgentId} />
) : null;
const agentComposerSection = showsComposer ? (
const composerSection = showsComposer ? (
<RenderProfile id={`AgentComposerSection:${agentId}`}>
<AgentComposerSection
agentId={agentId}
@@ -1175,16 +1186,6 @@ 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} />}
@@ -1194,7 +1195,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
serverId={serverId}
agentId={agentId}
agent={effectiveAgent}
draft={draft}
routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
hasActiveComposer={hasActiveComposer}
@@ -1202,12 +1202,16 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</RenderProfile>
{hasActiveComposer && !draft ? (
{hasActiveComposer ? (
<AgentTracks
serverId={serverId}
workspaceId={workspaceId}
agentId={agentId}
parentAgentId={parentPillAgentId}
subagentRows={subagentRows}
tasks={tasks}
archiveFinishedStatus={archiveFinishedSubagents.status}
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
/>
) : null}
</View>
@@ -1245,6 +1249,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
const content = (
<RewindComposerRestoreProvider
attachmentScopeKey={rewindAttachmentScopeKey}
textSource={agentInputDraft.textSource}
setText={agentInputDraft.replaceText}
onRewindComplete={handleRewindComplete}
@@ -1263,7 +1268,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
</RewindComposerRestoreProvider>
);
return wrapAgentCommandProvider({
enabled: showsComposer && hasActiveComposer && !draft,
enabled: showsComposer && hasActiveComposer,
serverId,
agentId,
editingSession: composerEditingSession,
@@ -1355,7 +1360,6 @@ const AgentStreamSection = memo(function AgentStreamSection({
serverId,
agentId,
agent,
draft,
routeBottomAnchorRequest,
hasAppliedAuthoritativeHistory,
hasActiveComposer,
@@ -1365,8 +1369,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
serverId: string;
agentId?: string;
agent: AgentScreenAgent | null;
draft: DraftConversation | null;
agent: AgentScreenAgent;
routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean;
hasActiveComposer: boolean;
@@ -1424,31 +1427,7 @@ 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}
@@ -1469,44 +1448,6 @@ 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,
@@ -1605,9 +1546,10 @@ function ActiveAgentComposer({
cwd,
workspaceId,
});
const agentAttachmentScopeKey = useAgentAttachmentScopeKey({ serverId, agentId });
const attachmentScopeKeys = useMemo(
() => [workspaceAttachmentScopeKey],
[workspaceAttachmentScopeKey],
() => [workspaceAttachmentScopeKey, agentAttachmentScopeKey],
[workspaceAttachmentScopeKey, agentAttachmentScopeKey],
);
const handleOpenWorkspaceAttachment = useCallback(
(attachment: WorkspaceComposerAttachment) => {
+12 -13
View File
@@ -9,11 +9,11 @@ import { useSettings } from "@/hooks/use-settings";
import { PluginComposerPills } from "@/plugins";
import { useSessionStore } from "@/stores/session-store";
import {
useArchiveFinishedSubagents,
type ArchiveFinishedStatus,
useArchiveSubagent,
useDetachSubagent,
useSubagentSheetTab,
useSubagentsForParent,
type SubagentRow,
} from "@/subagents";
import { ParentAgentPill } from "@/subagents/parent";
import { SubagentsTrack } from "@/subagents/track";
@@ -35,21 +35,20 @@ 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);
@@ -120,8 +119,8 @@ export const AgentTracks = memo(function AgentTracks({
onOpenProviderSubagent={handleOpenProviderSubagent}
sheetTabFor={sheetTabFor}
onArchiveSubagent={archiveSubagent}
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
archiveFinishedStatus={archiveFinishedSubagents.status}
onArchiveFinished={onArchiveFinished}
archiveFinishedStatus={archiveFinishedStatus}
onDetachSubagent={canDetachSubagents ? detachSubagent : undefined}
/>
<PluginComposerPills
@@ -390,39 +390,4 @@ 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,26 +21,6 @@ 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 =
@@ -79,8 +59,7 @@ export class AgentStoreProjection {
let accepted = agent;
useSessionStore.getState().setAgents(this.serverId, (current) => {
const previous = current.get(agent.id);
const winner = acceptAgentDirectoryUpdate(previous, mergeSnapshotTurn(previous, agent));
accepted = previous ? keepUnchangedFields(previous, winner) : winner;
accepted = acceptAgentDirectoryUpdate(previous, mergeSnapshotTurn(previous, agent));
if (accepted === previous) return current;
const next = new Map(current);
next.set(agent.id, accepted);
@@ -138,7 +117,7 @@ export class AgentStoreProjection {
for (const [agentId, fetchedAgent] of fetched) {
const existing = current.get(agentId);
const merged = mergeSnapshotTurn(existing, fetchedAgent);
agents.set(agentId, existing ? keepUnchangedFields(existing, merged) : merged);
agents.set(agentId, existing && equal(existing, merged) ? existing : merged);
if (fetchedAgent.archivedAt) {
clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId });
}
@@ -870,12 +870,8 @@ 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" || activeTab?.kind === "draft");
return supportsStreamTopOverlay && activeTab?.kind === "agent";
}
/** A fullscreen composer covers the compact header; the column lays out without it. */
+99
View File
@@ -9,6 +9,7 @@ import {
hydrateStreamState,
mergeToolCallDetail,
reduceStreamUpdate,
replaceWithCanonicalStream,
streamTimelineItemIdentity,
type AgentToolCallItem,
type StreamItem,
@@ -158,6 +159,104 @@ 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",
+19 -2
View File
@@ -263,6 +263,7 @@ 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 &&
@@ -891,6 +892,18 @@ 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,
@@ -900,8 +913,9 @@ function appendUserMessage(
clientMessageId?: string,
timelineCursor?: TimelinePosition,
turnId?: string,
chatHistory?: Extract<AgentTimelineItem, { type: "user_message" }>["chatHistory"],
): StreamItem[] {
const { chunk, hasContent } = normalizeChunk(text);
const { chunk, hasContent, attachments } = normalizeUserMessageContent(text, chatHistory);
if (!hasContent) {
return state;
}
@@ -914,6 +928,7 @@ function appendUserMessage(
timelineCursor,
turnId,
text: chunk,
attachments,
timestamp,
});
return upsertUserMessage(state, nextItem);
@@ -1523,6 +1538,7 @@ function reduceTimelineEvent(
item.clientMessageId,
timelineCursor,
event.turnId,
item.chatHistory,
);
case "assistant_message":
return appendAssistantMessage(
@@ -1874,7 +1890,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 = normalizeChunk(event.item.text);
const normalized = normalizeUserMessageContent(event.item.text, event.item.chatHistory);
const flushedTail = head.length > 0 ? flushHeadToTail(tail, head) : tail;
const flushedHead = head.length > 0 ? [] : head;
@@ -1887,6 +1903,7 @@ function applyCanonicalUserMessageEvent(params: {
turnId: event.turnId,
timelineCursor,
text: normalized.chunk,
attachments: normalized.attachments,
timestamp,
});
if (unmatchedInsert === "head") {
@@ -126,23 +126,6 @@ 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")],
+8 -2
View File
@@ -1,5 +1,5 @@
import type { AgentMessage } from "./agent-message.js";
import type { AgentAttachment } from "./messages.js";
import type { AgentAttachment, ChatHistoryAttachment } from "./messages.js";
export type AgentProvider = string;
@@ -373,7 +373,13 @@ export interface AgentTaskItem {
}
export type AgentTimelineItem =
| { type: "user_message"; text: string; messageId?: string; clientMessageId?: string }
| {
type: "user_message";
text: string;
messageId?: string;
clientMessageId?: string;
chatHistory?: ChatHistoryAttachment;
}
| { type: "assistant_message"; text: string; messageId?: string }
| { type: "reasoning"; text: string }
| ToolCallTimelineItem
+9
View File
@@ -0,0 +1,9 @@
/** 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,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
AgentTimelineItemPayloadSchema,
AgentForkContextRequestMessageSchema,
AgentForkContextResponseMessageSchema,
CreateAgentRequestMessageSchema,
@@ -9,6 +10,24 @@ 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({
@@ -269,6 +288,29 @@ 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,6 +712,25 @@ 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([
@@ -720,6 +739,7 @@ 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"),
@@ -1180,6 +1200,7 @@ export const TextAttachmentSchema = z
title: z.string().nullable().optional(),
text: z.string(),
externalResource: ExternalResourceAttachmentMetadataSchema.optional(),
chatHistorySource: ChatHistorySourceSchema.optional(),
})
.transform(({ contextKind, ...attachment }) => ({
...attachment,
@@ -572,3 +572,57 @@ 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,3 +1,4 @@
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";
@@ -21,6 +22,7 @@ const FORK_CONTEXT_KINDS: readonly AgentTimelineItem["type"][] = [
];
interface ActivityCuratorOptions {
includeChatHistory?: boolean;
maxItems?: number;
labelAssistantMessages?: boolean;
includeKinds?: readonly AgentTimelineItem["type"][];
@@ -37,6 +39,17 @@ 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" }>,
@@ -158,7 +171,7 @@ function curateProjectedActivityEntries(
switch (item.type) {
case "user_message":
entries.push(activityEntry(`[User] ${item.text.trim()}`));
appendUserEntry(entries, item, options);
break;
case "assistant_message":
case "reasoning":
@@ -361,6 +374,7 @@ export function buildAgentForkContextAttachment(input: {
});
const entries = curateProjectedActivityEntries(selected.items, {
maxItems: 0,
includeChatHistory: true,
labelAssistantMessages: true,
includeKinds: FORK_CONTEXT_KINDS,
includeExternalToolInput: false,
@@ -729,6 +729,159 @@ 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,6 +441,7 @@ interface HandleStreamEventOptions {
}
interface ManagedAgentBase {
forkContext?: StoredAgentRecord["forkContext"];
id: string;
provider: AgentProvider;
cwd: string;
@@ -2701,11 +2702,15 @@ export class AgentManager {
if (event.type === "timeline") {
this.touchUpdatedAt(agent);
const row = this.recordTimeline(agent.id, event.item);
this.dispatchStream(agent.id, event, {
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
});
this.dispatchStream(
agent.id,
{ ...event, item: row.item },
{
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
},
);
return;
}
this.dispatchStream(agent.id, event, { timestamp: new Date().toISOString() });
@@ -3919,6 +3924,8 @@ 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[] = [];
@@ -4491,11 +4498,15 @@ export class AgentManager {
event.timestamp ? { timestamp: event.timestamp } : undefined,
);
if (broadcastTimeline) {
this.dispatchStream(agent.id, event, {
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
});
this.dispatchStream(
agent.id,
{ ...event, item: row.item },
{
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
},
);
}
}
this.touchUpdatedAt(agent);
@@ -4560,13 +4571,17 @@ export class AgentManager {
event.timestamp ? { timestamp: event.timestamp } : undefined,
);
if (deferredBroadcast) {
timelineEvents.push({ event, row });
timelineEvents.push({ event: { ...event, item: row.item }, row });
} else if (broadcast) {
this.dispatchStream(agent.id, event, {
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
});
this.dispatchStream(
agent.id,
{ ...event, item: row.item },
{
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
},
);
}
}
agent.historyPrimed = true;
@@ -5101,7 +5116,7 @@ export class AgentManager {
const row = this.recordTimeline(agentId, item, { ...options, turnId });
const event: AgentStreamEvent = {
type: "timeline",
item,
item: row.item,
provider,
...(turnId !== undefined ? { turnId } : {}),
};
@@ -5133,9 +5148,25 @@ 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: submittedPromptText(prompt),
text,
clientMessageId,
...(options?.messageId ? { messageId: options.messageId } : {}),
});
@@ -5183,6 +5214,10 @@ 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,
@@ -5247,6 +5282,10 @@ 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,
@@ -5256,6 +5295,29 @@ 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,6 +92,7 @@ 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 } from "@getpaseo/protocol/messages";
import type { AgentAttachment, ChatHistoryAttachment } from "@getpaseo/protocol/messages";
import type { PaseoToolCatalog } from "./tools/types.js";
export type {
@@ -398,7 +398,13 @@ export interface PluginTimelineItem {
}
export type AgentTimelineItem =
| { type: "user_message"; text: string; messageId?: string; clientMessageId?: string }
| {
type: "user_message";
text: string;
messageId?: string;
clientMessageId?: string;
chatHistory?: ChatHistoryAttachment;
}
| { 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 } from "../messages.js";
import { AgentFeatureSchema, AgentStatusSchema, ChatHistoryAttachmentSchema } 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,6 +53,15 @@ 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,16 +270,6 @@ 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,18 +867,7 @@ it.each([false, true])(
writeFileSync(exitRequestPath, "");
await waitForCondition(() => first.getTerminal(ending.id) === undefined, 10000);
expect(existsSync(exitObservedPath)).toBe(true);
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);
expect(isProcessAlive(endingPid)).toBe(false);
} finally {
if (recordFd !== null) closeSync(recordFd);
}
+20 -47
View File
@@ -25,9 +25,6 @@
# 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.
@@ -48,7 +45,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..5fe940d 100644
index eeb3fb0..b1689ad 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
@@ -208,19 +205,7 @@ index eeb3fb0..5fe940d 100644
}
// src/core/updateItemSizes.ts
@@ -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
@@ -5414,7 +5458,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
state.fixedItemSizeKeys.add(itemKey);
}
const prevSizeKnown = sizesKnown.get(itemKey);
@@ -229,7 +214,7 @@ index eeb3fb0..5fe940d 100644
return 0;
}
const size = Platform.OS === "web" ? Math.round(rawSize) : roundSize(rawSize);
@@ -6283,6 +6332,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
@@ -6283,6 +6327,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
// src/components/Containers.native.tsx
var useCommittedMinSize = IsNewArchitecture ? () => useArr$(["totalSize"])[0] : () => void 0;
@@ -240,7 +225,7 @@ index eeb3fb0..5fe940d 100644
var ContainersLayer = typedMemo(function ContainersLayer2({
children,
freshDataTransitionEpoch,
@@ -6290,7 +6343,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
@@ -6290,7 +6338,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
}) {
const ctx = useStateContext();
const columnWrapperStyle = ctx.columnWrapperStyle;
@@ -249,7 +234,7 @@ index eeb3fb0..5fe940d 100644
const minSize = useCommittedMinSize();
const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]);
const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch);
@@ -6513,6 +6566,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
@@ -6513,6 +6561,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
null
);
});
@@ -264,7 +249,7 @@ index eeb3fb0..5fe940d 100644
var ListComponent = typedMemo(function ListComponent2({
canRender,
freshDataTransitionEpoch,
@@ -6651,7 +6712,7 @@ var ListComponent = typedMemo(function ListComponent2({
@@ -6651,7 +6707,7 @@ var ListComponent = typedMemo(function ListComponent2({
style: autoOtherAxisStyle ? [autoOtherAxisStyle, style] : style
},
/* @__PURE__ */ React2__namespace.createElement(ScrollAdjust, null),
@@ -273,7 +258,7 @@ index eeb3fb0..5fe940d 100644
ListEmptyComponent && getComponent(ListEmptyComponent),
shouldRenderAlignItemsAtEndSpacer && /* @__PURE__ */ React2__namespace.createElement(AlignItemsAtEndSpacer, { horizontal }),
canRender && !ListEmptyComponent && /* @__PURE__ */ React2__namespace.createElement(
@@ -6665,7 +6726,7 @@ var ListComponent = typedMemo(function ListComponent2({
@@ -6665,7 +6721,7 @@ var ListComponent = typedMemo(function ListComponent2({
stickyHeaderConfig
}
),
@@ -282,7 +267,7 @@ index eeb3fb0..5fe940d 100644
IS_DEV && ENABLE_DEVMODE
);
});
@@ -7886,7 +7947,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
@@ -7886,7 +7942,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
const state = ctx.state;
if (stylePaddingTop !== void 0) {
const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0;
@@ -294,7 +279,7 @@ index eeb3fb0..5fe940d 100644
let prevTotalSize = peek$(ctx, "totalSize") || 0;
set$(ctx, "totalSize", prevTotalSize + prevStylePaddingTop);
state.timeoutSetPaddingTop = setTimeout(() => {
@@ -7989,6 +8053,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
@@ -7989,6 +8048,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
estimatedListSize,
extraData,
getFixedItemSize,
@@ -302,7 +287,7 @@ index eeb3fb0..5fe940d 100644
getItemType,
horizontal,
rtl,
@@ -8282,6 +8347,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
@@ -8282,6 +8342,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
dataVersion,
drawDistance,
estimatedItemSize,
@@ -311,7 +296,7 @@ index eeb3fb0..5fe940d 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..7f1b9dd 100644
index fe5f415..e61945e 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
@@ -471,19 +456,7 @@ index fe5f415..7f1b9dd 100644
}
// src/core/updateItemSizes.ts
@@ -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
@@ -5393,7 +5437,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
state.fixedItemSizeKeys.add(itemKey);
}
const prevSizeKnown = sizesKnown.get(itemKey);
@@ -492,7 +465,7 @@ index fe5f415..7f1b9dd 100644
return 0;
}
const size = Platform.OS === "web" ? Math.round(rawSize) : roundSize(rawSize);
@@ -6262,6 +6311,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
@@ -6262,6 +6306,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
// src/components/Containers.native.tsx
var useCommittedMinSize = IsNewArchitecture ? () => useArr$(["totalSize"])[0] : () => void 0;
@@ -503,7 +476,7 @@ index fe5f415..7f1b9dd 100644
var ContainersLayer = typedMemo(function ContainersLayer2({
children,
freshDataTransitionEpoch,
@@ -6269,7 +6322,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
@@ -6269,7 +6317,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
}) {
const ctx = useStateContext();
const columnWrapperStyle = ctx.columnWrapperStyle;
@@ -512,7 +485,7 @@ index fe5f415..7f1b9dd 100644
const minSize = useCommittedMinSize();
const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]);
const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch);
@@ -6492,6 +6545,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
@@ -6492,6 +6540,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
null
);
});
@@ -527,7 +500,7 @@ index fe5f415..7f1b9dd 100644
var ListComponent = typedMemo(function ListComponent2({
canRender,
freshDataTransitionEpoch,
@@ -6630,7 +6691,7 @@ var ListComponent = typedMemo(function ListComponent2({
@@ -6630,7 +6686,7 @@ var ListComponent = typedMemo(function ListComponent2({
style: autoOtherAxisStyle ? [autoOtherAxisStyle, style] : style
},
/* @__PURE__ */ React2.createElement(ScrollAdjust, null),
@@ -536,7 +509,7 @@ index fe5f415..7f1b9dd 100644
ListEmptyComponent && getComponent(ListEmptyComponent),
shouldRenderAlignItemsAtEndSpacer && /* @__PURE__ */ React2.createElement(AlignItemsAtEndSpacer, { horizontal }),
canRender && !ListEmptyComponent && /* @__PURE__ */ React2.createElement(
@@ -6644,7 +6705,7 @@ var ListComponent = typedMemo(function ListComponent2({
@@ -6644,7 +6700,7 @@ var ListComponent = typedMemo(function ListComponent2({
stickyHeaderConfig
}
),
@@ -545,7 +518,7 @@ index fe5f415..7f1b9dd 100644
IS_DEV && ENABLE_DEVMODE
);
});
@@ -7865,7 +7926,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
@@ -7865,7 +7921,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
const state = ctx.state;
if (stylePaddingTop !== void 0) {
const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0;
@@ -557,7 +530,7 @@ index fe5f415..7f1b9dd 100644
let prevTotalSize = peek$(ctx, "totalSize") || 0;
set$(ctx, "totalSize", prevTotalSize + prevStylePaddingTop);
state.timeoutSetPaddingTop = setTimeout(() => {
@@ -7968,6 +8032,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
@@ -7968,6 +8027,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
estimatedListSize,
extraData,
getFixedItemSize,
@@ -565,7 +538,7 @@ index fe5f415..7f1b9dd 100644
getItemType,
horizontal,
rtl,
@@ -8261,6 +8326,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
@@ -8261,6 +8321,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
dataVersion,
drawDistance,
estimatedItemSize,