@omercnet/paseo-shared-browser 1.2.2-next.232.1 → 1.2.2-next.239.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +312 -25
- package/REMOTE_BROWSER_RESEARCH.md +395 -0
- package/STREAMING_DESIGN.md +191 -0
- package/client/browser-auto-capture-density.test.ts +102 -0
- package/client/browser-auto-capture-density.ts +30 -0
- package/client/browser-canvas-input.test.ts +198 -0
- package/client/browser-canvas-input.ts +202 -0
- package/client/browser-canvas-keyboard-web.test.ts +113 -0
- package/client/browser-canvas-keyboard-web.ts +230 -0
- package/client/browser-canvas-keyboard.test.ts +123 -0
- package/client/browser-canvas-keyboard.ts +126 -0
- package/client/browser-canvas-layout.test.ts +109 -0
- package/client/browser-canvas-layout.ts +51 -0
- package/client/browser-canvas-viewport-web.test.ts +77 -0
- package/client/browser-canvas-viewport-web.ts +63 -0
- package/client/browser-canvas-viewport.tsx +152 -0
- package/client/browser-capture-cadence.test.ts +9 -0
- package/client/browser-capture-cadence.ts +6 -0
- package/client/browser-capture-density-controls.tsx +75 -0
- package/client/browser-chrome.tsx +234 -0
- package/client/browser-client-presence.ts +184 -0
- package/client/browser-compose-controls.test.tsx +275 -0
- package/client/browser-compose-controls.tsx +174 -0
- package/client/browser-control-button.tsx +109 -0
- package/client/browser-control-dialog.tsx +71 -0
- package/client/browser-control-request.test.ts +27 -0
- package/client/browser-control-request.ts +57 -0
- package/client/browser-control-tooltip-web.ts +10 -0
- package/client/browser-dialog-body.tsx +22 -0
- package/client/browser-emulation-mode.test.ts +75 -0
- package/client/browser-emulation-mode.ts +98 -0
- package/client/browser-fill-viewport.test.ts +45 -0
- package/client/browser-fill-viewport.ts +28 -0
- package/client/browser-frame-buffer.test.ts +157 -0
- package/client/browser-frame-buffer.ts +148 -0
- package/client/browser-frame-image.tsx +62 -0
- package/client/browser-image-load.test.ts +23 -0
- package/client/browser-image-load.ts +26 -0
- package/client/browser-input-queue.test.ts +1201 -0
- package/client/browser-input-queue.ts +505 -0
- package/client/browser-menu-placement.test.ts +90 -0
- package/client/browser-menu-placement.ts +78 -0
- package/client/browser-panel-styles.ts +384 -0
- package/client/browser-quality-controls.test.ts +26 -0
- package/client/browser-quality-controls.tsx +139 -0
- package/client/browser-resolution-picker.tsx +189 -0
- package/client/browser-tabs.tsx +117 -0
- package/client/browser-toolbar-menu-web.test.ts +185 -0
- package/client/browser-toolbar-menu-web.ts +159 -0
- package/client/browser-toolbar-menu.tsx +255 -0
- package/client/browser-toolbar-submenu.tsx +93 -0
- package/client/browser-video-bytes.test.ts +44 -0
- package/client/browser-video-decoder.test.ts +473 -0
- package/client/browser-video-decoder.ts +311 -0
- package/client/browser-video-surface.tsx +21 -0
- package/client/browser-video-visibility.test.ts +45 -0
- package/client/browser-viewer-recovery.test.ts +111 -0
- package/client/browser-viewer-recovery.ts +78 -0
- package/client/browser.tsx +1642 -1569
- package/client/favorite-star.tsx +23 -0
- package/client/frame-lifecycle.ts +62 -59
- package/client/use-browser-auto-capture-density.test.ts +104 -0
- package/client/use-browser-auto-capture-density.ts +43 -0
- package/client/use-browser-canvas-input.ts +370 -0
- package/client/use-browser-capture-density.ts +87 -0
- package/client/use-browser-emulation-mode.ts +56 -0
- package/client/use-browser-fill-viewport.test.ts +108 -0
- package/client/use-browser-fill-viewport.ts +62 -0
- package/client/use-browser-frame-buffer.ts +66 -0
- package/client/use-browser-image-capture.ts +139 -0
- package/client/use-browser-scoped-mutation.test.ts +118 -0
- package/client/use-browser-scoped-mutation.ts +59 -0
- package/client/use-browser-video.test.ts +991 -0
- package/client/use-browser-video.ts +474 -0
- package/client/use-browser-viewer-recovery.ts +26 -0
- package/client/use-resolution-favorites.ts +90 -0
- package/client/web.test.ts +125 -0
- package/client/web.ts +405 -0
- package/index.client.tsx +0 -10
- package/index.server.ts +32 -0
- package/npm-shrinkwrap.json +7 -7
- package/package.json +9 -5
- package/paseo-plugin.json +3 -1
- package/scripts/prepare-runtime.mjs +1 -1
- package/server/agent-browser-runtime.ts +1598 -314
- package/server/browser-cursor.test.ts +64 -0
- package/server/browser-cursor.ts +69 -0
- package/server/browser-frame-context.ts +22 -0
- package/server/browser-frame-epoch.test.ts +95 -0
- package/server/browser-frame-rejection.ts +17 -0
- package/server/browser-gesture.ts +120 -0
- package/server/browser-operation-diagnostics.test.ts +79 -0
- package/server/browser-operation-diagnostics.ts +103 -0
- package/server/browser-policy.ts +1313 -151
- package/server/browser-video-input-authority.test.ts +208 -0
- package/server/browser-video-policy.test.ts +564 -0
- package/server/browser-video-socket.test.ts +368 -0
- package/server/browser.ts +151 -16
- package/server/capture-transport-policy.test.ts +105 -0
- package/server/capture-transport-policy.ts +154 -0
- package/server/capture-transport-runtime.test.ts +213 -0
- package/server/daemon-launch-chain.test.ts +70 -0
- package/server/daemon-shutdown.test.ts +289 -0
- package/server/discrete-input-freshness.test.ts +364 -0
- package/server/discrete-input-native-lifetime.test.ts +150 -0
- package/server/display-loss-recovery.test.ts +294 -0
- package/server/emulation-restoration.test.ts +262 -0
- package/server/input-cleanup.ts +23 -0
- package/server/input-generation.test.ts +31 -0
- package/server/input-generation.ts +36 -0
- package/server/jpeg-capture-demand.test.ts +76 -0
- package/server/jpeg-capture-demand.ts +98 -0
- package/server/jpeg-frame.ts +29 -0
- package/server/keyboard-input.test.ts +61 -0
- package/server/keyboard-input.ts +104 -0
- package/server/live-input-policy.test.ts +958 -0
- package/server/live-input-runtime.test.ts +732 -0
- package/server/live-input-supervisor.test.ts +147 -0
- package/server/mcp-entry.ts +44 -2
- package/server/native-video-backpressure.test.ts +228 -0
- package/server/native-video-buffer.test.ts +141 -0
- package/server/native-video-buffer.ts +114 -0
- package/server/native-video-capture.test.ts +599 -0
- package/server/native-video-capture.ts +497 -0
- package/server/native-video-extension-source.test.ts +201 -0
- package/server/native-video-extension-source.ts +364 -0
- package/server/native-video-extension.test.ts +41 -0
- package/server/native-video-extension.ts +83 -0
- package/server/native-video-packet.ts +9 -0
- package/server/native-video-settings.test.ts +245 -0
- package/server/navigation-budget.ts +8 -0
- package/server/navigation-commit.ts +125 -0
- package/server/navigation-policy.test.ts +153 -0
- package/server/process-identity.ts +58 -0
- package/server/reattach-held-input.test.ts +143 -0
- package/server/runtime-frame-reuse.test.ts +295 -0
- package/server/runtime-jpeg-demand.test.ts +200 -0
- package/server/runtime-jpeg-shutdown.test.ts +210 -0
- package/server/runtime-jpeg-source-order.test.ts +201 -0
- package/server/runtime-launch-environment.test.ts +111 -0
- package/server/runtime-navigation.test.ts +327 -0
- package/server/runtime-owner-display.test.ts +152 -0
- package/server/runtime-owner.ts +305 -31
- package/server/runtime-page-state.test.ts +249 -0
- package/server/runtime-protocol.ts +31 -1
- package/server/runtime-target-retention.test.ts +181 -0
- package/server/runtime-video-opt-in.test.ts +82 -0
- package/server/runtime-video.test.ts +444 -0
- package/server/screencast-source-time.ts +46 -0
- package/server/stale-wheel-admission.test.ts +136 -0
- package/server/supervisor-client.ts +28 -4
- package/server/supervisor-heartbeat.test.ts +80 -0
- package/server/supervisor.ts +553 -55
- package/server/video-source-recovery.test.ts +20 -0
- package/server/video-source-recovery.ts +64 -0
- package/server/viewer-capture-lifetime.ts +51 -0
- package/server/virtual-display.test.ts +184 -0
- package/server/virtual-display.ts +236 -0
- package/shared/browser-display-preferences.ts +46 -0
- package/shared/browser-video.ts +80 -0
- package/shared/browser.ts +286 -65
- package/shared/capture-density.ts +16 -0
- package/shared/capture-settings.ts +17 -0
- package/shared/device-presets.ts +90 -0
- package/shared/resolution-menu.test.ts +120 -0
- package/shared/resolution-menu.ts +72 -0
- package/shared/video-settings.test.ts +38 -0
- package/shared/video-settings.ts +34 -0
- package/shared/viewport-limits.ts +5 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/** Own composer-pill presence subscription and paging; teardown fences late directory replies and releases only this contribution. */
|
|
2
|
+
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
3
|
+
import { listOpenBrowserWorkspacesRpc } from "../shared/browser";
|
|
4
|
+
|
|
5
|
+
const PILL_PRESENCE_POLL_MS = 2_000;
|
|
6
|
+
const AGENT_DIRECTORY_PAGE_LIMIT = 200;
|
|
7
|
+
|
|
8
|
+
interface AgentDirectoryPage {
|
|
9
|
+
entries: Array<{ agent: { id: string; workspaceId?: string | undefined } }>;
|
|
10
|
+
pageInfo: { hasMore: boolean; nextCursor: string | null };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type AgentDirectoryUpdate =
|
|
14
|
+
| { kind: "remove"; agentId: string }
|
|
15
|
+
| { kind: "upsert"; agent: { id: string; workspaceId?: string | undefined } };
|
|
16
|
+
type AgentPlacement = { id: string; workspaceId: string };
|
|
17
|
+
|
|
18
|
+
/** Add workspace browser pills for active agents; cleanup is idempotent and stops late publication. */
|
|
19
|
+
export function contributeSharedBrowserClient(client: PluginClientContext) {
|
|
20
|
+
const agents = new Map<string, AgentPlacement>();
|
|
21
|
+
const pills = new Map<string, { workspaceId: string; remove: () => void }>();
|
|
22
|
+
const lifetime = new AbortController();
|
|
23
|
+
let openWorkspaceIds = new Set<string>();
|
|
24
|
+
let refreshing = false;
|
|
25
|
+
let stopped = false;
|
|
26
|
+
let directoryGeneration = 0;
|
|
27
|
+
let pendingDirectory: { generation: number; updates: AgentDirectoryUpdate[] } | null = null;
|
|
28
|
+
let unsubscribeDirectory: (() => void) | null = null;
|
|
29
|
+
let releaseDirectory: (() => Promise<void>) | null = null;
|
|
30
|
+
|
|
31
|
+
const removePill = (agentId: string) => {
|
|
32
|
+
pills.get(agentId)?.remove();
|
|
33
|
+
pills.delete(agentId);
|
|
34
|
+
};
|
|
35
|
+
const syncPill = (agent: AgentPlacement) => {
|
|
36
|
+
const current = pills.get(agent.id);
|
|
37
|
+
if (!openWorkspaceIds.has(agent.workspaceId)) {
|
|
38
|
+
removePill(agent.id);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (current?.workspaceId === agent.workspaceId) return;
|
|
42
|
+
removePill(agent.id);
|
|
43
|
+
const workspaceId = agent.workspaceId;
|
|
44
|
+
const pill = client.addComposerPill({
|
|
45
|
+
id: "open-shared-browser",
|
|
46
|
+
workspaceId,
|
|
47
|
+
agentId: agent.id,
|
|
48
|
+
button: {
|
|
49
|
+
title: "Open Shared Browser",
|
|
50
|
+
icon: "PanelsTopLeft",
|
|
51
|
+
label: "Shared Browser",
|
|
52
|
+
behavior: {
|
|
53
|
+
kind: "action",
|
|
54
|
+
onPress() {
|
|
55
|
+
client.openPanel("shared-browser", { workspaceId });
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
pills.set(agent.id, { workspaceId, remove: pill.remove });
|
|
61
|
+
};
|
|
62
|
+
const syncAllPills = () => {
|
|
63
|
+
for (const agent of agents.values()) syncPill(agent);
|
|
64
|
+
};
|
|
65
|
+
const refreshPresence = async () => {
|
|
66
|
+
if (stopped || refreshing) return;
|
|
67
|
+
refreshing = true;
|
|
68
|
+
try {
|
|
69
|
+
const result = await client.rpc(listOpenBrowserWorkspacesRpc, {});
|
|
70
|
+
if (stopped) return;
|
|
71
|
+
openWorkspaceIds = new Set(result.workspaceIds);
|
|
72
|
+
syncAllPills();
|
|
73
|
+
} catch {
|
|
74
|
+
return;
|
|
75
|
+
} finally {
|
|
76
|
+
refreshing = false;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const applyUpdate = (target: Map<string, AgentPlacement>, update: AgentDirectoryUpdate) => {
|
|
80
|
+
if (update.kind === "remove") {
|
|
81
|
+
target.delete(update.agentId);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const { id, workspaceId } = update.agent;
|
|
85
|
+
if (workspaceId) target.set(id, { id, workspaceId });
|
|
86
|
+
else target.delete(id);
|
|
87
|
+
};
|
|
88
|
+
const applyLiveUpdate = (update: AgentDirectoryUpdate) => {
|
|
89
|
+
if (stopped) return;
|
|
90
|
+
pendingDirectory?.updates.push(update);
|
|
91
|
+
applyUpdate(agents, update);
|
|
92
|
+
if (update.kind === "remove") {
|
|
93
|
+
removePill(update.agentId);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const { id, workspaceId } = update.agent;
|
|
97
|
+
if (!workspaceId) {
|
|
98
|
+
removePill(id);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
syncPill({ id, workspaceId });
|
|
102
|
+
void refreshPresence();
|
|
103
|
+
};
|
|
104
|
+
const replaceAgents = (next: Map<string, AgentPlacement>) => {
|
|
105
|
+
for (const agentId of agents.keys()) {
|
|
106
|
+
if (!next.has(agentId)) removePill(agentId);
|
|
107
|
+
}
|
|
108
|
+
agents.clear();
|
|
109
|
+
for (const [agentId, agent] of next) agents.set(agentId, agent);
|
|
110
|
+
syncAllPills();
|
|
111
|
+
};
|
|
112
|
+
const followSnapshot = async (snapshot: AgentDirectoryPage) => {
|
|
113
|
+
if (stopped) return;
|
|
114
|
+
const generation = ++directoryGeneration;
|
|
115
|
+
const transaction = { generation, updates: [] as AgentDirectoryUpdate[] };
|
|
116
|
+
pendingDirectory = transaction;
|
|
117
|
+
const next = new Map<string, AgentPlacement>();
|
|
118
|
+
for (const { agent } of snapshot.entries) {
|
|
119
|
+
if (agent.workspaceId) next.set(agent.id, { id: agent.id, workspaceId: agent.workspaceId });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
let cursor = snapshot.pageInfo.hasMore ? snapshot.pageInfo.nextCursor : null;
|
|
124
|
+
while (cursor) {
|
|
125
|
+
const page = await client.paseo.agents.list({
|
|
126
|
+
scope: "active",
|
|
127
|
+
page: { limit: AGENT_DIRECTORY_PAGE_LIMIT, cursor },
|
|
128
|
+
signal: lifetime.signal,
|
|
129
|
+
});
|
|
130
|
+
if (stopped || pendingDirectory?.generation !== generation) return;
|
|
131
|
+
for (const { agent } of page.entries) {
|
|
132
|
+
if (agent.workspaceId)
|
|
133
|
+
next.set(agent.id, { id: agent.id, workspaceId: agent.workspaceId });
|
|
134
|
+
}
|
|
135
|
+
cursor = page.pageInfo.hasMore ? page.pageInfo.nextCursor : null;
|
|
136
|
+
}
|
|
137
|
+
if (stopped || pendingDirectory?.generation !== generation) return;
|
|
138
|
+
for (const update of transaction.updates) applyUpdate(next, update);
|
|
139
|
+
pendingDirectory = null;
|
|
140
|
+
replaceAgents(next);
|
|
141
|
+
await refreshPresence();
|
|
142
|
+
} catch {
|
|
143
|
+
if (!stopped && pendingDirectory?.generation === generation) pendingDirectory = null;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
void client.paseo.agents
|
|
148
|
+
.list({
|
|
149
|
+
scope: "active",
|
|
150
|
+
page: { limit: AGENT_DIRECTORY_PAGE_LIMIT },
|
|
151
|
+
subscribe: {},
|
|
152
|
+
signal: lifetime.signal,
|
|
153
|
+
})
|
|
154
|
+
.then(({ subscription }) => {
|
|
155
|
+
if (stopped) {
|
|
156
|
+
void subscription.release().catch(() => undefined);
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
releaseDirectory = subscription.release;
|
|
160
|
+
unsubscribeDirectory = subscription.subscribe({
|
|
161
|
+
snapshot: (snapshot) => void followSnapshot(snapshot),
|
|
162
|
+
update: (message) => {
|
|
163
|
+
if (message.type === "agent_update") applyLiveUpdate(message.payload);
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
return undefined;
|
|
167
|
+
})
|
|
168
|
+
.catch(() => undefined);
|
|
169
|
+
const presenceTimer = setInterval(() => void refreshPresence(), PILL_PRESENCE_POLL_MS);
|
|
170
|
+
|
|
171
|
+
return () => {
|
|
172
|
+
if (stopped) return;
|
|
173
|
+
stopped = true;
|
|
174
|
+
directoryGeneration += 1;
|
|
175
|
+
pendingDirectory = null;
|
|
176
|
+
clearInterval(presenceTimer);
|
|
177
|
+
unsubscribeDirectory?.();
|
|
178
|
+
void releaseDirectory?.().catch(() => undefined);
|
|
179
|
+
lifetime.abort();
|
|
180
|
+
for (const { remove } of pills.values()) remove();
|
|
181
|
+
pills.clear();
|
|
182
|
+
agents.clear();
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Minimal hook runtime: one component instance, state kept across renders.
|
|
4
|
+
const hooks = vi.hoisted(() => {
|
|
5
|
+
const runtime = {
|
|
6
|
+
slots: [] as unknown[],
|
|
7
|
+
index: 0,
|
|
8
|
+
effects: [] as Array<() => void>,
|
|
9
|
+
rerender: () => {},
|
|
10
|
+
};
|
|
11
|
+
return runtime;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
vi.mock("react", () => ({
|
|
15
|
+
useRef: (initial: unknown) => {
|
|
16
|
+
const i = hooks.index++;
|
|
17
|
+
hooks.slots[i] ??= { current: initial };
|
|
18
|
+
return hooks.slots[i];
|
|
19
|
+
},
|
|
20
|
+
useState: (initial: unknown) => {
|
|
21
|
+
const i = hooks.index++;
|
|
22
|
+
hooks.slots[i] ??= initial;
|
|
23
|
+
return [
|
|
24
|
+
hooks.slots[i],
|
|
25
|
+
(next: unknown) => {
|
|
26
|
+
hooks.slots[i] =
|
|
27
|
+
typeof next === "function" ? (next as (v: unknown) => unknown)(hooks.slots[i]) : next;
|
|
28
|
+
hooks.rerender();
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
},
|
|
32
|
+
useEffect: (effect: () => void) => {
|
|
33
|
+
hooks.effects.push(effect);
|
|
34
|
+
},
|
|
35
|
+
}));
|
|
36
|
+
vi.mock("@getpaseo/plugin/client/react-native", () => {
|
|
37
|
+
const Modal = Object.assign(() => null, { Content: () => null });
|
|
38
|
+
return { Modal, TextInput: () => null };
|
|
39
|
+
});
|
|
40
|
+
vi.mock("react-native", () => ({ Text: () => null, View: () => null }));
|
|
41
|
+
vi.mock("./browser-chrome", () => ({ ControlButton: () => null }));
|
|
42
|
+
|
|
43
|
+
import { ComposeTextControls } from "./browser-compose-controls";
|
|
44
|
+
|
|
45
|
+
type Node = { props?: Record<string, unknown> } | Node[] | string | null | undefined;
|
|
46
|
+
function find(
|
|
47
|
+
node: Node,
|
|
48
|
+
match: (props: Record<string, unknown>) => boolean,
|
|
49
|
+
): Record<string, unknown> {
|
|
50
|
+
const stack: Node[] = [node];
|
|
51
|
+
while (stack.length) {
|
|
52
|
+
const current = stack.pop();
|
|
53
|
+
if (Array.isArray(current)) stack.push(...current);
|
|
54
|
+
else if (current && typeof current === "object" && current.props) {
|
|
55
|
+
if (match(current.props)) return current.props;
|
|
56
|
+
stack.push(current.props.children as Node);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
throw new Error("element not found");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const composeText = vi.fn((_text: string) => true);
|
|
63
|
+
const cancelInput = vi.fn();
|
|
64
|
+
const onRequestControlForCompose = vi.fn();
|
|
65
|
+
let props: Parameters<typeof ComposeTextControls>[0];
|
|
66
|
+
let tree: Node;
|
|
67
|
+
|
|
68
|
+
function render(overrides: Partial<typeof props> = {}) {
|
|
69
|
+
props = { ...props, ...overrides };
|
|
70
|
+
hooks.index = 0;
|
|
71
|
+
hooks.effects = [];
|
|
72
|
+
tree = ComposeTextControls(props) as Node;
|
|
73
|
+
// Effects run after render, as in React; state they set re-renders.
|
|
74
|
+
for (const effect of hooks.effects) effect();
|
|
75
|
+
return tree;
|
|
76
|
+
}
|
|
77
|
+
const done = () => find(tree, (p) => p.label === "Done");
|
|
78
|
+
const draft = (text: string) =>
|
|
79
|
+
(find(tree, (p) => "onChangeText" in p).onChangeText as (t: string) => void)(text);
|
|
80
|
+
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
hooks.slots = [];
|
|
83
|
+
composeText.mockClear();
|
|
84
|
+
cancelInput.mockClear();
|
|
85
|
+
onRequestControlForCompose.mockClear();
|
|
86
|
+
hooks.rerender = () => {
|
|
87
|
+
hooks.index = 0;
|
|
88
|
+
hooks.effects = [];
|
|
89
|
+
tree = ComposeTextControls(props) as Node;
|
|
90
|
+
};
|
|
91
|
+
props = {
|
|
92
|
+
styles: {} as never,
|
|
93
|
+
theme: {} as never,
|
|
94
|
+
composeText,
|
|
95
|
+
cancelInput,
|
|
96
|
+
enabled: true,
|
|
97
|
+
ownershipKey: "page-1",
|
|
98
|
+
documentKey: "doc-1",
|
|
99
|
+
request: null,
|
|
100
|
+
onRequestHandled: vi.fn(),
|
|
101
|
+
onRequestControlForCompose,
|
|
102
|
+
canRequestControl: false,
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
function open() {
|
|
107
|
+
render({ request: { id: 1, kind: "compose", ownershipKey: "page-1" } });
|
|
108
|
+
draft("hello");
|
|
109
|
+
render();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
describe("ComposeTextControls", () => {
|
|
113
|
+
it("opening cancels held canvas input and Done inserts once even when pressed rapidly", () => {
|
|
114
|
+
open();
|
|
115
|
+
expect(cancelInput).toHaveBeenCalledTimes(1);
|
|
116
|
+
const press = done().onPress as () => void;
|
|
117
|
+
press();
|
|
118
|
+
press();
|
|
119
|
+
expect(composeText).toHaveBeenCalledTimes(1);
|
|
120
|
+
expect(composeText).toHaveBeenCalledWith("hello");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("a second Done arriving while the first is being published inserts nothing more", () => {
|
|
124
|
+
open();
|
|
125
|
+
const press = done().onPress as () => void;
|
|
126
|
+
composeText.mockImplementationOnce(() => {
|
|
127
|
+
press();
|
|
128
|
+
return true;
|
|
129
|
+
});
|
|
130
|
+
press();
|
|
131
|
+
expect(composeText).toHaveBeenCalledTimes(1);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("keeps the draft for review when nothing was admitted, then allows one retry", () => {
|
|
135
|
+
composeText.mockReturnValueOnce(false);
|
|
136
|
+
open();
|
|
137
|
+
(done().onPress as () => void)();
|
|
138
|
+
(done().onPress as () => void)();
|
|
139
|
+
expect(composeText).toHaveBeenCalledTimes(2);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("a retained Done callback cannot insert after ownership changes; the same document rebinds once", () => {
|
|
143
|
+
open();
|
|
144
|
+
const stale = done().onPress as () => void;
|
|
145
|
+
render({ ownershipKey: "page-2" });
|
|
146
|
+
stale();
|
|
147
|
+
expect(composeText).not.toHaveBeenCalled();
|
|
148
|
+
(done().onPress as () => void)();
|
|
149
|
+
expect(composeText).toHaveBeenCalledTimes(1);
|
|
150
|
+
expect(composeText).toHaveBeenCalledWith("hello");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("a replaced document refuses Done entirely", () => {
|
|
154
|
+
open();
|
|
155
|
+
const stale = done().onPress as () => void;
|
|
156
|
+
render({ ownershipKey: "page-2", documentKey: "doc-2" });
|
|
157
|
+
expect(done().disabled).toBe(true);
|
|
158
|
+
stale();
|
|
159
|
+
expect(composeText).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("without control, Done asks for control instead of inserting", () => {
|
|
163
|
+
open();
|
|
164
|
+
render({ enabled: false, canRequestControl: true });
|
|
165
|
+
(done().onPress as () => void)();
|
|
166
|
+
expect(composeText).not.toHaveBeenCalled();
|
|
167
|
+
expect(onRequestControlForCompose).toHaveBeenCalledTimes(1);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("a retained Done callback cannot insert after control is lost", () => {
|
|
171
|
+
open();
|
|
172
|
+
const stale = done().onPress as () => void;
|
|
173
|
+
render({ enabled: false });
|
|
174
|
+
stale();
|
|
175
|
+
expect(composeText).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("keeps every character of input that arrives faster than a re-render, and never echoes a value back", () => {
|
|
179
|
+
render({ request: { id: 1, kind: "compose", ownershipKey: "page-1" } });
|
|
180
|
+
const field = find(tree, (p) => "onChangeText" in p);
|
|
181
|
+
expect("value" in field).toBe(false);
|
|
182
|
+
// One retained handler, no render between events: the host input owns the text.
|
|
183
|
+
const change = field.onChangeText as (t: string) => void;
|
|
184
|
+
for (const next of ["h", "he", "hel", "hell", "hello", "hello日", "hello日本"]) change(next);
|
|
185
|
+
render();
|
|
186
|
+
(done().onPress as () => void)();
|
|
187
|
+
expect(composeText).toHaveBeenCalledTimes(1);
|
|
188
|
+
expect(composeText).toHaveBeenCalledWith("hello日本");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("commits ten rapid characters and an IME-style whole-string replacement exactly", () => {
|
|
192
|
+
render({ request: { id: 1, kind: "compose", ownershipKey: "page-1" } });
|
|
193
|
+
const change = find(tree, (p) => "onChangeText" in p).onChangeText as (t: string) => void;
|
|
194
|
+
for (let i = 1; i <= 10; i++) change("abcdefghij".slice(0, i));
|
|
195
|
+
change("にほんご"); // composition commit replaces the whole value
|
|
196
|
+
render();
|
|
197
|
+
(done().onPress as () => void)();
|
|
198
|
+
expect(composeText).toHaveBeenCalledWith("にほんご");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("clears the draft on close and reopen, and ignores a retained handler from the old draft", () => {
|
|
202
|
+
render({ request: { id: 1, kind: "compose", ownershipKey: "page-1" } });
|
|
203
|
+
const retained = find(tree, (p) => "onChangeText" in p).onChangeText as (t: string) => void;
|
|
204
|
+
retained("old draft");
|
|
205
|
+
render();
|
|
206
|
+
(find(tree, (p) => p.label === "Cancel").onPress as () => void)();
|
|
207
|
+
retained("late old text");
|
|
208
|
+
render({ request: { id: 2, kind: "compose", ownershipKey: "page-1" } });
|
|
209
|
+
expect(done().disabled).toBe(true);
|
|
210
|
+
(done().onPress as () => void)();
|
|
211
|
+
expect(composeText).not.toHaveBeenCalled();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const cancel = () => find(tree, (p) => p.label === "Cancel").onPress as () => void;
|
|
215
|
+
const onOpenChange = () =>
|
|
216
|
+
find(tree, (p) => "onOpenChange" in p).onOpenChange as (o: boolean) => void;
|
|
217
|
+
const reopen = (id: number) => {
|
|
218
|
+
cancel()();
|
|
219
|
+
render({ request: { id, kind: "compose", ownershipKey: "page-1" } });
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
it("a retained Done from a closed draft cannot publish or close a same-owner replacement", () => {
|
|
223
|
+
open();
|
|
224
|
+
const staleDone = done().onPress as () => void;
|
|
225
|
+
reopen(2);
|
|
226
|
+
draft("new draft");
|
|
227
|
+
render();
|
|
228
|
+
staleDone();
|
|
229
|
+
expect(composeText).not.toHaveBeenCalled();
|
|
230
|
+
(done().onPress as () => void)();
|
|
231
|
+
expect(composeText).toHaveBeenCalledTimes(1);
|
|
232
|
+
expect(composeText).toHaveBeenCalledWith("new draft");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("a retained Cancel or dismissal from a closed draft cannot close the replacement", () => {
|
|
236
|
+
open();
|
|
237
|
+
const staleCancel = cancel();
|
|
238
|
+
const staleDismiss = onOpenChange();
|
|
239
|
+
reopen(2);
|
|
240
|
+
draft("keep me");
|
|
241
|
+
render();
|
|
242
|
+
staleCancel();
|
|
243
|
+
staleDismiss(false);
|
|
244
|
+
render();
|
|
245
|
+
expect(find(tree, (p) => "open" in p && "onOpenChange" in p).open).toBe(true);
|
|
246
|
+
(done().onPress as () => void)();
|
|
247
|
+
expect(composeText).toHaveBeenCalledWith("keep me");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("a commit that synchronously opens a new draft publishes once and leaves that draft open", () => {
|
|
251
|
+
open();
|
|
252
|
+
composeText.mockImplementationOnce(() => {
|
|
253
|
+
reopen(2);
|
|
254
|
+
draft("second");
|
|
255
|
+
return true;
|
|
256
|
+
});
|
|
257
|
+
(done().onPress as () => void)();
|
|
258
|
+
render();
|
|
259
|
+
expect(find(tree, (p) => "open" in p && "onOpenChange" in p).open).toBe(true);
|
|
260
|
+
expect(composeText).toHaveBeenCalledTimes(1);
|
|
261
|
+
(done().onPress as () => void)();
|
|
262
|
+
expect(composeText).toHaveBeenLastCalledWith("second");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("a retained Done from a closed draft neither rebinds nor requests control", () => {
|
|
266
|
+
open();
|
|
267
|
+
render({ enabled: false, canRequestControl: true });
|
|
268
|
+
const staleDone = done().onPress as () => void;
|
|
269
|
+
reopen(2);
|
|
270
|
+
render({ enabled: false, canRequestControl: true });
|
|
271
|
+
staleDone();
|
|
272
|
+
expect(onRequestControlForCompose).not.toHaveBeenCalled();
|
|
273
|
+
expect(composeText).not.toHaveBeenCalled();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/** Explicit Compose draft UI, with original control/document ownership pinned until Done. */
|
|
2
|
+
import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
|
|
3
|
+
import { Modal, TextInput } from "@getpaseo/plugin/client/react-native";
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
|
+
import { Text, View } from "react-native";
|
|
6
|
+
import { ControlButton } from "./browser-chrome";
|
|
7
|
+
import type { createStyles } from "./browser-panel-styles";
|
|
8
|
+
|
|
9
|
+
type Theme = PluginWorkspacePanelProps["theme"];
|
|
10
|
+
|
|
11
|
+
/** Visible draft sheet; text reaches the focused page field only by an explicit Done. */
|
|
12
|
+
export function ComposeTextControls({
|
|
13
|
+
styles,
|
|
14
|
+
theme,
|
|
15
|
+
composeText,
|
|
16
|
+
cancelInput,
|
|
17
|
+
enabled,
|
|
18
|
+
ownershipKey,
|
|
19
|
+
documentKey,
|
|
20
|
+
request,
|
|
21
|
+
onRequestHandled,
|
|
22
|
+
onRequestControlForCompose,
|
|
23
|
+
canRequestControl,
|
|
24
|
+
}: {
|
|
25
|
+
styles: ReturnType<typeof createStyles>;
|
|
26
|
+
theme: Theme;
|
|
27
|
+
composeText(text: string): boolean;
|
|
28
|
+
cancelInput(): void;
|
|
29
|
+
enabled: boolean;
|
|
30
|
+
ownershipKey: string;
|
|
31
|
+
documentKey: string;
|
|
32
|
+
request: { id: number; kind: "compose" | "commit"; ownershipKey: string } | null;
|
|
33
|
+
onRequestHandled(id: number): void;
|
|
34
|
+
onRequestControlForCompose(): void;
|
|
35
|
+
canRequestControl: boolean;
|
|
36
|
+
}) {
|
|
37
|
+
const handledRequest = useRef<number | null>(null);
|
|
38
|
+
const [composeOpen, setComposeOpen] = useState(false);
|
|
39
|
+
// The input is host-owned (uncontrolled): echoing every character back through
|
|
40
|
+
// React state let a fast second keystroke be overwritten by a stale value.
|
|
41
|
+
// Latest text lives here; state changes only when emptiness flips or the draft resets.
|
|
42
|
+
const text = useRef("");
|
|
43
|
+
const generation = useRef(0);
|
|
44
|
+
const [hasText, setHasText] = useState(false);
|
|
45
|
+
const [inputKey, setInputKey] = useState(0);
|
|
46
|
+
const draftGeneration = generation.current;
|
|
47
|
+
const resetDraft = () => {
|
|
48
|
+
generation.current += 1;
|
|
49
|
+
text.current = "";
|
|
50
|
+
setHasText(false);
|
|
51
|
+
setInputKey(generation.current);
|
|
52
|
+
};
|
|
53
|
+
const composeOwner = useRef<string | null>(null);
|
|
54
|
+
const composeDocument = useRef<string | null>(null);
|
|
55
|
+
const liveAuthority = useRef({ enabled, ownershipKey });
|
|
56
|
+
liveAuthority.current = { enabled, ownershipKey };
|
|
57
|
+
const canCommit = enabled && composeOwner.current === ownershipKey;
|
|
58
|
+
const canRebindCommit = enabled && composeDocument.current === documentKey;
|
|
59
|
+
const canRequestCommit = canRequestControl && composeDocument.current === documentKey;
|
|
60
|
+
const closeCompose = () => {
|
|
61
|
+
setComposeOpen(false);
|
|
62
|
+
resetDraft();
|
|
63
|
+
composeOwner.current = null;
|
|
64
|
+
composeDocument.current = null;
|
|
65
|
+
};
|
|
66
|
+
const openCompose = () => {
|
|
67
|
+
cancelInput();
|
|
68
|
+
resetDraft();
|
|
69
|
+
composeOwner.current = ownershipKey;
|
|
70
|
+
composeDocument.current = documentKey;
|
|
71
|
+
setComposeOpen(true);
|
|
72
|
+
};
|
|
73
|
+
// Callbacks are fenced to the draft that rendered them: a retained Done/Cancel
|
|
74
|
+
// from a closed draft must not publish, or close, a same-owner replacement.
|
|
75
|
+
const commit = () => {
|
|
76
|
+
if (generation.current !== draftGeneration) return;
|
|
77
|
+
// Consume before publication: a rapid second Done or retained old handler
|
|
78
|
+
// must not repeat insertion or borrow a replacement page's control.
|
|
79
|
+
if (
|
|
80
|
+
!liveAuthority.current.enabled ||
|
|
81
|
+
liveAuthority.current.ownershipKey !== ownershipKey ||
|
|
82
|
+
composeOwner.current !== ownershipKey ||
|
|
83
|
+
!text.current
|
|
84
|
+
)
|
|
85
|
+
return;
|
|
86
|
+
composeOwner.current = null;
|
|
87
|
+
const accepted = composeText(text.current);
|
|
88
|
+
// Publication may synchronously open a new draft; it owns the refs now.
|
|
89
|
+
if (generation.current !== draftGeneration) return;
|
|
90
|
+
if (accepted) {
|
|
91
|
+
closeCompose();
|
|
92
|
+
} else {
|
|
93
|
+
// false means nothing was admitted, so the same draft remains reviewable.
|
|
94
|
+
composeOwner.current = ownershipKey;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const dismiss = () => {
|
|
98
|
+
if (generation.current === draftGeneration) closeCompose();
|
|
99
|
+
};
|
|
100
|
+
// Menu dismissal commits before this effect; the sheet is mounted outside the menu.
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (!request || handledRequest.current === request.id) return;
|
|
103
|
+
handledRequest.current = request.id;
|
|
104
|
+
if (enabled && request.ownershipKey === ownershipKey) {
|
|
105
|
+
if (request.kind === "compose") openCompose();
|
|
106
|
+
else if (composeOpen && composeDocument.current === documentKey && text.current) {
|
|
107
|
+
composeOwner.current = ownershipKey;
|
|
108
|
+
commit();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
onRequestHandled(request.id);
|
|
112
|
+
}, [request, enabled, ownershipKey, documentKey, onRequestHandled]);
|
|
113
|
+
return (
|
|
114
|
+
<>
|
|
115
|
+
<Modal
|
|
116
|
+
title="Compose text"
|
|
117
|
+
open={composeOpen}
|
|
118
|
+
onOpenChange={(open) => {
|
|
119
|
+
if (!open) dismiss();
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
<Modal.Content>
|
|
123
|
+
<View style={styles.sheetContent}>
|
|
124
|
+
<Text style={styles.devicePresetDetail}>
|
|
125
|
+
Compose with your keyboard, then choose Done to insert the text into the focused page
|
|
126
|
+
field.
|
|
127
|
+
</Text>
|
|
128
|
+
<TextInput
|
|
129
|
+
autoFocus
|
|
130
|
+
multiline
|
|
131
|
+
key={inputKey}
|
|
132
|
+
defaultValue=""
|
|
133
|
+
maxLength={16_000}
|
|
134
|
+
accessibilityLabel="Text to compose"
|
|
135
|
+
onChangeText={(next) => {
|
|
136
|
+
// A callback retained from a closed or replaced draft must not write into the new one.
|
|
137
|
+
if (generation.current !== draftGeneration) return;
|
|
138
|
+
text.current = next;
|
|
139
|
+
setHasText(next.length > 0);
|
|
140
|
+
}}
|
|
141
|
+
style={[styles.field, { minHeight: 100 }]}
|
|
142
|
+
/>
|
|
143
|
+
{!canCommit ? (
|
|
144
|
+
<Text style={styles.devicePresetDetail}>
|
|
145
|
+
Browser control or page changed. Close this draft and focus the field again.
|
|
146
|
+
</Text>
|
|
147
|
+
) : null}
|
|
148
|
+
<View style={styles.mobileRow}>
|
|
149
|
+
<ControlButton styles={styles} theme={theme} label="Cancel" onPress={dismiss} />
|
|
150
|
+
<ControlButton
|
|
151
|
+
styles={styles}
|
|
152
|
+
theme={theme}
|
|
153
|
+
label="Done"
|
|
154
|
+
primary
|
|
155
|
+
disabled={(!canCommit && !canRebindCommit && !canRequestCommit) || !hasText}
|
|
156
|
+
onPress={() => {
|
|
157
|
+
if (generation.current !== draftGeneration) return;
|
|
158
|
+
if (canCommit) {
|
|
159
|
+
commit();
|
|
160
|
+
} else if (canRebindCommit) {
|
|
161
|
+
composeOwner.current = ownershipKey;
|
|
162
|
+
commit();
|
|
163
|
+
} else {
|
|
164
|
+
onRequestControlForCompose();
|
|
165
|
+
}
|
|
166
|
+
}}
|
|
167
|
+
/>
|
|
168
|
+
</View>
|
|
169
|
+
</View>
|
|
170
|
+
</Modal.Content>
|
|
171
|
+
</Modal>
|
|
172
|
+
</>
|
|
173
|
+
);
|
|
174
|
+
}
|