mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 08:54:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f792ac8a4e | ||
|
|
e8a9e2d699 | ||
|
|
e79e826e23 | ||
|
|
5feec6fa35 | ||
|
|
4928500e1f | ||
|
|
1e2ab3d596 | ||
|
|
2716bd96de | ||
|
|
138d9d63ab |
+11
-2
@@ -179,8 +179,17 @@ This is the Android counterpart of the iOS local-simulator flow in [development.
|
||||
`modules/paseo-scroll` replaces React Native's vertical scroll view with `PaseoScrollView`, which
|
||||
keeps React Native's scroll manager interface and corrects `maintainVisibleContentPosition` for the
|
||||
chat timeline. [agent-stream-disclosure.md](agent-stream-disclosure.md#scroll-view-corrections)
|
||||
lists the corrections and the React Native issues they work around. Its instrumentation test,
|
||||
`VisibleContentPositionClampTest`, mounts through React Native's own mounting manager:
|
||||
lists the content-position corrections and the React Native issues they work around.
|
||||
|
||||
The timeline's `nativeID` (`agent-chat-scroll-native-virtualized`) also identifies it to the native
|
||||
scroll owner: a nested panel's edge overshoot and unconsumed fling must leave the timeline still.
|
||||
Ordinary scroll parents retain Android's handoff. Keep that identity in sync with
|
||||
`agent-stream/strategy-native.tsx`; the end pin is absent while reading history, so it cannot
|
||||
identify the timeline for this rule.
|
||||
|
||||
`VisibleContentPositionClampTest` mounts through React Native's own mounting manager;
|
||||
`PanelEdgeTest` covers both panel edges, flings, interior/direct scrolling, and ordinary-parent
|
||||
handoff with the real compiled scroll views:
|
||||
|
||||
```bash
|
||||
cd packages/app/android
|
||||
|
||||
@@ -235,6 +235,7 @@ async function beginTimelineRowStabilityCheck(
|
||||
lastTop: initialRow.getBoundingClientRect().top,
|
||||
largestDownwardShift: 0,
|
||||
sawMissing: false,
|
||||
sawHidden: false,
|
||||
};
|
||||
const windowState = window as unknown as Record<string, unknown>;
|
||||
windowState.__messageSubmissionTimelineRowStability = state;
|
||||
@@ -245,6 +246,9 @@ async function beginTimelineRowStabilityCheck(
|
||||
requestAnimationFrame(checkFrame);
|
||||
return;
|
||||
}
|
||||
if (!initialRow.checkVisibility({ opacityProperty: true, visibilityProperty: true })) {
|
||||
state.sawHidden = true;
|
||||
}
|
||||
const top = initialRow.getBoundingClientRect().top;
|
||||
state.largestDownwardShift = Math.max(state.largestDownwardShift, top - state.lastTop);
|
||||
state.lastTop = top;
|
||||
@@ -257,14 +261,24 @@ async function beginTimelineRowStabilityCheck(
|
||||
const result = await page.evaluate(() => {
|
||||
const windowState = window as unknown as Record<string, unknown>;
|
||||
const state = windowState.__messageSubmissionTimelineRowStability as
|
||||
| { active: boolean; largestDownwardShift: number; sawMissing: boolean }
|
||||
| {
|
||||
active: boolean;
|
||||
largestDownwardShift: number;
|
||||
sawMissing: boolean;
|
||||
sawHidden: boolean;
|
||||
}
|
||||
| undefined;
|
||||
if (!state) throw new Error("Timeline-row stability check was not started");
|
||||
state.active = false;
|
||||
delete windowState.__messageSubmissionTimelineRowStability;
|
||||
return { largestDownwardShift: state.largestDownwardShift, sawMissing: state.sawMissing };
|
||||
return {
|
||||
largestDownwardShift: state.largestDownwardShift,
|
||||
sawMissing: state.sawMissing,
|
||||
sawHidden: state.sawHidden,
|
||||
};
|
||||
});
|
||||
expect(result.sawMissing).toBe(false);
|
||||
expect(result.sawHidden).toBe(false);
|
||||
expect(result.largestDownwardShift).toBeLessThanOrEqual(2);
|
||||
};
|
||||
}
|
||||
@@ -1178,7 +1192,13 @@ test.describe("Agent message submission", () => {
|
||||
test.setTimeout(120_000);
|
||||
const toasts = await recordPanelToasts(page);
|
||||
const pending = await beginDraftCreateSubmission(page, draftCreateScenario);
|
||||
// The created agent takes over the draft's timeline: the same row stays mounted and painted.
|
||||
const finishTimelineRowStabilityCheck = await beginTimelineRowStabilityCheck(
|
||||
page,
|
||||
pending.userMessage,
|
||||
);
|
||||
await completeDraftCreateSubmission(page, draftCreateScenario, pending);
|
||||
await finishTimelineRowStabilityCheck();
|
||||
// A chat this client just created is current by construction; it is never out of date.
|
||||
await toasts.expectNeverShown("agent-updating-toast");
|
||||
});
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
package sh.paseo.scroll
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
||||
import com.facebook.react.uimanager.DisplayMetricsHolder
|
||||
import com.facebook.react.uimanager.ThemedReactContext
|
||||
import com.facebook.react.views.scroll.ReactScrollView
|
||||
import com.facebook.react.views.scroll.ReactScrollViewManager
|
||||
import com.facebook.react.views.view.ReactViewGroup
|
||||
import com.facebook.soloader.SoLoader
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/** Forward-order parent, as used by the current Android timeline, and a nested panel at its edge. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PanelEdgeTest {
|
||||
private val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
private var activity: Activity? = null
|
||||
private lateinit var manager: ReactScrollViewManager
|
||||
|
||||
@After fun finish() {
|
||||
instrumentation.runOnMainSync { activity?.finish() }
|
||||
}
|
||||
|
||||
@Test fun panelBottomEdgeLeavesTimelineStill() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
child.scrollTo(0, 600)
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.dispatchNestedScroll(0, 0, 0, 100, null)
|
||||
child.stopNestedScroll()
|
||||
assertEquals("Panel stays at its bottom", 600, child.scrollY)
|
||||
assertEquals("Panel overshoot must leave timeline still", 400, parent.scrollY)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun panelTopEdgeLeavesTimelineStill() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
child.scrollTo(0, 0)
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.dispatchNestedScroll(0, 0, 0, -100, null)
|
||||
child.stopNestedScroll()
|
||||
assertEquals("Panel stays at its top", 0, child.scrollY)
|
||||
assertEquals("Panel overshoot must leave timeline still", 400, parent.scrollY)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun panelFlingLeavesTimelineStill() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
child.scrollTo(0, 600)
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.dispatchNestedFling(0f, 1500f, false)
|
||||
child.stopNestedScroll()
|
||||
}
|
||||
SystemClock.sleep(500)
|
||||
instrumentation.runOnMainSync {
|
||||
assertEquals("Panel fling must leave timeline still", 400, parent.scrollY)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun disablingChildNestedScrollPreventsHandoff() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
child.isNestedScrollingEnabled = false
|
||||
assertEquals(false, child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.dispatchNestedScroll(0, 0, 0, 100, null)
|
||||
assertEquals(400, parent.scrollY)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun ordinaryParentStillConsumesNestedOvershoot() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
manager.setNativeId(parent, "ordinary-scroll-parent")
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.dispatchNestedScroll(0, 0, 0, 100, null)
|
||||
child.stopNestedScroll()
|
||||
assertEquals("Ordinary parent retains framework handoff", 500, parent.scrollY)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun ordinaryParentStillConsumesUnconsumedFling() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
manager.setNativeId(parent, "ordinary-scroll-parent")
|
||||
child.scrollTo(0, 600)
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.dispatchNestedFling(0f, 1500f, false)
|
||||
child.stopNestedScroll()
|
||||
}
|
||||
SystemClock.sleep(500)
|
||||
instrumentation.runOnMainSync { assertTrue(parent.scrollY > 400) }
|
||||
}
|
||||
|
||||
@Test fun consumedPanelFlingDoesNotStartParentFling() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
assertEquals(false, child.dispatchNestedFling(0f, 1500f, true))
|
||||
child.stopNestedScroll()
|
||||
assertEquals(400, parent.scrollY)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun panelInteriorScrollingLeavesTimelineStill() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
child.scrollTo(0, 100)
|
||||
assertEquals(100, child.scrollY)
|
||||
assertEquals(400, parent.scrollY)
|
||||
parent.scrollTo(0, 500)
|
||||
assertEquals("Direct timeline scrolling remains available", 500, parent.scrollY)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openPanel(): Pair<ReactScrollView, ReactScrollView> {
|
||||
val started = instrumentation.startActivitySync(
|
||||
Intent(instrumentation.targetContext, Activity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
activity = started
|
||||
lateinit var parent: ReactScrollView
|
||||
lateinit var child: ReactScrollView
|
||||
instrumentation.runOnMainSync {
|
||||
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(started)
|
||||
SoLoader.init(started, OpenSourceMergedSoMapping)
|
||||
val react = ScrollTestContext(started.applicationContext)
|
||||
val context = ThemedReactContext(react, started)
|
||||
manager = PaseoScrollPackage().createViewManager(react, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
|
||||
parent = manager.createViewInstance(context)
|
||||
manager.setNativeId(parent, "agent-chat-scroll-native-virtualized")
|
||||
child = manager.createViewInstance(context)
|
||||
child.isNestedScrollingEnabled = true
|
||||
val content = ReactViewGroup(context)
|
||||
content.addView(child, FrameLayout.LayoutParams(300, 200).apply { topMargin = 400 })
|
||||
parent.addView(content)
|
||||
val childContent = ReactViewGroup(context)
|
||||
child.addView(childContent)
|
||||
started.setContentView(FrameLayout(started).apply { addView(parent, FrameLayout.LayoutParams(300, 400)) })
|
||||
parent.layout(0, 0, 300, 400)
|
||||
content.layout(0, 0, 300, 2000)
|
||||
child.layout(0, 400, 300, 600)
|
||||
childContent.layout(0, 0, 300, 800)
|
||||
parent.scrollTo(0, 400)
|
||||
}
|
||||
return Pair(parent, child)
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package sh.paseo.scroll;
|
||||
|
||||
import android.content.Context;
|
||||
import com.facebook.react.bridge.BridgeReactContext;
|
||||
import com.facebook.react.uimanager.events.BlackHoleEventDispatcher;
|
||||
import com.facebook.react.uimanager.events.EventDispatcher;
|
||||
import com.facebook.react.uimanager.events.EventDispatcherProvider;
|
||||
|
||||
/** Native-only tests use RN's own event sink for a context without JavaScript. */
|
||||
final class ScrollTestContext extends BridgeReactContext implements EventDispatcherProvider {
|
||||
ScrollTestContext(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public EventDispatcher getEventDispatcher() {
|
||||
return BlackHoleEventDispatcher.INSTANCE;
|
||||
}
|
||||
}
|
||||
+24
-1
@@ -12,10 +12,13 @@ import com.facebook.react.views.scroll.ReactScrollView;
|
||||
import com.facebook.react.views.scroll.ReactScrollViewHelper;
|
||||
|
||||
/**
|
||||
* React Native's vertical scroll view with two corrections. Java, because the content-position
|
||||
* React Native's vertical scroll view with timeline corrections. Java, because the content-position
|
||||
* config type is internal to React Native's Kotlin module.
|
||||
*/
|
||||
final class PaseoScrollView extends ReactScrollView {
|
||||
// The chat timeline, packages/app/src/agent-stream/strategy-native.tsx.
|
||||
private static final String TIMELINE_NATIVE_ID = "agent-chat-scroll-native-virtualized";
|
||||
|
||||
// The chat timeline's end pin, packages/app/src/agent-stream/native-end-pin.tsx.
|
||||
private static final String END_PIN_NATIVE_ID = "paseo-end-pin";
|
||||
|
||||
@@ -32,6 +35,26 @@ final class PaseoScrollView extends ReactScrollView {
|
||||
super(context);
|
||||
}
|
||||
|
||||
// A panel owns its gesture through its edges; remaining child motion must not move the chat
|
||||
// behind it. The timeline's native identity stays present when its end pin is removed for
|
||||
// reading history. Ordinary parents retain Android's nested-scroll handoff.
|
||||
@Override
|
||||
public void onNestedScroll(
|
||||
View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
|
||||
if (isTimeline()) return;
|
||||
super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) {
|
||||
if (isTimeline() && !consumed) return true;
|
||||
return super.onNestedFling(target, velocityX, velocityY, consumed);
|
||||
}
|
||||
|
||||
private boolean isTimeline() {
|
||||
return TIMELINE_NATIVE_ID.equals(getTag(R.id.view_tag_native_id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setMaintainVisibleContentPosition(
|
||||
@Nullable MaintainVisibleScrollPositionHelper.Config config) {
|
||||
|
||||
@@ -65,7 +65,6 @@ interface BottomAnchorControllerDriver {
|
||||
pendingVerification: AttemptContext | null;
|
||||
blockedReason: BottomAnchorBlockedReason | null;
|
||||
};
|
||||
resetForAgent: () => void;
|
||||
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
|
||||
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
|
||||
beginUserScroll: () => void;
|
||||
@@ -480,17 +479,6 @@ function createBottomAnchorControllerDriver(
|
||||
blockedReason,
|
||||
};
|
||||
},
|
||||
resetForAgent() {
|
||||
lastRouteRequestKey = null;
|
||||
pendingRequest = null;
|
||||
blockedReason = null;
|
||||
cancelPendingAttempt();
|
||||
stickyMeasurementRevision = 0;
|
||||
lastVerifiedStickyMeasurementRevision = 0;
|
||||
isUserScrollActive = false;
|
||||
mode = "sticky-bottom";
|
||||
input.onModeChange("sticky-bottom");
|
||||
},
|
||||
applyRouteRequest(request) {
|
||||
if (!request) {
|
||||
return;
|
||||
@@ -741,10 +729,6 @@ export function useBottomAnchorController(input: {
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
driverRef.current?.resetForAgent();
|
||||
}, [input.agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
driverRef.current?.applyRouteRequest(input.routeRequest);
|
||||
}, [input.routeRequest]);
|
||||
|
||||
@@ -129,7 +129,8 @@ function mountList(heldRowId: string) {
|
||||
}
|
||||
return position - state.scroll;
|
||||
};
|
||||
return { render, scrollTo, heldRowTop };
|
||||
const rowSize = (rowId: string) => listRef.current?.getState().sizes.get(rowId);
|
||||
return { render, scrollTo, heldRowTop, rowSize };
|
||||
}
|
||||
|
||||
it("anchors a data change on a held row whose top is above the viewport", async () => {
|
||||
@@ -169,3 +170,21 @@ it("anchors a size change on a held row whose top is above the viewport", async
|
||||
|
||||
expect(list.heldRowTop()).toBe(-20);
|
||||
});
|
||||
|
||||
it("keeps measured row sizes while the list is not laid out", async () => {
|
||||
// A hidden retained tab is display: none. Its rows still render, and a row measured there
|
||||
// reports 0 x 0. When the tab shows again, a row whose frame did not change gets no new layout
|
||||
// event, so a 0 recorded while hidden would stay and the next row would overlap it.
|
||||
const rows: Row[] = Array.from({ length: 3 }, (_, index) => ({ id: `row-${index}`, height: 40 }));
|
||||
const list = mountList("row-0");
|
||||
list.render(rows);
|
||||
await wait(150);
|
||||
expect(rows.map((row) => list.rowSize(row.id))).toEqual([40, 40, 40]);
|
||||
|
||||
host.style.display = "none";
|
||||
await wait(150);
|
||||
list.render(rows.map((row) => ({ ...row })));
|
||||
await wait(150);
|
||||
|
||||
expect(rows.map((row) => list.rowSize(row.id))).toEqual([40, 40, 40]);
|
||||
});
|
||||
|
||||
@@ -137,9 +137,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
} = props;
|
||||
const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers;
|
||||
const listRef = useRef<LegendListRef>(null);
|
||||
// LegendList places the rows of each agent once, behind a hidden reveal (`dataKey`).
|
||||
const [placedAgentId, setPlacedAgentId] = useState<string | null>(null);
|
||||
const isListPlaced = placedAgentId === agentId;
|
||||
// LegendList places the rows once, behind a hidden reveal, when the list mounts.
|
||||
const [isListPlaced, setIsListPlaced] = useState(false);
|
||||
const isListPlacedRef = useRef(isListPlaced);
|
||||
isListPlacedRef.current = isListPlaced;
|
||||
const streamViewportMetricsRef = useRef({
|
||||
@@ -384,23 +383,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
streamViewportMetricsRef.current = {
|
||||
containerKey: "native-virtualized",
|
||||
contentHeight: 0,
|
||||
viewportWidth: 0,
|
||||
viewportHeight: 0,
|
||||
offsetY: 0,
|
||||
viewportMeasuredForKey: null,
|
||||
contentMeasuredForKey: null,
|
||||
};
|
||||
scrollOffsetYRef.current = 0;
|
||||
isUserScrollActiveRef.current = false;
|
||||
clearPendingUserScrollEnd();
|
||||
disclosureHold.cancel();
|
||||
historyStartReadyRef.current = false;
|
||||
const initialHistoryStartState = createHistoryStartPaginationState();
|
||||
historyStartPaginationStateRef.current = initialHistoryStartState;
|
||||
setHistoryStartPaginationState(initialHistoryStartState);
|
||||
const frame = requestAnimationFrame(() => {
|
||||
historyStartReadyRef.current = true;
|
||||
evaluateHistoryStart();
|
||||
@@ -411,7 +393,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
historyStartSettleSchedulerRef.current?.cancel();
|
||||
historyStartSettleSchedulerRef.current = null;
|
||||
};
|
||||
}, [agentId, clearPendingUserScrollEnd, disclosureHold, evaluateHistoryStart]);
|
||||
}, [clearPendingUserScrollEnd, evaluateHistoryStart]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle: StreamViewportHandle = {
|
||||
@@ -623,7 +605,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
|
||||
const handleListPlaced = useStableEvent(() => {
|
||||
isListPlacedRef.current = true;
|
||||
setPlacedAgentId(agentId);
|
||||
setIsListPlaced(true);
|
||||
streamViewportMetricsRef.current = {
|
||||
...streamViewportMetricsRef.current,
|
||||
contentMeasuredForKey: "native-virtualized",
|
||||
@@ -693,6 +675,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
extraData={renderHistoryMountedRow}
|
||||
keyExtractor={keyExtractor}
|
||||
testID="agent-chat-scroll"
|
||||
// The Android scroll owner identifies the timeline to contain nested panel gestures.
|
||||
nativeID="agent-chat-scroll-native-virtualized"
|
||||
ListHeaderComponent={historyStartContent}
|
||||
ListFooterComponent={liveAuxiliaryContent ?? undefined}
|
||||
@@ -711,7 +694,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
onItemSizeChanged={handleItemSizeChanged}
|
||||
onReady={handleListPlaced}
|
||||
dataKey={agentId}
|
||||
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
||||
renderScrollComponent={renderScrollComponent}
|
||||
recycleItems={false}
|
||||
|
||||
@@ -839,10 +839,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const initialHistoryStartState = createHistoryStartPaginationState();
|
||||
historyStartPaginationStateRef.current = initialHistoryStartState;
|
||||
setHistoryStartPaginationState(initialHistoryStartState);
|
||||
readingAnchor.reset();
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
historyStartReadyRef.current = true;
|
||||
evaluateHistoryStart();
|
||||
@@ -853,7 +849,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
historyStartSettleSchedulerRef.current?.cancel();
|
||||
historyStartSettleSchedulerRef.current = null;
|
||||
};
|
||||
}, [evaluateHistoryStart, props.agentId, readingAnchor]);
|
||||
}, [evaluateHistoryStart]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActiveRef.current || !isActivationReady) {
|
||||
@@ -1395,11 +1391,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): StreamStrategy {
|
||||
return createStreamStrategy({
|
||||
render: (renderInput) => (
|
||||
<WebStreamViewport
|
||||
key={renderInput.agentId}
|
||||
{...renderInput}
|
||||
isMobileBreakpoint={input.isMobileBreakpoint}
|
||||
/>
|
||||
<WebStreamViewport {...renderInput} isMobileBreakpoint={input.isMobileBreakpoint} />
|
||||
),
|
||||
orderTailReverse: false,
|
||||
orderHeadReverse: false,
|
||||
|
||||
@@ -21,7 +21,7 @@ describe("useStreamHistoryWindow", () => {
|
||||
]).flat();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useStreamHistoryWindow({ agentId: "agent-1", items, loadRemoteOlder: vi.fn() }),
|
||||
useStreamHistoryWindow({ items, loadRemoteOlder: vi.fn() }),
|
||||
);
|
||||
|
||||
expect(result.current.start).toBe(2);
|
||||
@@ -35,7 +35,6 @@ describe("useStreamHistoryWindow", () => {
|
||||
const loadRemoteOlder = vi.fn(async () => true);
|
||||
const { result } = renderHook(() =>
|
||||
useStreamHistoryWindow({
|
||||
agentId: "agent-1",
|
||||
items: [
|
||||
message("u1", "user_message"),
|
||||
message("a1", "assistant_message"),
|
||||
@@ -67,8 +66,7 @@ describe("useStreamHistoryWindow", () => {
|
||||
message("a2", "assistant_message"),
|
||||
];
|
||||
const { result, rerender } = renderHook(
|
||||
({ history }) =>
|
||||
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
|
||||
({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }),
|
||||
{ initialProps: { history: items } },
|
||||
);
|
||||
|
||||
@@ -91,8 +89,7 @@ describe("useStreamHistoryWindow", () => {
|
||||
message("a2", "assistant_message"),
|
||||
];
|
||||
const { result, rerender } = renderHook(
|
||||
({ history }) =>
|
||||
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
|
||||
({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }),
|
||||
{ initialProps: { history: items } },
|
||||
);
|
||||
|
||||
@@ -108,8 +105,7 @@ describe("useStreamHistoryWindow", () => {
|
||||
configurable: true,
|
||||
});
|
||||
const { result, rerender } = renderHook(
|
||||
({ history }) =>
|
||||
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
|
||||
({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }),
|
||||
{
|
||||
initialProps: {
|
||||
history: [
|
||||
@@ -142,7 +138,6 @@ describe("useStreamHistoryWindow", () => {
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
useStreamHistoryWindow({
|
||||
agentId: "agent-1",
|
||||
items: [
|
||||
message("u1", "user_message"),
|
||||
message("a1", "assistant_message"),
|
||||
|
||||
@@ -28,22 +28,17 @@ function findEarlierHistoryWindowStart(input: {
|
||||
}
|
||||
|
||||
interface HistoryWindowBoundary {
|
||||
agentId: string;
|
||||
boundaryItemId: string | null;
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
function reconcileHistoryWindow(input: {
|
||||
current: HistoryWindowBoundary;
|
||||
agentId: string;
|
||||
boundaryIndex: number;
|
||||
initialBoundaryItemId: string | null;
|
||||
hasItems: boolean;
|
||||
}): HistoryWindowBoundary {
|
||||
const { current, agentId, boundaryIndex, initialBoundaryItemId, hasItems } = input;
|
||||
if (current.agentId !== agentId) {
|
||||
return { agentId, boundaryItemId: initialBoundaryItemId, initialized: hasItems };
|
||||
}
|
||||
const { current, boundaryIndex, initialBoundaryItemId, hasItems } = input;
|
||||
if (!current.initialized && hasItems) {
|
||||
return { ...current, boundaryItemId: initialBoundaryItemId, initialized: true };
|
||||
}
|
||||
@@ -54,28 +49,26 @@ function reconcileHistoryWindow(input: {
|
||||
}
|
||||
|
||||
export function useStreamHistoryWindow(input: {
|
||||
agentId: string;
|
||||
items: StreamItem[];
|
||||
loadRemoteOlder: () => boolean | Promise<boolean>;
|
||||
}) {
|
||||
const { agentId, items, loadRemoteOlder } = input;
|
||||
const { items, loadRemoteOlder } = input;
|
||||
const initialStart = useMemo(
|
||||
() => findMountedWindowStart({ items, minMountedCount: getMountedRecentStreamItems() }),
|
||||
[items],
|
||||
);
|
||||
const initialBoundaryItemId = initialStart === 0 ? null : (items[initialStart]?.id ?? null);
|
||||
const [window, setWindow] = useState(() => ({
|
||||
agentId,
|
||||
boundaryItemId: initialBoundaryItemId,
|
||||
initialized: items.length > 0,
|
||||
}));
|
||||
const boundaryItemId = window.agentId === agentId ? window.boundaryItemId : null;
|
||||
const boundaryItemId = window.boundaryItemId;
|
||||
const boundaryIndex = useMemo(
|
||||
() => (boundaryItemId === null ? -1 : items.findIndex((item) => item.id === boundaryItemId)),
|
||||
[boundaryItemId, items],
|
||||
);
|
||||
let start = initialStart;
|
||||
if (window.agentId === agentId && window.initialized) {
|
||||
if (window.initialized) {
|
||||
if (window.boundaryItemId === null) {
|
||||
start = 0;
|
||||
} else if (boundaryIndex >= 0) {
|
||||
@@ -87,13 +80,12 @@ export function useStreamHistoryWindow(input: {
|
||||
setWindow((current) =>
|
||||
reconcileHistoryWindow({
|
||||
current,
|
||||
agentId,
|
||||
boundaryIndex,
|
||||
initialBoundaryItemId,
|
||||
hasItems: items.length > 0,
|
||||
}),
|
||||
);
|
||||
}, [agentId, boundaryIndex, initialBoundaryItemId, items.length]);
|
||||
}, [boundaryIndex, initialBoundaryItemId, items.length]);
|
||||
|
||||
const revealLoadedHistory = useCallback(
|
||||
(itemId?: string): boolean => {
|
||||
@@ -106,13 +98,12 @@ export function useStreamHistoryWindow(input: {
|
||||
return false;
|
||||
}
|
||||
setWindow({
|
||||
agentId,
|
||||
boundaryItemId: items[nextStart]?.id ?? null,
|
||||
initialized: true,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[agentId, items, start],
|
||||
[items, start],
|
||||
);
|
||||
const loadOlder = useCallback(async (): Promise<boolean> => {
|
||||
return revealLoadedHistory() || (await loadRemoteOlder());
|
||||
|
||||
@@ -430,10 +430,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
loadOlder: historyPagination.onLoadOlder,
|
||||
}
|
||||
: agentHistoryPagination;
|
||||
useEffect(() => {
|
||||
setExpandedInlineToolCallIds(new Set());
|
||||
setExpandedTurnHostIds(new Set());
|
||||
}, [agentId]);
|
||||
|
||||
const isActive = useRetainedPanelActive();
|
||||
const openInlineFile = useStableEvent(
|
||||
@@ -476,7 +472,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
useEffect(() => {
|
||||
if (!isActive) inlinePathOpener.cancel();
|
||||
return () => inlinePathOpener.cancel();
|
||||
}, [inlinePathOpener, isActive, agentId]);
|
||||
}, [inlinePathOpener, isActive]);
|
||||
const handleInlinePathPress = useStableEvent(
|
||||
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
|
||||
if (isActive) void inlinePathOpener.open(target, disposition);
|
||||
@@ -551,7 +547,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
revealLoadedHistory,
|
||||
loadOlder,
|
||||
} = useStreamHistoryWindow({
|
||||
agentId,
|
||||
items: presentation.tail,
|
||||
loadRemoteOlder,
|
||||
});
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { within, waitFor } from "@testing-library/dom";
|
||||
import { EnrichedMarkdownText } from "react-native-enriched-markdown";
|
||||
import { expect, it } from "vitest";
|
||||
import { normalizeMarkdownHtml } from "./html";
|
||||
|
||||
const FLAGS = { hardSoftBreaks: true, latexMath: false };
|
||||
|
||||
it.each([false, true])(
|
||||
"renders table-cell breaks on separate lines (details: %s)",
|
||||
async (details) => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const table =
|
||||
"| A | B |\n|---|---|\n| 1 | first<br/>second |\n| 2 | first<br />second |\n| 3 | first<br>second |";
|
||||
const source = details ? `<details><summary>Results</summary>\n${table}\n</details>` : table;
|
||||
try {
|
||||
act(() =>
|
||||
root.render(
|
||||
<EnrichedMarkdownText markdown={normalizeMarkdownHtml(source)} md4cFlags={FLAGS} />,
|
||||
),
|
||||
);
|
||||
await waitFor(() => expect(within(container).getAllByRole("row")).toHaveLength(4));
|
||||
for (const row of within(container).getAllByRole("row").slice(1)) {
|
||||
const cell = within(row).getAllByRole("cell")[1];
|
||||
expect(cell.innerText).toBe("first\nsecond");
|
||||
const first = document.createRange();
|
||||
first.selectNodeContents(cell.firstChild!);
|
||||
const second = document.createRange();
|
||||
second.selectNodeContents(cell.lastChild!);
|
||||
expect(second.getBoundingClientRect().top).toBeGreaterThan(
|
||||
first.getBoundingClientRect().top,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Parser } from "htmlparser2";
|
||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
import { gfmTable } from "micromark-extension-gfm-table";
|
||||
import { gfmTableFromMarkdown } from "mdast-util-gfm-table";
|
||||
import { decodeString } from "micromark-util-decode-string";
|
||||
import type { Nodes } from "mdast";
|
||||
|
||||
@@ -36,35 +34,25 @@ const EMPHASIS: Readonly<Record<string, string>> = { b: "**", strong: "**", i: "
|
||||
export function normalizeMarkdownHtml(source: string): string {
|
||||
if (!source.includes("<")) return source;
|
||||
const htmlRanges: { start: number; end: number }[] = [];
|
||||
const tableRanges: { start: number; end: number }[] = [];
|
||||
function findHtml(node: Nodes) {
|
||||
if (node.type === "html" && node.position) {
|
||||
htmlRanges.push({ start: node.position.start.offset!, end: node.position.end.offset! });
|
||||
} else if ("children" in node) node.children.forEach(findHtml);
|
||||
}
|
||||
findHtml(
|
||||
fromMarkdown(source, {
|
||||
extensions: [gfmTable()],
|
||||
mdastExtensions: [gfmTableFromMarkdown()],
|
||||
}),
|
||||
);
|
||||
findHtml(fromMarkdown(source));
|
||||
const codeRanges: { start: number; end: number }[] = [];
|
||||
// Disable HTML parsing so code and tables inside <details> remain visible.
|
||||
// Disable HTML parsing so code inside <details> remains visible to the Markdown parser.
|
||||
const tree = fromMarkdown(source, {
|
||||
extensions: [{ disable: { null: ["htmlFlow", "htmlText"] } }, gfmTable()],
|
||||
mdastExtensions: [gfmTableFromMarkdown()],
|
||||
extensions: [{ disable: { null: ["htmlFlow", "htmlText"] } }],
|
||||
});
|
||||
function findMarkdownRanges(node: Nodes) {
|
||||
if (node.type === "table" && node.position) {
|
||||
tableRanges.push({ start: node.position.start.offset!, end: node.position.end.offset! });
|
||||
}
|
||||
function protectCode(node: Nodes) {
|
||||
if (node.type === "code" || node.type === "inlineCode") {
|
||||
const start = node.position?.start.offset;
|
||||
const end = node.position?.end.offset;
|
||||
if (start !== undefined && end !== undefined) codeRanges.push({ start, end });
|
||||
} else if ("children" in node) node.children.forEach(findMarkdownRanges);
|
||||
} else if ("children" in node) node.children.forEach(protectCode);
|
||||
}
|
||||
findMarkdownRanges(tree);
|
||||
protectCode(tree);
|
||||
const links: (string | undefined)[] = [];
|
||||
let previousTag: { name: string; end: number } | null = null;
|
||||
const convert = (raw: string, offset: number): string => {
|
||||
@@ -96,13 +84,6 @@ export function normalizeMarkdownHtml(source: string): string {
|
||||
: /^\s*$/.test(source.slice(0, offset));
|
||||
previousTag = { name, end: offset + raw.length };
|
||||
if (EMPHASIS[name]) return EMPHASIS[name];
|
||||
if (name === "br") {
|
||||
if (closing) return "";
|
||||
// A Unicode line separator keeps the row intact; its space ends GFM autolinks.
|
||||
return tableRanges.some(({ start, end }) => offset >= start && offset < end)
|
||||
? " \u2028"
|
||||
: " \n";
|
||||
}
|
||||
const boundary = layoutBoundary(name, source, raw, offset, closing, startsAfterBoundary);
|
||||
if (boundary !== null) return boundary;
|
||||
if (name === "a") return markdownAnchor(raw, closing, links);
|
||||
@@ -187,6 +168,7 @@ function layoutBoundary(
|
||||
closing: boolean,
|
||||
startsAfterBoundary: boolean,
|
||||
): string | null {
|
||||
if (name === "br") return closing ? "" : " \n";
|
||||
if (name === "summary") return closing ? "**\n\n" : "**";
|
||||
if (name === "details")
|
||||
return (closing ? offset + raw.length === source.length : offset === 0) ? "" : "\n\n";
|
||||
|
||||
@@ -39,88 +39,6 @@ it("resolves authored root-relative links only when the target is a known comman
|
||||
});
|
||||
|
||||
describe("shared Markdown source adaptation", () => {
|
||||
it("keeps HTML line breaks inside their Markdown table cells", async () => {
|
||||
const source =
|
||||
"| A | B |\n|---|---|\n| 1 | first<br/>second |\n| 2 | first<br />second |\n| 3 | first<br>second |";
|
||||
const tree = await parseMarkdown(prepareMarkdownSource(source).render(), {
|
||||
hardSoftBreaks: true,
|
||||
latexMath: false,
|
||||
});
|
||||
const rows: ASTNode[] = [];
|
||||
const collect = (node: ASTNode) => {
|
||||
if (node.type === "TableRow") rows.push(node);
|
||||
node.children?.forEach(collect);
|
||||
};
|
||||
collect(tree);
|
||||
expect(rows).toHaveLength(4);
|
||||
const text = (node: ASTNode): string =>
|
||||
(node.content ?? "") + (node.children?.map(text).join("") ?? "");
|
||||
expect(rows.map((row) => row.children?.map(text))).toEqual([
|
||||
["A", "B"],
|
||||
["1", "first \u2028second"],
|
||||
["2", "first \u2028second"],
|
||||
["3", "first \u2028second"],
|
||||
]);
|
||||
});
|
||||
it("keeps table breaks inside a details wrapper without a blank line", async () => {
|
||||
const source =
|
||||
"<details><summary>Results</summary>\n| A | B |\n|---|---|\n| 1 | first<br>second |\n</details>";
|
||||
const tree = await parseMarkdown(prepareMarkdownSource(source).render(), {
|
||||
hardSoftBreaks: true,
|
||||
latexMath: false,
|
||||
});
|
||||
const tables: ASTNode[] = [];
|
||||
const collect = (node: ASTNode) => {
|
||||
if (node.type === "Table") tables.push(node);
|
||||
node.children?.forEach(collect);
|
||||
};
|
||||
collect(tree);
|
||||
expect(tables).toHaveLength(1);
|
||||
expect(tables[0].attributes?.bodyRowCount).toBe("1");
|
||||
expect(tables[0].children?.[1]?.children?.[0]?.children?.[1]?.children).toEqual([
|
||||
{ type: "Text", content: "first \u2028second" },
|
||||
]);
|
||||
});
|
||||
it.each([
|
||||
["src/first.ts", "src/second.ts"],
|
||||
["https://example.com/first", "src/second.ts"],
|
||||
["https://example.com/first", "https://example.com/second"],
|
||||
])("keeps %s and %s separate across a table break", async (first, second) => {
|
||||
const prepared = prepareMarkdownSource(
|
||||
`| A | B |\n|---|---|\n| 1 | ${first}<br>${second} |`,
|
||||
"/repo",
|
||||
);
|
||||
const tree = recognizeTextLinks(
|
||||
await parseMarkdown(prepared.render(), { hardSoftBreaks: true, latexMath: false }),
|
||||
prepared.linkRecognition.text,
|
||||
prepared.linkRecognition.inlineCode,
|
||||
);
|
||||
const links: string[] = [];
|
||||
const collect = (node: ASTNode) => {
|
||||
if (node.type === "Link") links.push(String(node.attributes?.url));
|
||||
node.children?.forEach(collect);
|
||||
};
|
||||
collect(tree);
|
||||
expect(links).toEqual([first, second]);
|
||||
});
|
||||
it.each([
|
||||
["| A<br>B | C |\n|---|---|\n| x | y |", "| A \u2028B | C |\n|---|---|\n| x | y |"],
|
||||
[
|
||||
"> | A | B |\n> |---|---|\n> | x | first<br>second |",
|
||||
"> | A | B |\n> |---|---|\n> | x | first \u2028second |",
|
||||
],
|
||||
[
|
||||
"| A | B |\n|---|---|\n| `<br>` | **first<br><BR />second** |",
|
||||
"| A | B |\n|---|---|\n| `<br>` | **first \u2028 \u2028second** |",
|
||||
],
|
||||
[
|
||||
"| A | B |\n|---|---|\n| x | \\<br> <br> |",
|
||||
"| A | B |\n|---|---|\n| x | \\<br> <br> |",
|
||||
],
|
||||
["Not a table | first<br>second", "Not a table | first \nsecond"],
|
||||
])("normalizes table breaks without changing literal tags: %s", (source, expected) => {
|
||||
expect(normalizeMarkdownHtml(source)).toBe(expected);
|
||||
});
|
||||
it("keeps paragraphs, fences and quotes together for the library", () => {
|
||||
const source = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote";
|
||||
expect(prepareMarkdownSource(source).render()).toBe(source);
|
||||
|
||||
@@ -10,10 +10,9 @@ import type { Link, Nodes, Paragraph, PhrasingContent, Root } from "mdast";
|
||||
|
||||
// Keep this regex in the syntax shared by JavaScript, ICU and java.util.regex.
|
||||
// The Unicode flag makes Metro expand it to escapes rejected by native engines.
|
||||
// U+2028 is an inline line break, including between table-cell file links.
|
||||
const PATH_CHARACTER = String.raw`[a-zA-Z0-9\u0080-\u2027\u2029-\uFFFF_@.%+-]`;
|
||||
const PATH_CHARACTER = String.raw`[a-zA-Z0-9\u0080-\uFFFF_@.%+-]`;
|
||||
const PATH_LETTER = String.raw`[a-zA-Z]`;
|
||||
const PATH_SEGMENT = String.raw`${PATH_CHARACTER}*[a-zA-Z0-9\u0080-\u2027\u2029-\uFFFF_@%+-]`;
|
||||
const PATH_SEGMENT = String.raw`${PATH_CHARACTER}*[a-zA-Z0-9\u0080-\uFFFF_@%+-]`;
|
||||
const LINE_SUFFIX = String.raw`(?::\d+(?::\d+)?(?:-\d+(?::\d+)?)?)?`;
|
||||
const FILE_EXTENSIONS = [...ASSISTANT_FILE_EXTENSIONS]
|
||||
.map((extension) => extension.replace(/[a-z]/g, (letter) => `[${letter}${letter.toUpperCase()}]`))
|
||||
@@ -30,7 +29,7 @@ function createTextPattern(path: string, forgeReferencePattern?: string): RegExp
|
||||
const forgeReference = forgeReferencePattern ? `|${forgeReferencePattern}` : "";
|
||||
// The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape.
|
||||
return new RegExp(
|
||||
String.raw`(?:${BARE_URL}${forgeReference}|(?<![a-zA-Z0-9\u0080-\u2027\u2029-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\u2027\u2029-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
|
||||
String.raw`(?:${BARE_URL}${forgeReference}|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
|
||||
"g",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,17 +60,6 @@ describe("useDraftAgentCreateFlow", () => {
|
||||
|
||||
expect(result.current.isSubmitting).toBe(true);
|
||||
expect(result.current.draftAgent).toEqual({ currentAttempt: attempt });
|
||||
expect(result.current.submittedStreamItems).toEqual([
|
||||
{
|
||||
kind: "user_message",
|
||||
id: "msg-prepared",
|
||||
clientMessageId: "msg-prepared",
|
||||
text: "build this",
|
||||
timestamp: attempt.timestamp,
|
||||
images: [image],
|
||||
attachments: [attachment],
|
||||
},
|
||||
]);
|
||||
expect(createRequest).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -8,15 +8,8 @@ import {
|
||||
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
createUserMessage,
|
||||
type StreamItem,
|
||||
type UserMessageImageAttachment,
|
||||
} from "@/types/stream";
|
||||
import { createUserMessage, type UserMessageImageAttachment } from "@/types/stream";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { PendingMessageSubmission } from "@/composer/submission/model";
|
||||
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
|
||||
interface CreateAttempt {
|
||||
clientMessageId: string;
|
||||
@@ -153,38 +146,6 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
|
||||
const isSubmitting = machine.tag === "creating";
|
||||
|
||||
const submittedStreamItems = useMemo<StreamItem[]>(() => {
|
||||
if (machine.tag !== "creating") {
|
||||
return EMPTY_STREAM_ITEMS;
|
||||
}
|
||||
|
||||
if (
|
||||
!machine.attempt.text &&
|
||||
(!machine.attempt.images || machine.attempt.images.length === 0) &&
|
||||
(!machine.attempt.attachments || machine.attempt.attachments.length === 0)
|
||||
) {
|
||||
return EMPTY_STREAM_ITEMS;
|
||||
}
|
||||
|
||||
return [
|
||||
createUserMessage({
|
||||
clientMessageId: machine.attempt.clientMessageId,
|
||||
text: machine.attempt.text,
|
||||
timestamp: machine.attempt.timestamp,
|
||||
images: machine.attempt.images,
|
||||
attachments: machine.attempt.attachments,
|
||||
}),
|
||||
];
|
||||
}, [machine]);
|
||||
const pendingMessageSubmissions = useMemo<readonly PendingMessageSubmission[]>(() => {
|
||||
if (machine.tag !== "creating") return [];
|
||||
return [
|
||||
{
|
||||
clientMessageId: machine.attempt.clientMessageId,
|
||||
},
|
||||
];
|
||||
}, [machine]);
|
||||
|
||||
const draftAgent = machine.tag === "creating" ? machine.draftAgent : null;
|
||||
const startCreateAttempt = useCallback(
|
||||
(attempt: CreateAttempt) => {
|
||||
@@ -365,8 +326,6 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
machine,
|
||||
formErrorMessage,
|
||||
isSubmitting,
|
||||
submittedStreamItems,
|
||||
pendingMessageSubmissions,
|
||||
draftAgent,
|
||||
handleCreateFromInput,
|
||||
continueCreateFromAttempt,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { PendingCreateAttempt } from "@/stores/create-flow-store";
|
||||
import type { UserMessageImageAttachment } from "@/types/stream";
|
||||
import { DRAFT_CAPABILITIES, resolveDraftTimeline } from "./timeline";
|
||||
|
||||
const image: UserMessageImageAttachment = {
|
||||
id: "image-1",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb",
|
||||
storageKey: "image-key",
|
||||
createdAt: 123,
|
||||
};
|
||||
const attachment = {
|
||||
type: "review",
|
||||
cwd: "/repo",
|
||||
summary: "Review",
|
||||
} as unknown as AgentAttachment;
|
||||
|
||||
function pendingAttempt(overrides: Partial<PendingCreateAttempt> = {}): PendingCreateAttempt {
|
||||
return {
|
||||
draftId: "draft-1",
|
||||
serverId: "server-1",
|
||||
agentId: null,
|
||||
clientMessageId: "draft-1:initial-message",
|
||||
text: "build this",
|
||||
timestamp: Date.parse("2026-05-25T00:00:00.000Z"),
|
||||
lifecycle: "active",
|
||||
images: [image],
|
||||
attachments: [attachment],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveDraftTimeline", () => {
|
||||
it("shows the sent prompt pending with the turn working", () => {
|
||||
const timeline = resolveDraftTimeline({
|
||||
pending: pendingAttempt(),
|
||||
serverId: "server-1",
|
||||
tabId: "tab-1",
|
||||
cwd: "/repo",
|
||||
});
|
||||
|
||||
expect(timeline?.agent).toEqual({
|
||||
serverId: "server-1",
|
||||
id: "tab-1",
|
||||
status: "running",
|
||||
cwd: "/repo",
|
||||
capabilities: DRAFT_CAPABILITIES,
|
||||
});
|
||||
expect(timeline?.streamItems).toEqual([
|
||||
{
|
||||
kind: "user_message",
|
||||
id: "draft-1:initial-message",
|
||||
clientMessageId: "draft-1:initial-message",
|
||||
text: "build this",
|
||||
timestamp: new Date("2026-05-25T00:00:00.000Z"),
|
||||
images: [image],
|
||||
attachments: [attachment],
|
||||
},
|
||||
]);
|
||||
expect(timeline?.pendingMessageSubmissions).toEqual([
|
||||
{ clientMessageId: "draft-1:initial-message" },
|
||||
]);
|
||||
expect(timeline?.turnPresentation.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the prompt once the agent is created, until the tab retargets", () => {
|
||||
const timeline = resolveDraftTimeline({
|
||||
pending: pendingAttempt({ lifecycle: "sent", agentId: "agent-1" }),
|
||||
serverId: "server-1",
|
||||
tabId: "tab-1",
|
||||
cwd: "/repo",
|
||||
});
|
||||
|
||||
expect(timeline?.streamItems).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows only the working turn for a prompt without content", () => {
|
||||
const timeline = resolveDraftTimeline({
|
||||
pending: pendingAttempt({ text: "", images: undefined, attachments: undefined }),
|
||||
serverId: "server-1",
|
||||
tabId: "tab-1",
|
||||
cwd: "/repo",
|
||||
});
|
||||
|
||||
expect(timeline?.streamItems).toEqual([]);
|
||||
expect(timeline?.turnPresentation.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it("shows no timeline before a prompt is sent, after it fails, or on another host", () => {
|
||||
const base = { serverId: "server-1", tabId: "tab-1", cwd: "/repo" };
|
||||
|
||||
expect(resolveDraftTimeline({ ...base, pending: undefined })).toBeNull();
|
||||
expect(
|
||||
resolveDraftTimeline({ ...base, pending: pendingAttempt({ lifecycle: "abandoned" }) }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveDraftTimeline({ ...base, pending: pendingAttempt({ serverId: "server-2" }) }),
|
||||
).toBeNull();
|
||||
expect(resolveDraftTimeline({ ...base, cwd: null, pending: pendingAttempt() })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useMemo } from "react";
|
||||
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
|
||||
import type { PendingMessageSubmission } from "@/composer/submission/model";
|
||||
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||
import { type PendingCreateAttempt, useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
resolveTurnPresentation,
|
||||
TURN_LIVENESS_IDLE,
|
||||
type TurnPresentation,
|
||||
} from "@/timeline/turn-liveness";
|
||||
import { createUserMessage, type StreamItem } from "@/types/stream";
|
||||
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
|
||||
import { resolveDraftWorkingDirectory } from "@/composer/draft/workspace-tab-core";
|
||||
|
||||
export const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
};
|
||||
|
||||
export interface DraftConversation {
|
||||
tabId: string;
|
||||
workspaceId: string;
|
||||
draftId: string;
|
||||
setup?: WorkspaceDraftTabSetup;
|
||||
}
|
||||
|
||||
/** The timeline a draft shows while its first prompt creates the agent. */
|
||||
export interface DraftTimeline {
|
||||
agent: AgentScreenAgent;
|
||||
streamItems: StreamItem[];
|
||||
pendingMessageSubmissions: readonly PendingMessageSubmission[];
|
||||
turnPresentation: TurnPresentation;
|
||||
}
|
||||
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
|
||||
/**
|
||||
* The sent prompt, pending, with the turn working. The created agent's timeline carries the same
|
||||
* row (same client message id), so the handoff only changes who owns it.
|
||||
*/
|
||||
export function resolveDraftTimeline(input: {
|
||||
pending: PendingCreateAttempt | undefined;
|
||||
serverId: string;
|
||||
tabId: string;
|
||||
cwd: string | null;
|
||||
}): DraftTimeline | null {
|
||||
const { pending, serverId, tabId, cwd } = input;
|
||||
if (!pending || pending.serverId !== serverId || pending.lifecycle === "abandoned" || !cwd) {
|
||||
return null;
|
||||
}
|
||||
const hasContent =
|
||||
pending.text.length > 0 ||
|
||||
(pending.images?.length ?? 0) > 0 ||
|
||||
(pending.attachments?.length ?? 0) > 0;
|
||||
return {
|
||||
agent: { serverId, id: tabId, status: "running", cwd, capabilities: DRAFT_CAPABILITIES },
|
||||
streamItems: hasContent
|
||||
? [
|
||||
createUserMessage({
|
||||
clientMessageId: pending.clientMessageId,
|
||||
text: pending.text,
|
||||
timestamp: new Date(pending.timestamp),
|
||||
images: pending.images,
|
||||
attachments: pending.attachments,
|
||||
}),
|
||||
]
|
||||
: EMPTY_STREAM_ITEMS,
|
||||
pendingMessageSubmissions: [{ clientMessageId: pending.clientMessageId }],
|
||||
turnPresentation: resolveTurnPresentation(TURN_LIVENESS_IDLE, true),
|
||||
};
|
||||
}
|
||||
|
||||
export function useDraftTimeline(input: {
|
||||
serverId: string;
|
||||
draft: DraftConversation | null;
|
||||
}): DraftTimeline | null {
|
||||
const { serverId, draft } = input;
|
||||
const pending = useCreateFlowStore((state) =>
|
||||
draft ? state.pendingByDraftId[draft.draftId] : undefined,
|
||||
);
|
||||
const workspaceDirectory = useWorkspaceFields(
|
||||
serverId,
|
||||
draft?.workspaceId ?? null,
|
||||
(workspace) => workspace.workspaceDirectory,
|
||||
);
|
||||
const cwd = draft
|
||||
? resolveDraftWorkingDirectory({
|
||||
workspaceDirectory: workspaceDirectory || null,
|
||||
initialSetup: draft.setup ?? null,
|
||||
})
|
||||
: null;
|
||||
const tabId = draft?.tabId ?? "";
|
||||
return useMemo(
|
||||
() => resolveDraftTimeline({ pending, serverId, tabId, cwd }),
|
||||
[cwd, pending, serverId, tabId],
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { resolveSubmissionReadiness } from "@/provider-selection/provider-selection";
|
||||
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
|
||||
|
||||
export interface WorkspaceDraftAutoSubmitConfig {
|
||||
provider: string;
|
||||
@@ -50,3 +51,13 @@ export function validateDraftSubmission(input: {
|
||||
});
|
||||
return readiness.ok ? null : (readiness.reason ?? null);
|
||||
}
|
||||
|
||||
export function resolveDraftWorkingDirectory(input: {
|
||||
workspaceDirectory: string | null;
|
||||
initialSetup: WorkspaceDraftTabSetup | null;
|
||||
}): string | null {
|
||||
if (input.initialSetup) {
|
||||
return input.initialSetup.cwd;
|
||||
}
|
||||
return input.workspaceDirectory;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native";
|
||||
import { Keyboard, StyleSheet as RNStyleSheet, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ComposerDock } from "@/composer/dock";
|
||||
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||
import invariant from "tiny-invariant";
|
||||
import { Composer } from "@/composer";
|
||||
import { useComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session";
|
||||
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||
import { type ComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session";
|
||||
import { ComposerImportPill } from "@/composer/draft/import-pill";
|
||||
import { COMPOSER_PILL_CLEARANCE } from "@/composer/pill-styles";
|
||||
import { AgentStreamView } from "@/agent-stream/view";
|
||||
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
|
||||
import { useAgentInputDraft } from "@/composer/draft/input-draft";
|
||||
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
||||
import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "@/composer/draft/create-flow";
|
||||
import { resolveTurnPresentation, TURN_LIVENESS_IDLE } from "@/timeline/turn-liveness";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
@@ -25,13 +21,13 @@ import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||
import {
|
||||
resolveDraftWorkingDirectory,
|
||||
shouldAllowEmptyDraftText,
|
||||
validateDraftSubmission,
|
||||
} from "@/composer/draft/workspace-tab-core";
|
||||
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
|
||||
import { DRAFT_CAPABILITIES } from "@/composer/draft/timeline";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
@@ -50,16 +46,6 @@ import {
|
||||
import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
|
||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
};
|
||||
|
||||
interface AutoSubmitConfig {
|
||||
provider: string;
|
||||
modeId: string | null;
|
||||
@@ -260,25 +246,15 @@ function buildDraftInitialValues(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDraftWorkingDirectory(input: {
|
||||
workspaceDirectory: string | null;
|
||||
initialSetup: WorkspaceDraftTabSetup | null;
|
||||
}): string | null {
|
||||
if (input.initialSetup) {
|
||||
return input.initialSetup.cwd;
|
||||
}
|
||||
return input.workspaceDirectory;
|
||||
}
|
||||
|
||||
interface WorkspaceDraftAgentTabProps {
|
||||
interface WorkspaceDraftComposerProps {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
tabId: string;
|
||||
draftId: string;
|
||||
initialSetup?: WorkspaceDraftTabSetup;
|
||||
isPaneFocused: boolean;
|
||||
editingSession: ComposerEditingSession;
|
||||
onCreated: (snapshot: AgentSnapshotPayload) => void;
|
||||
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => void;
|
||||
onOpenImportSheet?: () => void;
|
||||
}
|
||||
|
||||
@@ -292,17 +268,21 @@ function resolveImportPillPress(
|
||||
return onOpenImportSheet ?? null;
|
||||
}
|
||||
|
||||
export function WorkspaceDraftAgentTab({
|
||||
/**
|
||||
* Creates the agent from the draft's first prompt. The conversation panel shows that prompt in
|
||||
* the timeline from the create-flow store while the agent is created.
|
||||
*/
|
||||
export function WorkspaceDraftComposer({
|
||||
serverId,
|
||||
workspaceId,
|
||||
tabId,
|
||||
draftId,
|
||||
initialSetup = undefined,
|
||||
isPaneFocused,
|
||||
editingSession,
|
||||
onCreated,
|
||||
onOpenWorkspaceFile,
|
||||
onOpenImportSheet,
|
||||
}: WorkspaceDraftAgentTabProps) {
|
||||
}: WorkspaceDraftComposerProps) {
|
||||
const { t } = useTranslation();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
@@ -387,7 +367,6 @@ export function WorkspaceDraftAgentTab({
|
||||
}, [pendingCreateAttempt]);
|
||||
const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true;
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const editingSession = useComposerEditingSession("chat");
|
||||
const isComposerFullscreen = useComposerFullscreen(editingSession);
|
||||
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
|
||||
COMPACT_CONTENT_WIDTH,
|
||||
@@ -421,82 +400,71 @@ export function WorkspaceDraftAgentTab({
|
||||
[isCompactFormFactor, openInSidePane, serverId, workspaceId],
|
||||
);
|
||||
|
||||
const {
|
||||
formErrorMessage,
|
||||
isSubmitting,
|
||||
submittedStreamItems,
|
||||
pendingMessageSubmissions,
|
||||
draftAgent,
|
||||
handleCreateFromInput,
|
||||
continueCreateFromAttempt,
|
||||
} = useDraftAgentCreateFlow<Agent, AgentSnapshotPayload>({
|
||||
draftId,
|
||||
getPendingServerId: () => serverId,
|
||||
initialAttempt: initialCreateAttempt,
|
||||
allowEmptyText: allowsEmptyAutoSubmit,
|
||||
validateBeforeSubmit: ({ text, attachments }) => {
|
||||
const allowsEmptyDraftText = shouldAllowEmptyDraftText({
|
||||
allowsEmptyAutoSubmit,
|
||||
attachments,
|
||||
});
|
||||
return validateDraftSubmission({
|
||||
text,
|
||||
allowsEmptyAutoSubmit: allowsEmptyDraftText,
|
||||
composerState,
|
||||
autoSubmitConfig,
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
isConnected,
|
||||
});
|
||||
},
|
||||
onBeforeSubmit: async () => {
|
||||
await composerState.persistFormPreferences();
|
||||
if (isWeb) {
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
}
|
||||
Keyboard.dismiss();
|
||||
},
|
||||
buildDraftAgent: (attempt) =>
|
||||
buildDraftAgentSnapshot({
|
||||
attempt,
|
||||
serverId,
|
||||
tabId,
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
autoSubmitConfig,
|
||||
composerState,
|
||||
selectModelMessage: t("workspaceSetup.errors.selectModel"),
|
||||
}),
|
||||
createRequest: async ({ attempt, text, images, attachments, cwd }) => {
|
||||
if (pendingAutoSubmit?.agentCreation) {
|
||||
const result = await pendingAutoSubmit.agentCreation.result;
|
||||
return { agentId: result.id, result };
|
||||
}
|
||||
return submitDraftCreateRequest({
|
||||
draftId,
|
||||
attempt,
|
||||
text,
|
||||
images,
|
||||
attachments,
|
||||
cwd,
|
||||
client,
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
workspaceId: workspaceFields?.id ?? null,
|
||||
autoSubmitConfig,
|
||||
composerState,
|
||||
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
||||
selectModelMessage: t("workspaceSetup.errors.selectModel"),
|
||||
});
|
||||
},
|
||||
onCreateSuccess: ({ result }) => {
|
||||
clearDraftInput("sent");
|
||||
clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey });
|
||||
useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId });
|
||||
onCreated(result);
|
||||
},
|
||||
});
|
||||
const turnPresentation = useMemo(
|
||||
() => resolveTurnPresentation(TURN_LIVENESS_IDLE, pendingMessageSubmissions.length > 0),
|
||||
[pendingMessageSubmissions],
|
||||
);
|
||||
const { formErrorMessage, isSubmitting, handleCreateFromInput, continueCreateFromAttempt } =
|
||||
useDraftAgentCreateFlow<Agent, AgentSnapshotPayload>({
|
||||
draftId,
|
||||
getPendingServerId: () => serverId,
|
||||
initialAttempt: initialCreateAttempt,
|
||||
allowEmptyText: allowsEmptyAutoSubmit,
|
||||
validateBeforeSubmit: ({ text, attachments }) => {
|
||||
const allowsEmptyDraftText = shouldAllowEmptyDraftText({
|
||||
allowsEmptyAutoSubmit,
|
||||
attachments,
|
||||
});
|
||||
return validateDraftSubmission({
|
||||
text,
|
||||
allowsEmptyAutoSubmit: allowsEmptyDraftText,
|
||||
composerState,
|
||||
autoSubmitConfig,
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
isConnected,
|
||||
});
|
||||
},
|
||||
onBeforeSubmit: async () => {
|
||||
await composerState.persistFormPreferences();
|
||||
if (isWeb) {
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
}
|
||||
Keyboard.dismiss();
|
||||
},
|
||||
buildDraftAgent: (attempt) =>
|
||||
buildDraftAgentSnapshot({
|
||||
attempt,
|
||||
serverId,
|
||||
tabId,
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
autoSubmitConfig,
|
||||
composerState,
|
||||
selectModelMessage: t("workspaceSetup.errors.selectModel"),
|
||||
}),
|
||||
createRequest: async ({ attempt, text, images, attachments, cwd }) => {
|
||||
if (pendingAutoSubmit?.agentCreation) {
|
||||
const result = await pendingAutoSubmit.agentCreation.result;
|
||||
return { agentId: result.id, result };
|
||||
}
|
||||
return submitDraftCreateRequest({
|
||||
draftId,
|
||||
attempt,
|
||||
text,
|
||||
images,
|
||||
attachments,
|
||||
cwd,
|
||||
client,
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
workspaceId: workspaceFields?.id ?? null,
|
||||
autoSubmitConfig,
|
||||
composerState,
|
||||
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
||||
selectModelMessage: t("workspaceSetup.errors.selectModel"),
|
||||
});
|
||||
},
|
||||
onCreateSuccess: ({ result }) => {
|
||||
clearDraftInput("sent");
|
||||
clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey });
|
||||
useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId });
|
||||
onCreated(result);
|
||||
},
|
||||
});
|
||||
useAgentControlCommandCenterActions({
|
||||
sourceId: `draft:${serverId}:${tabId}`,
|
||||
enabled: isPaneFocused && !isSubmitting,
|
||||
@@ -600,75 +568,48 @@ export function WorkspaceDraftAgentTab({
|
||||
}),
|
||||
[composerState.agentControls, handleDropdownCloseFocus, isSubmitting],
|
||||
);
|
||||
const dockContent = (
|
||||
<View style={styles.contentContainer}>
|
||||
{isSubmitting && draftAgent ? (
|
||||
<View style={styles.streamContainer}>
|
||||
<AgentStreamView
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
context={draftAgent}
|
||||
streamItems={submittedStreamItems}
|
||||
pendingMessageSubmissions={pendingMessageSubmissions}
|
||||
turnPresentation={turnPresentation}
|
||||
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={styles.configScrollContent}>
|
||||
<View style={styles.configSection}>
|
||||
{formErrorMessage ? (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{formErrorMessage}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<FileDropZone style={styles.container}>
|
||||
<ComposerDock fullscreen={isComposerFullscreen}>
|
||||
{dockContent}
|
||||
<View style={animatedStaticStyles.inputAreaWrapper} onLayout={onInputAreaLayout}>
|
||||
{importPillPress && !isComposerFullscreen ? (
|
||||
<View style={styles.importPillRow}>
|
||||
<View style={styles.importPillContent}>
|
||||
<ComposerImportPill onPress={importPillPress} />
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
<Composer
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
onSubmitMessage={handleCreateFromInput}
|
||||
isSubmitLoading={isSubmitting}
|
||||
blurOnSubmit={true}
|
||||
textSource={draftInput.textSource}
|
||||
onChangeText={draftInput.editText}
|
||||
textReplacement={draftInput.textReplacement}
|
||||
attachments={draftInput.attachments}
|
||||
attachmentScopeKeys={attachmentScopeKeys}
|
||||
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||
onChangeAttachments={draftInput.setAttachments}
|
||||
cwd={composerState.workingDir}
|
||||
clearDraft={draftInput.clear}
|
||||
editingSession={editingSession}
|
||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
|
||||
onFocusInput={handleFocusInputCallback}
|
||||
commandDraft={composerState.commandDraft}
|
||||
agentControls={composerAgentControls}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
<View style={animatedStaticStyles.inputAreaWrapper} onLayout={onInputAreaLayout}>
|
||||
{formErrorMessage ? (
|
||||
<View style={styles.formErrorRow}>
|
||||
<View style={styles.formError}>
|
||||
<Text style={styles.errorText}>{formErrorMessage}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ComposerDock>
|
||||
</FileDropZone>
|
||||
) : null}
|
||||
{importPillPress && !isComposerFullscreen ? (
|
||||
<View style={styles.importPillRow}>
|
||||
<View style={styles.importPillContent}>
|
||||
<ComposerImportPill onPress={importPillPress} />
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
<Composer
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
onSubmitMessage={handleCreateFromInput}
|
||||
isSubmitLoading={isSubmitting}
|
||||
blurOnSubmit={true}
|
||||
textSource={draftInput.textSource}
|
||||
onChangeText={draftInput.editText}
|
||||
textReplacement={draftInput.textReplacement}
|
||||
attachments={draftInput.attachments}
|
||||
attachmentScopeKeys={attachmentScopeKeys}
|
||||
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||
onChangeAttachments={draftInput.setAttachments}
|
||||
cwd={composerState.workingDir}
|
||||
clearDraft={draftInput.clear}
|
||||
editingSession={editingSession}
|
||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
|
||||
onFocusInput={handleFocusInputCallback}
|
||||
commandDraft={composerState.commandDraft}
|
||||
agentControls={composerAgentControls}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -682,27 +623,10 @@ const animatedStaticStyles = RNStyleSheet.create({
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
formErrorRow: {
|
||||
width: "100%",
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
streamContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
configScrollContent: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingTop: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[6],
|
||||
},
|
||||
configSection: {
|
||||
gap: theme.spacing[3],
|
||||
alignItems: "center",
|
||||
},
|
||||
importPillRow: {
|
||||
width: "100%",
|
||||
@@ -722,8 +646,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
maxWidth: theme.contentMaxWidth,
|
||||
flexDirection: "row",
|
||||
},
|
||||
errorContainer: {
|
||||
marginTop: theme.spacing[2],
|
||||
formError: {
|
||||
width: "100%",
|
||||
maxWidth: theme.contentMaxWidth,
|
||||
marginBottom: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
|
||||
@@ -78,7 +78,8 @@ import {
|
||||
deriveRouteBottomAnchorIntent,
|
||||
deriveRouteBottomAnchorRequest,
|
||||
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
|
||||
import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab";
|
||||
import { WorkspaceDraftComposer } from "@/composer/draft/workspace-tab";
|
||||
import { type DraftConversation, useDraftTimeline } from "@/composer/draft/timeline";
|
||||
import { AgentTracks } from "@/panels/agent-tracks";
|
||||
import { ParentAgentCallout, useParentAgentId } from "@/subagents/parent";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
@@ -90,14 +91,13 @@ import {
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { buildWorkspaceTabPersistenceKey, type WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { StreamItem, TodoEntry } from "@/types/stream";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { ViewedTimelineStatus, ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync";
|
||||
import { useArchiveFinishedSubagents, useSubagentsForParent } from "@/subagents";
|
||||
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
||||
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
|
||||
@@ -225,6 +225,29 @@ function AgentLoadErrorView({ message, onRetry }: { message: string; onRetry: ()
|
||||
);
|
||||
}
|
||||
|
||||
/** A draft has no agent to wait for; an agent shows its loading and error states until ready. */
|
||||
function renderConversationNonReadyView(args: {
|
||||
draft: DraftConversation | null;
|
||||
agentId: string | undefined;
|
||||
cwd: string | null;
|
||||
viewState: AgentScreenViewState;
|
||||
effectiveAgent: AgentScreenAgent | null;
|
||||
t: TFunction;
|
||||
onRetryLoad: () => void;
|
||||
}): React.ReactElement | null {
|
||||
if (args.draft) {
|
||||
return null;
|
||||
}
|
||||
const nonReadyView = renderChatAgentNonReadyView(args);
|
||||
if (nonReadyView) {
|
||||
return nonReadyView;
|
||||
}
|
||||
invariant(args.agentId, "agent id is defined when agent content is ready");
|
||||
invariant(args.effectiveAgent, "effectiveAgent is defined when the non-ready view is absent");
|
||||
invariant(args.cwd, "agent cwd is defined when agent content is ready");
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderChatAgentNonReadyView(args: {
|
||||
viewState: AgentScreenViewState;
|
||||
effectiveAgent: AgentScreenAgent | null;
|
||||
@@ -303,17 +326,31 @@ function storeFetchedAgentDetail(input: {
|
||||
return hydrated;
|
||||
}
|
||||
|
||||
function AgentPanel() {
|
||||
type ConversationTarget = Extract<WorkspaceTabTarget, { kind: "agent" } | { kind: "draft" }>;
|
||||
|
||||
/** The agent a conversation shows; a draft has none until its first prompt creates one. */
|
||||
function resolveConversationAgentId(target: ConversationTarget): string | undefined {
|
||||
return target.kind === "agent" ? target.agentId.trim() || undefined : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drafts and agents render through one chain so the timeline stays mounted when the draft's
|
||||
* first prompt creates the agent and the tab retargets. The agent gates are idle for a draft.
|
||||
*/
|
||||
export function AgentConversationPanel() {
|
||||
const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext();
|
||||
const { isInteractive } = usePaneFocus();
|
||||
invariant(target.kind === "agent", "AgentPanel requires agent target");
|
||||
invariant(
|
||||
target.kind === "agent" || target.kind === "draft",
|
||||
"AgentConversationPanel requires an agent or draft target",
|
||||
);
|
||||
|
||||
return (
|
||||
<RetainedChatContent>
|
||||
<AgentPanelContent
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
agentId={target.agentId}
|
||||
target={target}
|
||||
isPaneFocused={isInteractive}
|
||||
onOpenWorkspaceFile={openFileInWorkspace}
|
||||
/>
|
||||
@@ -321,55 +358,6 @@ function AgentPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
function DraftPanel() {
|
||||
const {
|
||||
serverId,
|
||||
workspaceId,
|
||||
tabId,
|
||||
target,
|
||||
openFileInWorkspace,
|
||||
openImportSheet,
|
||||
retargetCurrentTab,
|
||||
} = usePaneContext();
|
||||
const { isInteractive } = usePaneFocus();
|
||||
invariant(target.kind === "draft", "DraftPanel requires draft target");
|
||||
|
||||
const handleCreated = useCallback(
|
||||
(agentSnapshot: Parameters<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,
|
||||
@@ -444,18 +432,18 @@ type AgentLookupState =
|
||||
function AgentPanelContent({
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
target,
|
||||
isPaneFocused,
|
||||
onOpenWorkspaceFile,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
target: ConversationTarget;
|
||||
isPaneFocused: boolean;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedAgentId = agentId.trim() || undefined;
|
||||
const resolvedAgentId = resolveConversationAgentId(target);
|
||||
const resolvedServerId = serverId.trim() || undefined;
|
||||
const daemons = useHosts();
|
||||
const runtimeServerId = resolvedServerId ?? "";
|
||||
@@ -484,7 +472,7 @@ function AgentPanelContent({
|
||||
: runtimeConnectionStatus;
|
||||
const lastConnectionError = runtimeLastError;
|
||||
|
||||
if (!resolvedServerId || (!runtimeClient && !hasCachedAgent)) {
|
||||
if (target.kind === "agent" && (!resolvedServerId || (!runtimeClient && !hasCachedAgent))) {
|
||||
return (
|
||||
<AgentSessionUnavailableState
|
||||
serverLabel={serverLabel}
|
||||
@@ -498,9 +486,9 @@ function AgentPanelContent({
|
||||
|
||||
return (
|
||||
<AgentPanelBody
|
||||
serverId={resolvedServerId}
|
||||
serverId={resolvedServerId ?? serverId}
|
||||
workspaceId={workspaceId}
|
||||
agentId={resolvedAgentId}
|
||||
target={target}
|
||||
isPaneFocused={isPaneFocused}
|
||||
client={runtimeClient}
|
||||
isConnected={runtimeIsConnected}
|
||||
@@ -512,7 +500,7 @@ function AgentPanelContent({
|
||||
function AgentPanelBody({
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
target,
|
||||
isPaneFocused,
|
||||
client,
|
||||
isConnected,
|
||||
@@ -520,13 +508,14 @@ function AgentPanelBody({
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId?: string;
|
||||
target: ConversationTarget;
|
||||
isPaneFocused: boolean;
|
||||
client: ReturnType<typeof useHostRuntimeClient>;
|
||||
isConnected: boolean;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const agentId = resolveConversationAgentId(target);
|
||||
const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent();
|
||||
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
||||
const projectPlacement = useStoreWithEqualityFn(
|
||||
@@ -641,7 +630,7 @@ function AgentPanelBody({
|
||||
}
|
||||
: null;
|
||||
|
||||
if (!agent) {
|
||||
if (target.kind === "agent" && !agent) {
|
||||
return (
|
||||
<View style={styles.container} testID="agent-loading">
|
||||
<View style={styles.errorContainer}>
|
||||
@@ -655,7 +644,7 @@ function AgentPanelBody({
|
||||
<ChatAgentContent
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
agentId={agentId}
|
||||
target={target}
|
||||
isPaneFocused={isPaneFocused}
|
||||
client={client}
|
||||
isConnected={isConnected}
|
||||
@@ -667,7 +656,7 @@ function AgentPanelBody({
|
||||
function ChatAgentContent({
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
target,
|
||||
isPaneFocused,
|
||||
client,
|
||||
isConnected,
|
||||
@@ -675,13 +664,15 @@ function ChatAgentContent({
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId?: string;
|
||||
target: ConversationTarget;
|
||||
isPaneFocused: boolean;
|
||||
client: ReturnType<typeof useHostRuntimeClient>;
|
||||
isConnected: boolean;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { tabId } = usePaneContext();
|
||||
const agentId = resolveConversationAgentId(target);
|
||||
const isPaneVisible = useRetainedPanelActive();
|
||||
const { api: toastApi, toast: toastState, dismiss: dismissToast } = useToastHost();
|
||||
const { isArchivingAgent } = useArchiveAgent();
|
||||
@@ -988,17 +979,23 @@ function ChatAgentContent({
|
||||
visibilityCatchUpStatus,
|
||||
]);
|
||||
|
||||
const nonReadyView = renderChatAgentNonReadyView({
|
||||
const draft = useMemo<DraftConversation | null>(
|
||||
() =>
|
||||
target.kind === "draft"
|
||||
? { tabId, workspaceId, draftId: target.draftId, setup: target.setup }
|
||||
: null,
|
||||
[tabId, target, workspaceId],
|
||||
);
|
||||
const nonReadyView = renderConversationNonReadyView({
|
||||
draft,
|
||||
agentId,
|
||||
cwd: agentState.cwd,
|
||||
viewState,
|
||||
effectiveAgent,
|
||||
t,
|
||||
onRetryLoad: retryAgentLoad,
|
||||
});
|
||||
if (nonReadyView) return nonReadyView;
|
||||
invariant(agentId, "agent id is defined when agent content is ready");
|
||||
invariant(effectiveAgent, "effectiveAgent is defined when the non-ready view is absent");
|
||||
const agentCwd = agentState.cwd;
|
||||
invariant(agentCwd, "agent cwd is defined when agent content is ready");
|
||||
const showHistorySyncOverlay =
|
||||
viewState.tag === "ready" &&
|
||||
viewState.sync.status === "catching_up" &&
|
||||
@@ -1013,7 +1010,8 @@ function ChatAgentContent({
|
||||
<ChatAgentReadyContent
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
agentId={agentId}
|
||||
agentId={agentId ?? tabId}
|
||||
draft={draft}
|
||||
isPaneFocused={isPaneFocused}
|
||||
isArchivingCurrentAgent={isArchivingCurrentAgent}
|
||||
agentState={agentState}
|
||||
@@ -1031,7 +1029,7 @@ function ChatAgentContent({
|
||||
showHistorySyncOverlay={showHistorySyncOverlay}
|
||||
showHistorySyncError={showHistorySyncError}
|
||||
isRetryingHistorySync={isRetryingHistorySync}
|
||||
cwd={agentCwd}
|
||||
cwd={agentState.cwd ?? ""}
|
||||
retryTimelineSync={retryTimelineSync}
|
||||
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
||||
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
||||
@@ -1044,6 +1042,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
draft,
|
||||
isPaneFocused,
|
||||
isArchivingCurrentAgent,
|
||||
agentState,
|
||||
@@ -1069,11 +1068,13 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
/** The agent's id, or the tab id while a draft has no agent yet. */
|
||||
agentId: string;
|
||||
draft: DraftConversation | null;
|
||||
isPaneFocused: boolean;
|
||||
isArchivingCurrentAgent: boolean;
|
||||
agentState: ChatAgentSelectedState;
|
||||
effectiveAgent: AgentScreenAgent;
|
||||
effectiveAgent: AgentScreenAgent | null;
|
||||
routeBottomAnchorRequest: RouteBottomAnchorRequest;
|
||||
hasAppliedAuthoritativeHistory: boolean;
|
||||
toastApi: ToastApi;
|
||||
@@ -1095,19 +1096,11 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// The workspace sheet is for looking; Open as tab brings the agent back with its composer.
|
||||
const showsComposer = usePaneContext().host !== "sheet";
|
||||
const paneHost = usePaneContext().host;
|
||||
const showsComposer = draft !== null || paneHost !== "sheet";
|
||||
// The dock lays out fullscreen for the same session the composer edits in.
|
||||
const composerEditingSession = useComposerEditingSession("chat");
|
||||
const isComposerFullscreen = useComposerFullscreen(composerEditingSession);
|
||||
const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
|
||||
const tasks = useSessionStore((state): TodoEntry[] | undefined =>
|
||||
state.sessions[serverId]?.agentTasks.get(agentId),
|
||||
);
|
||||
const archiveFinishedSubagents = useArchiveFinishedSubagents({
|
||||
serverId,
|
||||
parentAgentId: agentId,
|
||||
rows: subagentRows,
|
||||
});
|
||||
const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
|
||||
const parentAgentId = useParentAgentId(serverId, agentId);
|
||||
// With a composer the parent is a pill above it; without one, the box takes the composer's place.
|
||||
@@ -1161,7 +1154,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
const parentCallout = parentAgentId ? (
|
||||
<ParentAgentCallout serverId={serverId} parentAgentId={parentAgentId} />
|
||||
) : null;
|
||||
const composerSection = showsComposer ? (
|
||||
const agentComposerSection = showsComposer ? (
|
||||
<RenderProfile id={`AgentComposerSection:${agentId}`}>
|
||||
<AgentComposerSection
|
||||
agentId={agentId}
|
||||
@@ -1182,6 +1175,16 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
) : (
|
||||
parentCallout
|
||||
);
|
||||
const composerSection = draft ? (
|
||||
<DraftComposerSection
|
||||
serverId={serverId}
|
||||
draft={draft}
|
||||
isPaneFocused={isPaneFocused}
|
||||
editingSession={composerEditingSession}
|
||||
/>
|
||||
) : (
|
||||
agentComposerSection
|
||||
);
|
||||
const streamContent = (
|
||||
<View style={animatedStaticStyles.content}>
|
||||
{showsComposer ? null : <SheetAgentFacts serverId={serverId} agentId={agentId} />}
|
||||
@@ -1191,6 +1194,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
agent={effectiveAgent}
|
||||
draft={draft}
|
||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
||||
hasActiveComposer={hasActiveComposer}
|
||||
@@ -1198,16 +1202,12 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
</RenderProfile>
|
||||
{hasActiveComposer ? (
|
||||
{hasActiveComposer && !draft ? (
|
||||
<AgentTracks
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
agentId={agentId}
|
||||
parentAgentId={parentPillAgentId}
|
||||
subagentRows={subagentRows}
|
||||
tasks={tasks}
|
||||
archiveFinishedStatus={archiveFinishedSubagents.status}
|
||||
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -1263,7 +1263,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
</RewindComposerRestoreProvider>
|
||||
);
|
||||
return wrapAgentCommandProvider({
|
||||
enabled: showsComposer && hasActiveComposer,
|
||||
enabled: showsComposer && hasActiveComposer && !draft,
|
||||
serverId,
|
||||
agentId,
|
||||
editingSession: composerEditingSession,
|
||||
@@ -1355,6 +1355,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
serverId,
|
||||
agentId,
|
||||
agent,
|
||||
draft,
|
||||
routeBottomAnchorRequest,
|
||||
hasAppliedAuthoritativeHistory,
|
||||
hasActiveComposer,
|
||||
@@ -1364,7 +1365,8 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
|
||||
serverId: string;
|
||||
agentId?: string;
|
||||
agent: AgentScreenAgent;
|
||||
agent: AgentScreenAgent | null;
|
||||
draft: DraftConversation | null;
|
||||
routeBottomAnchorRequest: RouteBottomAnchorRequest;
|
||||
hasAppliedAuthoritativeHistory: boolean;
|
||||
hasActiveComposer: boolean;
|
||||
@@ -1422,7 +1424,31 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
}
|
||||
return new Map(pendingPermissionList.map((permission) => [permission.key, permission]));
|
||||
}, [pendingPermissionList]);
|
||||
// A draft's timeline exists only while its first prompt creates the agent. The created agent's
|
||||
// timeline then replaces this input in the same mounted view.
|
||||
const draftTimeline = useDraftTimeline({ serverId, draft });
|
||||
|
||||
if (draftTimeline) {
|
||||
return (
|
||||
<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}
|
||||
@@ -1443,6 +1469,44 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
);
|
||||
});
|
||||
|
||||
/** The draft's composer, which creates the agent and retargets this tab to it. */
|
||||
function DraftComposerSection({
|
||||
serverId,
|
||||
draft,
|
||||
isPaneFocused,
|
||||
editingSession,
|
||||
}: {
|
||||
serverId: string;
|
||||
draft: DraftConversation;
|
||||
isPaneFocused: boolean;
|
||||
editingSession: ComposerEditingSession;
|
||||
}) {
|
||||
const { openImportSheet, retargetCurrentTab } = usePaneContext();
|
||||
const handleCreated = useCallback(
|
||||
(agentSnapshot: Parameters<typeof normalizeAgentSnapshot>[0]) => {
|
||||
const normalized = normalizeAgentSnapshot(agentSnapshot, serverId);
|
||||
const agent = applyLegacyDaemonWorkspaceOwnership({ serverId, agent: normalized });
|
||||
getHostRuntimeStore().acceptAgentSnapshot(serverId, agent);
|
||||
retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id });
|
||||
},
|
||||
[retargetCurrentTab, serverId],
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkspaceDraftComposer
|
||||
serverId={serverId}
|
||||
workspaceId={draft.workspaceId}
|
||||
tabId={draft.tabId}
|
||||
draftId={draft.draftId}
|
||||
initialSetup={draft.setup}
|
||||
isPaneFocused={isPaneFocused}
|
||||
editingSession={editingSession}
|
||||
onCreated={handleCreated}
|
||||
onOpenImportSheet={openImportSheet}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const AgentComposerSection = memo(function AgentComposerSection({
|
||||
agentId,
|
||||
serverId,
|
||||
|
||||
@@ -9,11 +9,11 @@ import { useSettings } from "@/hooks/use-settings";
|
||||
import { PluginComposerPills } from "@/plugins";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
type ArchiveFinishedStatus,
|
||||
useArchiveFinishedSubagents,
|
||||
useArchiveSubagent,
|
||||
useDetachSubagent,
|
||||
useSubagentSheetTab,
|
||||
type SubagentRow,
|
||||
useSubagentsForParent,
|
||||
} from "@/subagents";
|
||||
import { ParentAgentPill } from "@/subagents/parent";
|
||||
import { SubagentsTrack } from "@/subagents/track";
|
||||
@@ -35,20 +35,21 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
workspaceId,
|
||||
agentId,
|
||||
parentAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
onArchiveFinished,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
parentAgentId: string | null;
|
||||
subagentRows: SubagentRow[];
|
||||
tasks: TodoEntry[] | undefined;
|
||||
archiveFinishedStatus: ArchiveFinishedStatus;
|
||||
onArchiveFinished: () => void;
|
||||
}): ReactElement {
|
||||
const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
|
||||
const tasks = useSessionStore((state): TodoEntry[] | undefined =>
|
||||
state.sessions[serverId]?.agentTasks.get(agentId),
|
||||
);
|
||||
const archiveFinishedSubagents = useArchiveFinishedSubagents({
|
||||
serverId,
|
||||
parentAgentId: agentId,
|
||||
rows: subagentRows,
|
||||
});
|
||||
const { tabId } = usePaneContext();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const openInSidePane = useSettings((settings) => settings.openInSidePane);
|
||||
@@ -119,8 +120,8 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
onOpenProviderSubagent={handleOpenProviderSubagent}
|
||||
sheetTabFor={sheetTabFor}
|
||||
onArchiveSubagent={archiveSubagent}
|
||||
onArchiveFinished={onArchiveFinished}
|
||||
archiveFinishedStatus={archiveFinishedStatus}
|
||||
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
|
||||
archiveFinishedStatus={archiveFinishedSubagents.status}
|
||||
onDetachSubagent={canDetachSubagents ? detachSubagent : undefined}
|
||||
/>
|
||||
<PluginComposerPills
|
||||
|
||||
@@ -390,4 +390,39 @@ describe("AgentDirectoryReplica", () => {
|
||||
expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(true);
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
it("keeps unchanged agent fields when an update changes only the status", () => {
|
||||
const serverId = "agent-replica-unchanged-fields";
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||
const replica = new AgentDirectoryReplica(
|
||||
serverId,
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
const agentInStore = () => useSessionStore.getState().sessions[serverId]?.agents.get("agent");
|
||||
replica.commitSnapshot([entry(payload("agent"))], []);
|
||||
const before = agentInStore();
|
||||
|
||||
replica.applyDelta({
|
||||
kind: "upsert",
|
||||
agent: { ...payload("agent"), status: "running", updatedAt: "2026-07-17T00:02:00.000Z" },
|
||||
project: entry(payload("agent")).project,
|
||||
});
|
||||
const afterDelta = agentInStore();
|
||||
replica.commitSnapshot(
|
||||
[entry({ ...payload("agent"), status: "idle", updatedAt: "2026-07-17T00:03:00.000Z" })],
|
||||
[],
|
||||
);
|
||||
const afterSnapshot = agentInStore();
|
||||
|
||||
expect(afterDelta?.status).toBe("running");
|
||||
expect(afterSnapshot?.status).toBe("idle");
|
||||
for (const agent of [afterDelta, afterSnapshot]) {
|
||||
expect(agent?.capabilities).toBe(before?.capabilities);
|
||||
expect(agent?.availableModes).toBe(before?.availableModes);
|
||||
expect(agent?.labels).toBe(before?.labels);
|
||||
expect(agent?.projectPlacement).toBe(before?.projectPlacement);
|
||||
}
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,26 @@ function withoutAgent(agentId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every update arrives as a whole new agent, so each nested value (capabilities, modes, labels) is
|
||||
* a new object even when it did not change. Keep the stored value for every field that is equal,
|
||||
* and the stored agent when nothing changed, so a reader of one field sees a new value only when
|
||||
* that field changes.
|
||||
*/
|
||||
function keepUnchangedFields(stored: Agent, incoming: Agent): Agent {
|
||||
const next = { ...incoming };
|
||||
let changed = Object.keys(stored).length !== Object.keys(next).length;
|
||||
for (const key of Object.keys(next) as (keyof Agent)[]) {
|
||||
if (next[key] === stored[key]) continue;
|
||||
if (key in stored && equal(next[key], stored[key])) {
|
||||
Object.assign(next, { [key]: stored[key] });
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : stored;
|
||||
}
|
||||
|
||||
function mergeSnapshotTurn(previous: Agent | undefined, incoming: Agent): Agent {
|
||||
if (!previous) return incoming;
|
||||
const activeTurn =
|
||||
@@ -59,7 +79,8 @@ export class AgentStoreProjection {
|
||||
let accepted = agent;
|
||||
useSessionStore.getState().setAgents(this.serverId, (current) => {
|
||||
const previous = current.get(agent.id);
|
||||
accepted = acceptAgentDirectoryUpdate(previous, mergeSnapshotTurn(previous, agent));
|
||||
const winner = acceptAgentDirectoryUpdate(previous, mergeSnapshotTurn(previous, agent));
|
||||
accepted = previous ? keepUnchangedFields(previous, winner) : winner;
|
||||
if (accepted === previous) return current;
|
||||
const next = new Map(current);
|
||||
next.set(agent.id, accepted);
|
||||
@@ -117,7 +138,7 @@ export class AgentStoreProjection {
|
||||
for (const [agentId, fetchedAgent] of fetched) {
|
||||
const existing = current.get(agentId);
|
||||
const merged = mergeSnapshotTurn(existing, fetchedAgent);
|
||||
agents.set(agentId, existing && equal(existing, merged) ? existing : merged);
|
||||
agents.set(agentId, existing ? keepUnchangedFields(existing, merged) : merged);
|
||||
if (fetchedAgent.archivedAt) {
|
||||
clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId });
|
||||
}
|
||||
|
||||
@@ -870,8 +870,12 @@ function WorkspaceDocumentTitleEffectSlot({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The header floats over a conversation's timeline. A draft and the agent it creates share one
|
||||
* timeline, so their surfaces match and the timeline does not move when the tab retargets.
|
||||
*/
|
||||
function floatsHeaderOverContent(activeTab: WorkspaceTabDescriptor | null): boolean {
|
||||
return supportsStreamTopOverlay && activeTab?.kind === "agent";
|
||||
return supportsStreamTopOverlay && (activeTab?.kind === "agent" || activeTab?.kind === "draft");
|
||||
}
|
||||
|
||||
/** A fullscreen composer covers the compact header; the column lays out without it. */
|
||||
|
||||
@@ -126,6 +126,23 @@ describe("agent directory reconciliation", () => {
|
||||
}).toEqual({ title: "newer page", status: "running", projectName: "repo" });
|
||||
});
|
||||
|
||||
it("takes the project of a newer buffered upsert", () => {
|
||||
const snapshotEntry = entry("agent", "idle");
|
||||
const newerProject = { ...snapshotEntry.project, projectName: "renamed project" };
|
||||
const result = reconcileAgentDirectory({
|
||||
snapshot: [snapshotEntry],
|
||||
deltas: [
|
||||
{
|
||||
kind: "upsert",
|
||||
agent: { ...snapshot("agent", "idle"), updatedAt: "2026-07-12T11:00:00.000Z" },
|
||||
project: newerProject,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result[0]?.project.projectName).toBe("renamed project");
|
||||
});
|
||||
|
||||
it("clears a snapshot stop when a newer buffered upsert is running", () => {
|
||||
const result = reconcileAgentDirectory({
|
||||
snapshot: [entry("agent", "idle")],
|
||||
|
||||
@@ -270,6 +270,16 @@ describe.skipIf(isPlatform("win32"))("terminal worker reaper", () => {
|
||||
await reapTerminalWorkers({ paseoHome: home, inspector, gracefulMs: 0 });
|
||||
|
||||
expect(isAlive(unrelated.pid!)).toBe(true);
|
||||
// Completion means the recorded process is gone, even if its PID is occupied.
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
!isAlive(original.pid) ||
|
||||
classifyTerminalProgramProcess(original, await inspector.inspect(original.pid)).status ===
|
||||
"unrelated",
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toBe(true);
|
||||
expect(existsSync(recordPath)).toBe(false);
|
||||
expect(
|
||||
classifyTerminalProgramProcess(original, await inspector.inspect(original.pid)),
|
||||
|
||||
@@ -867,7 +867,18 @@ it.each([false, true])(
|
||||
writeFileSync(exitRequestPath, "");
|
||||
await waitForCondition(() => first.getTerminal(ending.id) === undefined, 10000);
|
||||
expect(existsSync(exitObservedPath)).toBe(true);
|
||||
expect(isProcessAlive(endingPid)).toBe(false);
|
||||
const originalEnding = record!.terminals.find((entry) => entry.id === ending.id)!;
|
||||
// Exit delivery does not reserve the PID. Wait for the original process to
|
||||
// disappear, allowing Windows to reuse its PID just as in the restart check below.
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
!isProcessAlive(endingPid) ||
|
||||
classifyTerminalProgramProcess(originalEnding, await inspector.inspect(endingPid))
|
||||
.status === "unrelated",
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toBe(true);
|
||||
} finally {
|
||||
if (recordFd !== null) closeSync(recordFd);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
# 10. A size change keeps only a row shouldRestorePosition accepts in place (prepareMVCP), as a
|
||||
# data change already does, so a disclosure that only grows (a todo card) holds the cut-off
|
||||
# row instead of the first row in view.
|
||||
# 11. A 0 x 0 measurement is not a size (updateItemSizesBatch): rows of a hidden retained tab
|
||||
# (display: none) still render and measure 0 x 0. Recording it collapsed the row, and a row
|
||||
# whose frame was unchanged when the tab showed again got no layout event to correct it.
|
||||
#
|
||||
# Upgrading: drop each hunk whose fix the new version ships, regenerate with
|
||||
# `npx patch-package @legendapp/list`, and copy this header back on top.
|
||||
@@ -45,7 +48,7 @@ index 983e937..970ba51 100644
|
||||
* Returns a stable item type used for pooling and size estimation.
|
||||
*/
|
||||
diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js
|
||||
index eeb3fb0..b1689ad 100644
|
||||
index eeb3fb0..5fe940d 100644
|
||||
--- a/node_modules/@legendapp/list/react-native.js
|
||||
+++ b/node_modules/@legendapp/list/react-native.js
|
||||
@@ -1497,7 +1497,15 @@ function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, no
|
||||
@@ -205,7 +208,19 @@ index eeb3fb0..b1689ad 100644
|
||||
}
|
||||
|
||||
// src/core/updateItemSizes.ts
|
||||
@@ -5414,7 +5458,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
|
||||
@@ -5291,6 +5335,11 @@ function updateItemSizesBatch(ctx, measurements) {
|
||||
const result = {};
|
||||
const previousTotalSize = state.totalSize;
|
||||
for (const measurement of measurements) {
|
||||
+ // Paseo patch: a view inside a display: none ancestor measures 0 x 0. That is no measurement: a
|
||||
+ // laid-out row is as wide as the list even when it has no height. Recording 0 there collapsed
|
||||
+ // the row, and when the list showed again a row whose frame had not changed got no layout event
|
||||
+ // to correct it, so the next row overlapped it.
|
||||
+ if (measurement.size.width === 0 && measurement.size.height === 0) continue;
|
||||
const ownsMeasuredItem = measurement.containerId === void 0 || isContainerItemCurrent(ctx, measurement.containerId, measurement.itemKey);
|
||||
if (ownsMeasuredItem) {
|
||||
const index = state.indexByKey.get(measurement.itemKey);
|
||||
@@ -5414,7 +5463,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
|
||||
state.fixedItemSizeKeys.add(itemKey);
|
||||
}
|
||||
const prevSizeKnown = sizesKnown.get(itemKey);
|
||||
@@ -214,7 +229,7 @@ index eeb3fb0..b1689ad 100644
|
||||
return 0;
|
||||
}
|
||||
const size = Platform.OS === "web" ? Math.round(rawSize) : roundSize(rawSize);
|
||||
@@ -6283,6 +6327,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
|
||||
@@ -6283,6 +6332,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
|
||||
|
||||
// src/components/Containers.native.tsx
|
||||
var useCommittedMinSize = IsNewArchitecture ? () => useArr$(["totalSize"])[0] : () => void 0;
|
||||
@@ -225,7 +240,7 @@ index eeb3fb0..b1689ad 100644
|
||||
var ContainersLayer = typedMemo(function ContainersLayer2({
|
||||
children,
|
||||
freshDataTransitionEpoch,
|
||||
@@ -6290,7 +6338,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
|
||||
@@ -6290,7 +6343,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
|
||||
}) {
|
||||
const ctx = useStateContext();
|
||||
const columnWrapperStyle = ctx.columnWrapperStyle;
|
||||
@@ -234,7 +249,7 @@ index eeb3fb0..b1689ad 100644
|
||||
const minSize = useCommittedMinSize();
|
||||
const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]);
|
||||
const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch);
|
||||
@@ -6513,6 +6561,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
|
||||
@@ -6513,6 +6566,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
|
||||
null
|
||||
);
|
||||
});
|
||||
@@ -249,7 +264,7 @@ index eeb3fb0..b1689ad 100644
|
||||
var ListComponent = typedMemo(function ListComponent2({
|
||||
canRender,
|
||||
freshDataTransitionEpoch,
|
||||
@@ -6651,7 +6707,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
@@ -6651,7 +6712,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
style: autoOtherAxisStyle ? [autoOtherAxisStyle, style] : style
|
||||
},
|
||||
/* @__PURE__ */ React2__namespace.createElement(ScrollAdjust, null),
|
||||
@@ -258,7 +273,7 @@ index eeb3fb0..b1689ad 100644
|
||||
ListEmptyComponent && getComponent(ListEmptyComponent),
|
||||
shouldRenderAlignItemsAtEndSpacer && /* @__PURE__ */ React2__namespace.createElement(AlignItemsAtEndSpacer, { horizontal }),
|
||||
canRender && !ListEmptyComponent && /* @__PURE__ */ React2__namespace.createElement(
|
||||
@@ -6665,7 +6721,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
@@ -6665,7 +6726,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
stickyHeaderConfig
|
||||
}
|
||||
),
|
||||
@@ -267,7 +282,7 @@ index eeb3fb0..b1689ad 100644
|
||||
IS_DEV && ENABLE_DEVMODE
|
||||
);
|
||||
});
|
||||
@@ -7886,7 +7942,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
|
||||
@@ -7886,7 +7947,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
|
||||
const state = ctx.state;
|
||||
if (stylePaddingTop !== void 0) {
|
||||
const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0;
|
||||
@@ -279,7 +294,7 @@ index eeb3fb0..b1689ad 100644
|
||||
let prevTotalSize = peek$(ctx, "totalSize") || 0;
|
||||
set$(ctx, "totalSize", prevTotalSize + prevStylePaddingTop);
|
||||
state.timeoutSetPaddingTop = setTimeout(() => {
|
||||
@@ -7989,6 +8048,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
@@ -7989,6 +8053,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
estimatedListSize,
|
||||
extraData,
|
||||
getFixedItemSize,
|
||||
@@ -287,7 +302,7 @@ index eeb3fb0..b1689ad 100644
|
||||
getItemType,
|
||||
horizontal,
|
||||
rtl,
|
||||
@@ -8282,6 +8342,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
@@ -8282,6 +8347,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
dataVersion,
|
||||
drawDistance,
|
||||
estimatedItemSize,
|
||||
@@ -296,7 +311,7 @@ index eeb3fb0..b1689ad 100644
|
||||
getItemType: useWrapIfItem(getItemType),
|
||||
hasExternalScroll,
|
||||
diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs
|
||||
index fe5f415..e61945e 100644
|
||||
index fe5f415..7f1b9dd 100644
|
||||
--- a/node_modules/@legendapp/list/react-native.mjs
|
||||
+++ b/node_modules/@legendapp/list/react-native.mjs
|
||||
@@ -1476,7 +1476,15 @@ function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, no
|
||||
@@ -456,7 +471,19 @@ index fe5f415..e61945e 100644
|
||||
}
|
||||
|
||||
// src/core/updateItemSizes.ts
|
||||
@@ -5393,7 +5437,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
|
||||
@@ -5270,6 +5314,11 @@ function updateItemSizesBatch(ctx, measurements) {
|
||||
const result = {};
|
||||
const previousTotalSize = state.totalSize;
|
||||
for (const measurement of measurements) {
|
||||
+ // Paseo patch: a view inside a display: none ancestor measures 0 x 0. That is no measurement: a
|
||||
+ // laid-out row is as wide as the list even when it has no height. Recording 0 there collapsed
|
||||
+ // the row, and when the list showed again a row whose frame had not changed got no layout event
|
||||
+ // to correct it, so the next row overlapped it.
|
||||
+ if (measurement.size.width === 0 && measurement.size.height === 0) continue;
|
||||
const ownsMeasuredItem = measurement.containerId === void 0 || isContainerItemCurrent(ctx, measurement.containerId, measurement.itemKey);
|
||||
if (ownsMeasuredItem) {
|
||||
const index = state.indexByKey.get(measurement.itemKey);
|
||||
@@ -5393,7 +5442,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
|
||||
state.fixedItemSizeKeys.add(itemKey);
|
||||
}
|
||||
const prevSizeKnown = sizesKnown.get(itemKey);
|
||||
@@ -465,7 +492,7 @@ index fe5f415..e61945e 100644
|
||||
return 0;
|
||||
}
|
||||
const size = Platform.OS === "web" ? Math.round(rawSize) : roundSize(rawSize);
|
||||
@@ -6262,6 +6306,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
|
||||
@@ -6262,6 +6311,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
|
||||
|
||||
// src/components/Containers.native.tsx
|
||||
var useCommittedMinSize = IsNewArchitecture ? () => useArr$(["totalSize"])[0] : () => void 0;
|
||||
@@ -476,7 +503,7 @@ index fe5f415..e61945e 100644
|
||||
var ContainersLayer = typedMemo(function ContainersLayer2({
|
||||
children,
|
||||
freshDataTransitionEpoch,
|
||||
@@ -6269,7 +6317,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
|
||||
@@ -6269,7 +6322,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
|
||||
}) {
|
||||
const ctx = useStateContext();
|
||||
const columnWrapperStyle = ctx.columnWrapperStyle;
|
||||
@@ -485,7 +512,7 @@ index fe5f415..e61945e 100644
|
||||
const minSize = useCommittedMinSize();
|
||||
const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]);
|
||||
const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch);
|
||||
@@ -6492,6 +6540,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
|
||||
@@ -6492,6 +6545,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
|
||||
null
|
||||
);
|
||||
});
|
||||
@@ -500,7 +527,7 @@ index fe5f415..e61945e 100644
|
||||
var ListComponent = typedMemo(function ListComponent2({
|
||||
canRender,
|
||||
freshDataTransitionEpoch,
|
||||
@@ -6630,7 +6686,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
@@ -6630,7 +6691,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
style: autoOtherAxisStyle ? [autoOtherAxisStyle, style] : style
|
||||
},
|
||||
/* @__PURE__ */ React2.createElement(ScrollAdjust, null),
|
||||
@@ -509,7 +536,7 @@ index fe5f415..e61945e 100644
|
||||
ListEmptyComponent && getComponent(ListEmptyComponent),
|
||||
shouldRenderAlignItemsAtEndSpacer && /* @__PURE__ */ React2.createElement(AlignItemsAtEndSpacer, { horizontal }),
|
||||
canRender && !ListEmptyComponent && /* @__PURE__ */ React2.createElement(
|
||||
@@ -6644,7 +6700,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
@@ -6644,7 +6705,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
stickyHeaderConfig
|
||||
}
|
||||
),
|
||||
@@ -518,7 +545,7 @@ index fe5f415..e61945e 100644
|
||||
IS_DEV && ENABLE_DEVMODE
|
||||
);
|
||||
});
|
||||
@@ -7865,7 +7921,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
|
||||
@@ -7865,7 +7926,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
|
||||
const state = ctx.state;
|
||||
if (stylePaddingTop !== void 0) {
|
||||
const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0;
|
||||
@@ -530,7 +557,7 @@ index fe5f415..e61945e 100644
|
||||
let prevTotalSize = peek$(ctx, "totalSize") || 0;
|
||||
set$(ctx, "totalSize", prevTotalSize + prevStylePaddingTop);
|
||||
state.timeoutSetPaddingTop = setTimeout(() => {
|
||||
@@ -7968,6 +8027,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
@@ -7968,6 +8032,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
estimatedListSize,
|
||||
extraData,
|
||||
getFixedItemSize,
|
||||
@@ -538,7 +565,7 @@ index fe5f415..e61945e 100644
|
||||
getItemType,
|
||||
horizontal,
|
||||
rtl,
|
||||
@@ -8261,6 +8321,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
@@ -8261,6 +8326,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
dataVersion,
|
||||
drawDistance,
|
||||
estimatedItemSize,
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user