Compare commits

..
Author SHA1 Message Date
Mohamed Boudra a45dfaf0e8 fix(app): keep dropped rows in place until their new order arrives
Sheet portals forward new children after the local drop commit. Renew transform hosts with the incoming row order so clearing drag offsets cannot expose the old slots. Reset same-slot drops explicitly because their data order does not change.
2026-11-15 12:35:05 +01:00
Mohamed Boudra f69dc31086 fix(app): type composed sheet gesture relations 2026-11-15 12:35:05 +01:00
Mohamed Boudra 6adf92f089 fix(app): keep card drags inside bottom sheets
Coordinate registered content drags as simultaneous while the sheet waits for them. Spread Gorhom gesture relations so RNGH resolves the handler refs instead of dropping nested arrays.
2026-11-15 12:35:05 +01:00
15 changed files with 492 additions and 122 deletions
+3 -4
View File
@@ -873,10 +873,9 @@ await paseo.agents.ref(agentId).timeline.append({
The daemon stamps the runtime `pluginId`; plugin code never supplies it. Reusing the same `id`
replaces the previous row from that plugin on live clients and fresh timeline fetches. Rows live in
the daemon's in-memory timeline and survive scroll, refetch, and reconnect, but not a daemon
restart. A row without an installed renderer shows an unavailable placeholder. `kind` must match
`/^[a-z][a-z0-9-]*$/`, the rule `addTimelineRenderer` enforces; the daemon rejects any other kind.
Serialized `data` must be at most 64 KiB; the daemon rejects a larger append instead of storing a
payload that cannot be rendered intact. The daemon advertises this RPC through
restart. A row without an installed renderer shows an unavailable placeholder. Serialized `data`
must be at most 64 KiB; the daemon rejects a larger append instead of storing a payload that cannot
be rendered intact. The daemon advertises this RPC through
`server_info.features.pluginTimelineItems`.
## Contribute client slash commands
@@ -20,21 +20,6 @@ function explorerSidebar(page: Parameters<typeof ensureExplorerSidebar>[0]) {
return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
}
const LAYOUT_VIEWPORTS = {
compact: { width: 390, height: 844 },
wide: { width: 1280, height: 900 },
};
// The previous layout stays on screen for a few renders after the resize. Wait for the shell to
// switch so the next step reads the layout it resized to.
async function resizeToLayout(
page: Parameters<typeof ensureExplorerSidebar>[0],
layout: keyof typeof LAYOUT_VIEWPORTS,
) {
await page.setViewportSize(LAYOUT_VIEWPORTS[layout]);
await expect(page.getByTestId(`${layout}-shell`)).toBeVisible();
}
async function expectExplorerActiveTabForeground(
page: Parameters<typeof ensureExplorerSidebar>[0],
) {
@@ -259,7 +244,7 @@ test("tablet prototype keeps Explorer tabs when resizing down to a phone", async
await page.screenshot({ path: testInfo.outputPath("tablet-951.png") });
await openExplorerAgentDraft(page);
await page.screenshot({ path: testInfo.outputPath("tablet-agent-draft.png") });
await resizeToLayout(page, "compact");
await page.setViewportSize({ width: 390, height: 844 });
await revealPhoneExplorer(page);
await expect(
explorerSidebar(page).getByRole("button", { name: "New tab", exact: true }),
@@ -304,10 +289,10 @@ test("retains the Explorer host through appearance changes and folding", async (
.poll(() => explorer.evaluate((element) => getComputedStyle(element).backgroundColor))
.not.toBe(previousColor);
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
await resizeToLayout(page, "compact");
await page.setViewportSize({ width: 390, height: 844 });
await ensureExplorerSidebar(page);
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
await resizeToLayout(page, "wide");
await page.setViewportSize({ width: 1280, height: 900 });
await expect(explorer).toBeVisible();
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
} finally {
@@ -342,7 +327,7 @@ test("reorders compact Explorer tabs with a pointer without changing selection",
const workspace = await seedWorkspace({ repoPrefix: "explorer-compact-drag-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await resizeToLayout(page, "compact");
await page.setViewportSize({ width: 390, height: 844 });
await openFilesPanel(page);
await reorderCompactExplorerTabs(page);
await expect(
@@ -409,9 +394,9 @@ test("updates populated code and diff appearance without losing the editor insta
.getByTestId("file-source-editor")
.elementHandle();
await text.fill(`${source}\n// retained Explorer edit`);
await resizeToLayout(page, "compact");
await page.setViewportSize({ width: 390, height: 844 });
await ensureExplorerSidebar(page);
await resizeToLayout(page, "wide");
await page.setViewportSize({ width: 1280, height: 900 });
expect(await explorerEditor!.evaluate((node) => node.isConnected)).toBe(true);
await expect(text).toContainText("retained Explorer edit");
} finally {
@@ -476,9 +461,9 @@ test("retains populated Explorer chat and terminal attachments through appearanc
const terminalNode = await terminal.elementHandle();
await page.getByTestId("workspace-explorer-toggle").first().click();
await ensureExplorerSidebar(page);
await resizeToLayout(page, "compact");
await page.setViewportSize({ width: 390, height: 844 });
await ensureExplorerSidebar(page);
await resizeToLayout(page, "wide");
await page.setViewportSize({ width: 1280, height: 900 });
await cycleWorkspaceTheme(page);
expect(await terminalNode!.evaluate((node) => node.isConnected)).toBe(true);
expect(attaches).toHaveLength(1);
@@ -498,7 +483,7 @@ test("compact file sheets cover Explorer and return to its retained file list",
try {
await writeFile(`${workspace.repoPath}/sheet-marker.txt`, "Explorer sheet marker\n");
await gotoWorkspace(page, workspace.workspaceId);
await resizeToLayout(page, "compact");
await page.setViewportSize({ width: 390, height: 844 });
const explorer = await ensureExplorerSidebar(page);
await openFilesPanel(page);
const host = await explorer.elementHandle();
@@ -0,0 +1,27 @@
# Android Usage drop regressions
Use a device you own and fixed reports from an isolated daemon. Keep recordings in `.artifacts/`. Do not upload recordings containing account details.
## Same-slot drop
Open Usage with two cards that fit without scrolling. Run:
```sh
ANDROID_SERIAL=emulator-5580 node packages/app/e2e/mobile/usage-drag/android.mjs
```
Set `PASEO_DEVICE_SESSION` to reuse your Agent Device session. The test holds the first handle, moves within its slot, drops it, then swipes the sheet body. It asserts sheet movement while the finger is still down: after release, the sheet can snap back to its starting position.
## Old-order frame
Record a drop with Android `screenrecord`. Include the original order, the held arrangement, the release, and at least five seconds after release. Take a reference screenshot before recording. Use fixed labels that stay visible throughout the drop.
Create a JSON map of label names to screenshot rectangles `[x, y, width, height]`, in physical pixels. Crop each label tightly, without changing text or the pointer. Install `opencv-python-headless` in a local virtual environment under `.artifacts/`, then run:
```sh
.artifacts/frames-venv/bin/python packages/app/e2e/mobile/usage-drag/frames.py \
--reference .artifacts/before.png --labels .artifacts/labels.json \
.artifacts/drop-up.mp4 .artifacts/drop-down.mp4
```
The check requires recognized labels in the first and last frames and a changed final order. After every label first reaches its final slot, it fails on any frame showing all original slots again. It prints the offending frame numbers for each recording. This exercises the Android compositor; a JavaScript render test cannot establish which intermediate commit reached the screen.
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { setTimeout } from "node:timers/promises";
// Open Usage with a fixture whose cards fit without scrolling before running.
// A same-slot drop must release the list's recognizer so a body swipe moves the sheet.
const serial = process.env.ANDROID_SERIAL;
assert.ok(serial, "Set ANDROID_SERIAL to the device owned by this test");
const session = process.env.PASEO_DEVICE_SESSION ?? "usage-drag-regression";
const artifacts = resolve(".artifacts/usage-drag-regression");
mkdirSync(artifacts, { recursive: true });
function ad(...args) {
return execFileSync(
"agent-device",
[...args, "--platform", "android", "--serial", serial, "--session", session],
{ encoding: "utf8", timeout: 30_000 },
);
}
function adb(...args) {
return execFileSync("adb", ["-s", serial, ...args], { timeout: 30_000 });
}
function snapshot() {
return JSON.parse(ad("snapshot", "--json")).data.nodes;
}
function touch(kind, x, y) {
adb("shell", "input", "motionevent", kind, String(Math.round(x)), String(Math.round(y)));
}
try {
ad("open", process.env.PASEO_MOBILE_APP_ID ?? "sh.paseo.debug");
const nodes = snapshot();
writeFileSync(resolve(artifacts, "before.json"), JSON.stringify(nodes));
const handle = nodes.find((node) => node.identifier === "usage-card-drag-handle");
const list = nodes.find((node) => node.identifier === "usage-list");
assert.ok(handle?.rect && list?.rect, "Open Usage with a visible card handle");
const x = handle.rect.x + handle.rect.width / 2;
const y = handle.rect.y + handle.rect.height / 2;
touch("DOWN", x, y);
await setTimeout(600);
touch("MOVE", x, y + 60);
await setTimeout(200);
touch("MOVE", x, y + 90);
touch("MOVE", x, y + 60);
ad("screenshot", resolve(artifacts, "dragging.png"));
touch("UP", x, y + 60);
await setTimeout(2000);
ad("screenshot", resolve(artifacts, "dropped.png"));
const swipeX = list.rect.x + list.rect.width / 2;
touch("DOWN", swipeX, y + 80);
for (let offset = 90; offset <= 500; offset += 10) {
touch("MOVE", swipeX, y + offset);
}
const during = snapshot();
writeFileSync(resolve(artifacts, "during.json"), JSON.stringify(during));
ad("screenshot", resolve(artifacts, "during.png"));
touch("UP", swipeX, y + 500);
await setTimeout(1500);
const after = snapshot();
writeFileSync(resolve(artifacts, "after.json"), JSON.stringify(after));
ad("screenshot", resolve(artifacts, "after.png"));
const beforeSheet = nodes.find((node) => node.label === "Bottom sheet handle");
const afterSheet = during.find((node) => node.label === "Bottom sheet handle");
assert.ok(beforeSheet?.rect, "Usage must start as a sheet");
assert.ok(
!afterSheet || afterSheet.rect.y > beforeSheet.rect.y + 50,
"A body swipe after a same-slot drop must move the sheet",
);
} finally {
ad("close");
}
@@ -0,0 +1,59 @@
"""Check native drop recordings against visible row labels (requires opencv-python-headless).
Supply a before screenshot and a JSON map of label names to [x, y, width, height].
A recording must start in the original order and end in a different order. Once
all labels reach their final slots, any frame returning to the original slots fails.
Keep the pointer clear of the labels and use fixed fixture data.
"""
import argparse
import json
from pathlib import Path
import cv2
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--reference", required=True)
parser.add_argument("--labels", required=True)
parser.add_argument("--top", type=int, default=0)
parser.add_argument("--bottom", type=int)
parser.add_argument("videos", nargs="+")
args = parser.parse_args()
reference = cv2.imread(args.reference, cv2.IMREAD_GRAYSCALE)
assert reference is not None, "Cannot read reference image"
labels = json.loads(Path(args.labels).read_text())
results = []
for video in args.videos:
capture = cv2.VideoCapture(video)
frames = []
while True:
ok, image = capture.read()
if not ok:
break
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
positions = {}
for label, (x, y, width, height) in labels.items():
template = reference[y:y + height, x:x + width]
matches = cv2.matchTemplate(gray[args.top:args.bottom, x:x + width], template, cv2.TM_CCOEFF_NORMED)
_, confidence, _, point = cv2.minMaxLoc(matches)
positions[label] = (point[1] + args.top, confidence)
frames.append(positions)
capture.release()
assert frames, f"No frames in {video}"
initial, final = frames[0], frames[-1]
assert all(confidence >= 0.95 for _, confidence in initial.values()), f"Unrecognized initial labels: {video}"
assert all(confidence >= 0.95 for _, confidence in final.values()), f"Unrecognized final labels: {video}"
assert sorted(initial, key=lambda key: initial[key][0]) != sorted(final, key=lambda key: final[key][0]), f"Drop did not reorder: {video}"
def matches_slots(frame, target):
return all(confidence >= 0.95 and abs(y - target[label][0]) <= 2
for label, (y, confidence) in frame.items())
reached_final = False
wrong = []
for number, frame in enumerate(frames, start=1):
reached_final = reached_final or matches_slots(frame, final)
if reached_final and matches_slots(frame, initial):
wrong.append(number)
results.append({"recording": video, "oldOrderFrames": wrong, "count": len(wrong)})
print(json.dumps(results, indent=2))
raise SystemExit(1 if any(result["count"] for result in results) else 0)
@@ -91,13 +91,10 @@ async function selectWorkspaceTab(tab: Locator): Promise<void> {
export async function ensureExplorerSidebar(page: Page): Promise<Locator> {
const toggle = page.getByTestId("workspace-explorer-toggle").first();
await expect(toggle).toBeVisible({ timeout: 30_000 });
// Decide from the toggle, which reports the open state. The sidebar's own visibility lags it:
// a compact layout draws its closed Explorer for a frame before hiding it.
if ((await toggle.getAttribute("aria-expanded")) !== "true") {
const explorer = explorerSidebar(page);
if ((await explorer.count()) === 0) {
await toggle.click();
}
await expect(toggle).toHaveAttribute("aria-expanded", "true");
const explorer = explorerSidebar(page);
await expect(explorer).toBeVisible({ timeout: 30_000 });
return explorer;
}
@@ -43,6 +43,7 @@ vi.mock("@gorhom/portal", () => ({
/** Every `waitFor` Gorhom received, in render order. Gorhom rebuilds its content pan per value. */
const sheetWaitFor = vi.hoisted(() => [] as unknown[]);
const sheetSimultaneousHandlers = vi.hoisted(() => [] as unknown[]);
vi.mock("@gorhom/bottom-sheet", async () => {
const { Portal } = await import("@gorhom/portal");
@@ -55,10 +56,17 @@ vi.mock("@gorhom/bottom-sheet", async () => {
children,
stackBehavior,
waitFor,
}: { children?: React.ReactNode; stackBehavior?: string; waitFor?: unknown },
simultaneousHandlers,
}: {
children?: React.ReactNode;
stackBehavior?: string;
waitFor?: unknown;
simultaneousHandlers?: unknown;
},
_ref,
) => {
sheetWaitFor.push(waitFor);
sheetSimultaneousHandlers.push(simultaneousHandlers);
return React.createElement(
Portal,
null,
@@ -259,6 +267,7 @@ describe("IsolatedBottomSheetModal content drag", () => {
document.body.appendChild(container);
root = createRoot(container);
sheetWaitFor.length = 0;
sheetSimultaneousHandlers.length = 0;
});
afterEach(() => {
@@ -275,11 +284,19 @@ describe("IsolatedBottomSheetModal content drag", () => {
return null;
}
function renderSheet(content: React.ReactNode) {
function renderSheet(
content: React.ReactNode,
handlers: Pick<
React.ComponentProps<typeof IsolatedBottomSheetModal>,
"waitFor" | "simultaneousHandlers"
> = {},
) {
act(() => {
root.render(
<>
<IsolatedBottomSheetModal contextBridge={null}>{content}</IsolatedBottomSheetModal>
<IsolatedBottomSheetModal contextBridge={null} {...handlers}>
{content}
</IsolatedBottomSheetModal>
<PortalHostProbe />
</>,
);
@@ -291,9 +308,106 @@ describe("IsolatedBottomSheetModal content drag", () => {
expect(sheetWaitFor.at(-1)).toBeUndefined();
renderSheet(<ContentDrag />);
expect(sheetWaitFor.at(-1)).toBe(dragGesture);
expect(sheetSimultaneousHandlers.at(-1)).toEqual([dragGesture]);
expect(sheetWaitFor.at(-1)).toEqual([dragGesture]);
renderSheet(null);
expect(sheetWaitFor.at(-1)).toBeUndefined();
expect(sheetSimultaneousHandlers.at(-1)).toBeUndefined();
});
it.each([false, true])(
"preserves supplied relations when a content drag mounts and unmounts (array=%s)",
(array) => {
const waiting = React.createRef<React.ComponentType>();
const simultaneous = React.createRef<React.ComponentType>();
const handlers = {
waitFor: array ? [waiting] : waiting,
simultaneousHandlers: array ? [simultaneous] : simultaneous,
};
renderSheet(<ContentDrag />, handlers);
expect(sheetWaitFor.at(-1)).toEqual([waiting, dragGesture]);
expect(sheetSimultaneousHandlers.at(-1)).toEqual([simultaneous, dragGesture]);
renderSheet(null, handlers);
expect(sheetWaitFor.at(-1)).toBe(handlers.waitFor);
expect(sheetSimultaneousHandlers.at(-1)).toBe(handlers.simultaneousHandlers);
},
);
});
// Load the real vendored implementations at runtime without typechecking Gorhom's entire source
// tree (its hooks barrel reaches upstream files that don't compile under the app's TS settings).
const gorhomSource = "@gorhom/bottom-sheet/src";
const { default: BottomSheetDraggableView } = await import(
`${gorhomSource}/components/bottomSheetDraggableView/BottomSheetDraggableView`
);
const { BottomSheetInternalContext } = await import(`${gorhomSource}/contexts/internal`);
const { BottomSheetDraggableContext, BottomSheetGestureHandlersContext } = await import(
`${gorhomSource}/contexts/gesture`
);
const rnghSource = "react-native-gesture-handler/src";
const { extractGestureRelations } = await import(
`${rnghSource}/handlers/gestures/GestureDetector/utils`
);
import { Gesture, type GestureType } from "react-native-gesture-handler";
const noGestureWork = () => {};
const gestureHandlerContext = {
contentPanGestureHandler: {
handleOnStart: noGestureWork,
handleOnChange: noGestureWork,
handleOnEnd: noGestureWork,
handleOnFinalize: noGestureWork,
},
handlePanGestureHandler: {
handleOnStart: noGestureWork,
handleOnChange: noGestureWork,
handleOnEnd: noGestureWork,
handleOnFinalize: noGestureWork,
},
};
function dragInternalContext(relations: SheetContentDragGesture | SheetContentDragGesture[]) {
return {
enableContentPanningGesture: true,
waitFor: relations,
simultaneousHandlers: relations,
};
}
describe("bottom sheet native drag relations", () => {
it.each([false, true])(
"resolves both relations through the real content pan (array=%s)",
(array) => {
const container = document.createElement("div");
const root = createRoot(container);
const child = Gesture.Pan();
child.handlerTag = 42;
const ref = { current: child };
const relations = array ? [ref] : ref;
let resolved: ReturnType<typeof extractGestureRelations> | undefined;
const internal = dragInternalContext(relations);
function Probe() {
const pan = useContext(BottomSheetDraggableContext) as GestureType | null;
if (pan) resolved = extractGestureRelations(pan);
return null;
}
try {
act(() =>
root.render(
<BottomSheetInternalContext.Provider value={internal}>
<BottomSheetGestureHandlersContext.Provider value={gestureHandlerContext}>
<BottomSheetDraggableView>
<Probe />
</BottomSheetDraggableView>
</BottomSheetGestureHandlersContext.Provider>
</BottomSheetInternalContext.Provider>,
),
);
expect(resolved?.waitFor).toEqual([42]);
expect(resolved?.simultaneousHandlers).toEqual([42]);
} finally {
act(() => root.unmount());
}
},
);
});
@@ -105,6 +105,8 @@ export const IsolatedBottomSheetModal = forwardRef<
contextBridge,
backdropOpacity,
enableDismissOnClose = true,
waitFor,
simultaneousHandlers,
...bottomSheetProps
} = props;
// State, not a ref the child fills in: Gorhom builds its pan once per `waitFor`, and RNGH reads
@@ -115,6 +117,19 @@ export const IsolatedBottomSheetModal = forwardRef<
setContentDragGesture(gesture);
return () => setContentDragGesture((current) => (current === gesture ? undefined : current));
}, []);
// RNGH Android's shouldBeCancelledByActiveHandler treats a waiting ancestor's raw ACTIVE
// state as ownership and cancels its child at activation. Simultaneity lets the child activate;
// waitFor still keeps the sheet stationary until the child fails.
const contentDragHandlers = useMemo(() => {
if (!contentDragGesture) return { waitFor, simultaneousHandlers };
return {
waitFor: [...(waitFor ? [waitFor].flat() : []), contentDragGesture],
simultaneousHandlers: [
...(simultaneousHandlers ? [simultaneousHandlers].flat() : []),
contentDragGesture,
],
};
}, [contentDragGesture, waitFor, simultaneousHandlers]);
const renderBackdrop = useCallback(
(backdropProps: BottomSheetBackdropProps) => (
<SheetBackdrop
@@ -132,7 +147,8 @@ export const IsolatedBottomSheetModal = forwardRef<
enableDismissOnClose={enableDismissOnClose}
stackBehavior={presentation}
backdropComponent={backdropOpacity === undefined ? undefined : renderBackdrop}
waitFor={contentDragGesture}
waitFor={contentDragHandlers.waitFor}
simultaneousHandlers={contentDragHandlers.simultaneousHandlers}
>
<BottomSheetScope attachContentDrag={attachContentDrag}>
{contextBridge ? contextBridge(children) : children}
@@ -1,6 +1,5 @@
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import { PluginIdSchema } from "@getpaseo/protocol/plugin-config";
export const PLUGIN_TIMELINE_DATA_MAX_BYTES = 64 * 1024;
@@ -15,15 +14,6 @@ const MARKER_COST = 32;
const SHORT_STRING = 1024;
const MAX_DEPTH = 64;
// The app registers renderer kinds under the plugin ID rule, so stored kinds must use that format.
export function assertPluginTimelineKind(kind: string): void {
if (!PluginIdSchema.safeParse(kind).success) {
throw new Error(
`Plugin timeline item kind must start with a lowercase letter and contain only lowercase letters, digits, and dashes: ${JSON.stringify(kind)}`,
);
}
}
export function assertPluginTimelineDataSize(data: JsonValue): void {
const serializedBytes = Buffer.byteLength(JSON.stringify(data), "utf8");
if (serializedBytes > PLUGIN_TIMELINE_DATA_MAX_BYTES) {
@@ -1660,37 +1660,6 @@ describe("plugin timeline append RPC", () => {
}),
);
});
test.each(["repro.card", "Review", "review_card", "1review", ""])(
"rejects the kind %j that no client renderer can register",
async (kind) => {
const messages: SessionOutboundMessage[] = [];
const appendTimelineItem = vi.fn();
const session = createSessionForTest({
clientId: "plugin:review",
messages,
agentManager: { appendTimelineItem },
});
await session.handleMessage({
type: "agent.timeline.append.request",
requestId: "append-kind",
agentId: "agent-1",
item: { type: "plugin", id: "review-1", kind, version: 1, data: {} },
});
expect(appendTimelineItem).not.toHaveBeenCalled();
expect(messages).toContainEqual({
type: "rpc_error",
payload: {
requestId: "append-kind",
requestType: "agent.timeline.append.request",
error: `Request failed: Plugin timeline item kind must start with a lowercase letter and contain only lowercase letters, digits, and dashes: ${JSON.stringify(kind)}`,
code: "handler_error",
},
});
},
);
});
describe("agent detach RPC", () => {
+1 -5
View File
@@ -144,10 +144,7 @@ import {
updateAgentCommand,
} from "./agent/lifecycle-command.js";
import { buildStoredAgentPayload, toAgentPayload } from "./agent/agent-projections.js";
import {
assertPluginTimelineDataSize,
assertPluginTimelineKind,
} from "./agent/agent-timeline-content.js";
import { assertPluginTimelineDataSize } from "./agent/agent-timeline-content.js";
import { parsePluginClientId } from "./plugins/plugin-session-identity.js";
import { buildAgentForkContextAttachment } from "./agent/activity-curator.js";
import { buildAgentPrompt } from "./agent/prompt-attachments.js";
@@ -8507,7 +8504,6 @@ export class Session {
): Promise<void> {
const pluginId = parsePluginClientId(this.clientId);
if (!pluginId) throw new Error("Only plugin sessions can append plugin timeline items");
assertPluginTimelineKind(msg.item.kind);
assertPluginTimelineDataSize(msg.item.data);
const { seq, epoch } = await this.agentManager.appendTimelineItem(msg.agentId, {
...msg.item,
+132
View File
@@ -1,3 +1,37 @@
diff --git a/node_modules/@gorhom/bottom-sheet/lib/commonjs/components/bottomSheetDraggableView/BottomSheetDraggableView.js b/node_modules/@gorhom/bottom-sheet/lib/commonjs/components/bottomSheetDraggableView/BottomSheetDraggableView.js
index f3d8f55..72d4322 100644
--- a/node_modules/@gorhom/bottom-sheet/lib/commonjs/components/bottomSheetDraggableView/BottomSheetDraggableView.js
+++ b/node_modules/@gorhom/bottom-sheet/lib/commonjs/components/bottomSheetDraggableView/BottomSheetDraggableView.js
@@ -55,10 +55,10 @@ const BottomSheetDraggableViewComponent = ({
const draggableGesture = (0, _react.useMemo)(() => {
let gesture = _reactNativeGestureHandler.Gesture.Pan().enabled(enableContentPanningGesture).shouldCancelWhenOutside(false).runOnJS(false).onStart(contentPanGestureHandler.handleOnStart).onChange(contentPanGestureHandler.handleOnChange).onEnd(contentPanGestureHandler.handleOnEnd).onFinalize(contentPanGestureHandler.handleOnFinalize);
if (waitFor) {
- gesture = gesture.requireExternalGestureToFail(waitFor);
+ gesture = gesture.requireExternalGestureToFail(...(Array.isArray(waitFor) ? waitFor : [waitFor]));
}
if (simultaneousHandlers) {
- gesture = gesture.simultaneousWithExternalGesture(simultaneousHandlers);
+ gesture = gesture.simultaneousWithExternalGesture(...simultaneousHandlers);
}
if (activeOffsetX) {
gesture = gesture.activeOffsetX(activeOffsetX);
diff --git a/node_modules/@gorhom/bottom-sheet/lib/commonjs/components/bottomSheetHandle/BottomSheetHandleContainer.js b/node_modules/@gorhom/bottom-sheet/lib/commonjs/components/bottomSheetHandle/BottomSheetHandleContainer.js
index ee9e898..1500886 100644
--- a/node_modules/@gorhom/bottom-sheet/lib/commonjs/components/bottomSheetHandle/BottomSheetHandleContainer.js
+++ b/node_modules/@gorhom/bottom-sheet/lib/commonjs/components/bottomSheetHandle/BottomSheetHandleContainer.js
@@ -59,10 +59,10 @@ function BottomSheetHandleContainerComponent({
const panGesture = (0, _react.useMemo)(() => {
let gesture = _reactNativeGestureHandler.Gesture.Pan().enabled(enableHandlePanningGesture).shouldCancelWhenOutside(false).runOnJS(false).onStart(handlePanGestureHandler.handleOnStart).onChange(handlePanGestureHandler.handleOnChange).onEnd(handlePanGestureHandler.handleOnEnd).onFinalize(handlePanGestureHandler.handleOnFinalize);
if (waitFor) {
- gesture = gesture.requireExternalGestureToFail(waitFor);
+ gesture = gesture.requireExternalGestureToFail(...(Array.isArray(waitFor) ? waitFor : [waitFor]));
}
if (simultaneousHandlers) {
- gesture = gesture.simultaneousWithExternalGesture(simultaneousHandlers);
+ gesture = gesture.simultaneousWithExternalGesture(...simultaneousHandlers);
}
if (activeOffsetX) {
gesture = gesture.activeOffsetX(activeOffsetX);
diff --git a/node_modules/@gorhom/bottom-sheet/lib/commonjs/components/bottomSheetModal/BottomSheetModal.js b/node_modules/@gorhom/bottom-sheet/lib/commonjs/components/bottomSheetModal/BottomSheetModal.js
index ee51e76..5fe8f75 100644
--- a/node_modules/@gorhom/bottom-sheet/lib/commonjs/components/bottomSheetModal/BottomSheetModal.js
@@ -70,6 +104,40 @@ index e44ad86..96555ef 100644
var _useScrollEventsHandlersDefault = require("./hooks/useScrollEventsHandlersDefault");
var _useGestureEventsHandlersDefault = require("./hooks/useGestureEventsHandlersDefault");
var _useBottomSheetGestureHandlers = require("./hooks/useBottomSheetGestureHandlers");
diff --git a/node_modules/@gorhom/bottom-sheet/lib/module/components/bottomSheetDraggableView/BottomSheetDraggableView.js b/node_modules/@gorhom/bottom-sheet/lib/module/components/bottomSheetDraggableView/BottomSheetDraggableView.js
index 9efadd2..942b78a 100644
--- a/node_modules/@gorhom/bottom-sheet/lib/module/components/bottomSheetDraggableView/BottomSheetDraggableView.js
+++ b/node_modules/@gorhom/bottom-sheet/lib/module/components/bottomSheetDraggableView/BottomSheetDraggableView.js
@@ -49,10 +49,10 @@ const BottomSheetDraggableViewComponent = ({
const draggableGesture = useMemo(() => {
let gesture = Gesture.Pan().enabled(enableContentPanningGesture).shouldCancelWhenOutside(false).runOnJS(false).onStart(contentPanGestureHandler.handleOnStart).onChange(contentPanGestureHandler.handleOnChange).onEnd(contentPanGestureHandler.handleOnEnd).onFinalize(contentPanGestureHandler.handleOnFinalize);
if (waitFor) {
- gesture = gesture.requireExternalGestureToFail(waitFor);
+ gesture = gesture.requireExternalGestureToFail(...(Array.isArray(waitFor) ? waitFor : [waitFor]));
}
if (simultaneousHandlers) {
- gesture = gesture.simultaneousWithExternalGesture(simultaneousHandlers);
+ gesture = gesture.simultaneousWithExternalGesture(...simultaneousHandlers);
}
if (activeOffsetX) {
gesture = gesture.activeOffsetX(activeOffsetX);
diff --git a/node_modules/@gorhom/bottom-sheet/lib/module/components/bottomSheetHandle/BottomSheetHandleContainer.js b/node_modules/@gorhom/bottom-sheet/lib/module/components/bottomSheetHandle/BottomSheetHandleContainer.js
index 3d1eafe..bf8e366 100644
--- a/node_modules/@gorhom/bottom-sheet/lib/module/components/bottomSheetHandle/BottomSheetHandleContainer.js
+++ b/node_modules/@gorhom/bottom-sheet/lib/module/components/bottomSheetHandle/BottomSheetHandleContainer.js
@@ -53,10 +53,10 @@ function BottomSheetHandleContainerComponent({
const panGesture = useMemo(() => {
let gesture = Gesture.Pan().enabled(enableHandlePanningGesture).shouldCancelWhenOutside(false).runOnJS(false).onStart(handlePanGestureHandler.handleOnStart).onChange(handlePanGestureHandler.handleOnChange).onEnd(handlePanGestureHandler.handleOnEnd).onFinalize(handlePanGestureHandler.handleOnFinalize);
if (waitFor) {
- gesture = gesture.requireExternalGestureToFail(waitFor);
+ gesture = gesture.requireExternalGestureToFail(...(Array.isArray(waitFor) ? waitFor : [waitFor]));
}
if (simultaneousHandlers) {
- gesture = gesture.simultaneousWithExternalGesture(simultaneousHandlers);
+ gesture = gesture.simultaneousWithExternalGesture(...simultaneousHandlers);
}
if (activeOffsetX) {
gesture = gesture.activeOffsetX(activeOffsetX);
diff --git a/node_modules/@gorhom/bottom-sheet/lib/module/components/bottomSheetModal/BottomSheetModal.js b/node_modules/@gorhom/bottom-sheet/lib/module/components/bottomSheetModal/BottomSheetModal.js
index 94fabdb..d870aa7 100644
--- a/node_modules/@gorhom/bottom-sheet/lib/module/components/bottomSheetModal/BottomSheetModal.js
@@ -128,6 +196,19 @@ index 8259637..eb057fe 100644
export { useScrollEventsHandlersDefault } from './hooks/useScrollEventsHandlersDefault';
export { useGestureEventsHandlersDefault } from './hooks/useGestureEventsHandlersDefault';
export { useBottomSheetGestureHandlers } from './hooks/useBottomSheetGestureHandlers';
diff --git a/node_modules/@gorhom/bottom-sheet/lib/typescript/components/bottomSheet/types.d.ts b/node_modules/@gorhom/bottom-sheet/lib/typescript/components/bottomSheet/types.d.ts
index 6b23056..8d92aae 100644
--- a/node_modules/@gorhom/bottom-sheet/lib/typescript/components/bottomSheet/types.d.ts
+++ b/node_modules/@gorhom/bottom-sheet/lib/typescript/components/bottomSheet/types.d.ts
@@ -353,6 +353,6 @@ export type BottomSheetGestureProps = {
simultaneousHandlers: Parameters<
PanGesture['simultaneousWithExternalGesture']
- >[0];
- waitFor: Parameters<PanGesture['requireExternalGestureToFail']>[0];
+ >[0] | Parameters<PanGesture['simultaneousWithExternalGesture']>;
+ waitFor: Parameters<PanGesture['requireExternalGestureToFail']>[0] | Parameters<PanGesture['requireExternalGestureToFail']>;
};
diff --git a/node_modules/@gorhom/bottom-sheet/lib/typescript/index.d.ts b/node_modules/@gorhom/bottom-sheet/lib/typescript/index.d.ts
index ffc6caa..550f1b3 100644
--- a/node_modules/@gorhom/bottom-sheet/lib/typescript/index.d.ts
@@ -141,6 +222,57 @@ index ffc6caa..550f1b3 100644
export { useScrollEventsHandlersDefault } from './hooks/useScrollEventsHandlersDefault';
export { useGestureEventsHandlersDefault } from './hooks/useGestureEventsHandlersDefault';
export { useBottomSheetGestureHandlers } from './hooks/useBottomSheetGestureHandlers';
diff --git a/node_modules/@gorhom/bottom-sheet/src/components/bottomSheet/types.d.ts b/node_modules/@gorhom/bottom-sheet/src/components/bottomSheet/types.d.ts
index 6b23056..8d92aae 100644
--- a/node_modules/@gorhom/bottom-sheet/src/components/bottomSheet/types.d.ts
+++ b/node_modules/@gorhom/bottom-sheet/src/components/bottomSheet/types.d.ts
@@ -353,6 +353,6 @@ export type BottomSheetGestureProps = {
simultaneousHandlers: Parameters<
PanGesture['simultaneousWithExternalGesture']
- >[0];
- waitFor: Parameters<PanGesture['requireExternalGestureToFail']>[0];
+ >[0] | Parameters<PanGesture['simultaneousWithExternalGesture']>;
+ waitFor: Parameters<PanGesture['requireExternalGestureToFail']>[0] | Parameters<PanGesture['requireExternalGestureToFail']>;
};
diff --git a/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetDraggableView/BottomSheetDraggableView.tsx b/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetDraggableView/BottomSheetDraggableView.tsx
index 923e430..eeef8cf 100644
--- a/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetDraggableView/BottomSheetDraggableView.tsx
+++ b/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetDraggableView/BottomSheetDraggableView.tsx
@@ -65,12 +65,12 @@ const BottomSheetDraggableViewComponent = ({
.onFinalize(contentPanGestureHandler.handleOnFinalize);
if (waitFor) {
- gesture = gesture.requireExternalGestureToFail(waitFor);
+ gesture = gesture.requireExternalGestureToFail(...(Array.isArray(waitFor) ? waitFor : [waitFor]));
}
if (simultaneousHandlers) {
gesture = gesture.simultaneousWithExternalGesture(
- simultaneousHandlers as never
+ ...(simultaneousHandlers as Parameters<typeof gesture.simultaneousWithExternalGesture>)
);
}
diff --git a/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetHandle/BottomSheetHandleContainer.tsx b/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetHandle/BottomSheetHandleContainer.tsx
index 0223843..4ca56e5 100644
--- a/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetHandle/BottomSheetHandleContainer.tsx
+++ b/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetHandle/BottomSheetHandleContainer.tsx
@@ -67,12 +67,12 @@ function BottomSheetHandleContainerComponent({
.onFinalize(handlePanGestureHandler.handleOnFinalize);
if (waitFor) {
- gesture = gesture.requireExternalGestureToFail(waitFor);
+ gesture = gesture.requireExternalGestureToFail(...(Array.isArray(waitFor) ? waitFor : [waitFor]));
}
if (simultaneousHandlers) {
gesture = gesture.simultaneousWithExternalGesture(
- simultaneousHandlers as never
+ ...(simultaneousHandlers as Parameters<typeof gesture.simultaneousWithExternalGesture>)
);
}
diff --git a/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetModal/BottomSheetModal.tsx b/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetModal/BottomSheetModal.tsx
index 2954b3f..936300b 100644
--- a/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetModal/BottomSheetModal.tsx
@@ -181,7 +181,7 @@ index e4e861a..f485139 100644
declare global {
diff --git a/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx b/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx
index 7c88afc..a97cb80 100644
index 7c88afc..05618ec 100644
--- a/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx
+++ b/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx
@@ -16,9 +16,11 @@ import {
@@ -227,7 +227,37 @@ index 7c88afc..a97cb80 100644
const [layoutAnimationDisabled, setLayoutAnimationDisabled] = useState(
!propsRef.current.enableLayoutAnimationExperimental
);
@@ -144,19 +163,88 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -113,10 +132,26 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
const dataRef = useRef(props.data);
const dataHasChanged =
- dataRef.current.map(keyExtractor).join("") !==
- props.data.map(keyExtractor).join("");
- dataRef.current = props.data;
+ dataRef.current.map(keyExtractor).join("") !== props.data.map(keyExtractor).join("");
+ // Sheet portals can deliver reordered data after the drop callback commits.
+ // Keep the held transforms until that data arrives, then renew moved hosts
+ // in the same render as their new slots.
if (dataHasChanged) {
+ const wasDragging = activeKey !== null;
+ const previousIndices = new Map(
+ dataRef.current.map((item, index) => [keyExtractor(item, index), index]),
+ );
+ setTransformHostKeys((previous) => {
+ const next = new Map<string, number>();
+ props.data.forEach((item, index) => {
+ const key = keyExtractor(item, index);
+ const generation = previous.get(key) ?? 0;
+ next.set(key, generation + (wasDragging && previousIndices.get(key) !== index ? 1 : 0));
+ });
+ return next;
+ });
+ dataRef.current = props.data;
+ setActiveKey(null);
// When data changes make sure `activeKey` is nulled out in the same render pass
activeKey = null;
InteractionManager.runAfterInteractions(() => {
@@ -144,19 +179,88 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
});
}, [props.data, keyExtractor, keyToIndexRef]);
@@ -323,7 +353,7 @@ index 7c88afc..a97cb80 100644
setActiveKey(activeKey);
onDragBegin?.(index);
}
@@ -218,14 +306,46 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -218,8 +322,25 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
props.onRelease?.(index);
});
@@ -350,28 +380,18 @@ index 7c88afc..a97cb80 100644
const { onDragEnd, data } = props;
const newData = [...data];
if (from !== to) {
newData.splice(from, 1);
newData.splice(to, 0, data[from]);
+ const movedKeys = data
+ .slice(Math.min(from, to), Math.max(from, to) + 1)
+ .map((item, offset) => keyExtractor(item, Math.min(from, to) + offset));
+ setTransformHostKeys((previous) => {
+ const next = new Map<string, number>();
+ data.forEach((item, index) => {
+ const itemKey = keyExtractor(item, index);
+ const hostKey = previous.get(itemKey);
+ if (hostKey !== undefined) next.set(itemKey, hostKey);
+ });
+ for (const movedKey of movedKeys) {
+ next.set(movedKey, (previous.get(movedKey) ?? 0) + 1);
+ }
+ return next;
+ });
}
@@ -230,34 +351,15 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
onDragEnd?.({ from, to, data: newData });
@@ -238,26 +358,6 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
- setActiveKey(null);
- }
+ // A drop in place doesn't change data, so no data-change reset will clear the armed index.
+ if (from === to) reset();
+ },
);
const onPlaceholderIndexChange = useStableCallback((index: number) => {
props.onPlaceholderIndexChange?.(index);
});
@@ -398,7 +418,7 @@ index 7c88afc..a97cb80 100644
useAnimatedReaction(
() => {
return spacerIndexAnim.value;
@@ -271,11 +371,26 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -271,11 +373,26 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
);
const gestureDisabled = useSharedValue(false);
@@ -426,7 +446,7 @@ index 7c88afc..a97cb80 100644
panGestureState.value = evt.state;
})
.onUpdate((evt) => {
@@ -286,8 +401,15 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -286,8 +403,15 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
: evt.translationY;
touchTranslate.value = translation;
})
@@ -443,7 +463,7 @@ index 7c88afc..a97cb80 100644
// Set touch val to current translate val
isTouchActiveNative.value = false;
const translation = horizontalAnim.value
@@ -299,6 +421,7 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -299,6 +423,7 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
// Only call onDragEnd if actually dragging a cell
if (activeIndexAnim.value === -1 || disabled.value) return;
@@ -451,7 +471,7 @@ index 7c88afc..a97cb80 100644
disabled.value = true;
runOnJS(onRelease)(activeIndexAnim.value);
const springTo = placeholderOffset.value - activeCellOffset.value;
@@ -309,22 +432,85 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -309,22 +434,85 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
runOnJS(onDragEnd)({
from: activeIndexAnim.value,
to: spacerIndexAnim.value,
@@ -540,7 +560,7 @@ index 7c88afc..a97cb80 100644
const activeOffset = [-activationDistanceProp, activationDistanceProp];
if (props.horizontal) {
panGesture.activeOffsetX(activeOffset);
@@ -332,6 +518,21 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -332,6 +520,21 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
panGesture.activeOffsetY(activeOffset);
}
}
@@ -562,7 +582,7 @@ index 7c88afc..a97cb80 100644
const onScroll = useStableCallback((scrollOffset: number) => {
props.onScrollOffsetChange?.(scrollOffset);
@@ -372,9 +573,11 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -372,9 +575,11 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
keyExtractor={keyExtractor}
horizontal={!!props.horizontal}
layoutAnimationDisabled={layoutAnimationDisabled}
+3 -6
View File
@@ -1279,12 +1279,9 @@ async function publishReview(agentId: string, { paseo }: PluginHandlerContext) {
The daemon stamps `pluginId` from the calling plugin session and rejects this RPC from non-plugin
sessions. The row appears live, survives timeline refetches, and keeps only the latest value for the
same plugin and `id`. If its renderer is missing, Paseo shows the existing unavailable row.
`kind` starts with a lowercase letter and contains lowercase letters, numbers, or hyphens, the same
rule `addTimelineRenderer` applies. Daemons reject any other `kind`. Daemons reject `data` over the
limit rather than truncating it. Daemons that support this operation advertise
`server_info.features.pluginTimelineItems`.
same plugin and `id`. If its renderer is missing, Paseo shows the existing unavailable row. Daemons
reject `data` over the limit rather than truncating it. Daemons that support this operation
advertise `server_info.features.pluginTimelineItems`.
## Theme and layout
+1 -3
View File
@@ -1060,9 +1060,7 @@ async function publishReview(agentId: string, { paseo }: PluginHandlerContext) {
| `version` | 正整数 | 是 | 选择渲染器契约的版本。 |
| `data` | 兼容 JSON | 是 | 渲染器负载,JSON 序列化后最多 64 KiB。 |
daemon 会根据发起调用的插件会话写入 `pluginId`,并拒绝来自非插件会话的此 RPC。该行会实时出现,在时间线重新获取后依然保留,并且对于同一插件和 `id` 只保留最新的值。如果它的渲染器缺失,Paseo 会显示现有的“不可用”行。
`kind` 以小写字母开头,只包含小写字母、数字或连字符,与 `addTimelineRenderer` 使用的规则相同。daemon 会拒绝其他任何 `kind`。daemon 会拒绝超过限制的 `data`,而不是截断它。支持此操作的 daemon 会声明 `server_info.features.pluginTimelineItems`。
daemon 会根据发起调用的插件会话写入 `pluginId`,并拒绝来自非插件会话的此 RPC。该行会实时出现,在时间线重新获取后依然保留,并且对于同一插件和 `id` 只保留最新的值。如果它的渲染器缺失,Paseo 会显示现有的“不可用”行。daemon 会拒绝超过限制的 `data`,而不是截断它。支持此操作的 daemon 会声明 `server_info.features.pluginTimelineItems`。
## 主题与布局 {#theme-and-layout}