Fix mobile web gestures, compact chat layout and notification diagnostics
This commit is contained in:
@@ -60,3 +60,11 @@ Client API contract docs:
|
||||
|
||||
- `/`: full Sybil app (sidebar + chat/search workspace)
|
||||
- `/search?q=bitcoin+price`: standalone search page that renders only the search query box and results panel
|
||||
|
||||
## Mobile web
|
||||
|
||||
Installed Sybil uses `standalone` display, retaining the system status bar where the browser supports it. The viewport keeps pinch zoom disabled. Compact regular-chat replies use the transcript width and the active provider/model appears beneath the title; Agent keeps its existing layout.
|
||||
|
||||
Horizontal navigation swipes start on workspace chrome or empty space. Text, tables, quotations, code, controls, existing selections and nested horizontal scrollers keep native gestures, including at scroll edges. Long-press/right-click on transcript text and links uses the native browser menu; the message-actions button provides Fork.
|
||||
|
||||
Agent notification settings include separate browser/server failure guidance and an expandable Android / GrapheneOS setup guide. See [mobile QA results and limitations](../docs/mobile-web-qa.md), including how to run the browser fixtures.
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Sybil" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
"description": "Sybil chat and search workspace",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "fullscreen",
|
||||
"display_override": ["fullscreen", "standalone"],
|
||||
"display": "standalone",
|
||||
"background_color": "#0b0718",
|
||||
"theme_color": "#0f172a",
|
||||
"icons": [
|
||||
|
||||
+25
-16
@@ -28,6 +28,7 @@ import { ChatComposer } from "@/components/chat/chat-composer";
|
||||
import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
|
||||
import { AgentConversation } from "@/components/agent/agent-conversation";
|
||||
import { AgentNotifications } from "@/components/agent/agent-notifications";
|
||||
import { hasTextSelection, ownsMobileGesture } from "@/lib/mobile-workspace-gestures";
|
||||
import { SearchResultsPanel } from "@/components/search/search-results-panel";
|
||||
import { SearchMatchLabel } from "@/components/search/search-match-label";
|
||||
import { SybilCharacter } from "@/components/sybil-character";
|
||||
@@ -201,6 +202,7 @@ type MobileWorkspaceSwipe = {
|
||||
touchIdentifier: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
startTimestamp: number;
|
||||
lastX: number;
|
||||
lastTimestamp: number;
|
||||
velocityX: number;
|
||||
@@ -1808,8 +1810,11 @@ export default function App() {
|
||||
};
|
||||
|
||||
const handleMobileWorkspaceTouchStart = (event: TargetedTouchEvent<HTMLElement>) => {
|
||||
resetMobileWorkspaceSwipe();
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.touches.length !== 1 ||
|
||||
hasTextSelection() ||
|
||||
!isMobileLayout() ||
|
||||
isMobileSidebarOpen ||
|
||||
isQuickQuestionOpen ||
|
||||
@@ -1819,13 +1824,7 @@ export default function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target;
|
||||
if (
|
||||
target instanceof Element &&
|
||||
target.closest("input, textarea, select, button, a, [role='dialog'], [data-mobile-swipe-ignore]")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (ownsMobileGesture(event.target, event.currentTarget)) return;
|
||||
|
||||
const touch = event.touches.item(0);
|
||||
if (!touch) return;
|
||||
@@ -1833,6 +1832,7 @@ export default function App() {
|
||||
touchIdentifier: touch.identifier,
|
||||
startX: touch.clientX,
|
||||
startY: touch.clientY,
|
||||
startTimestamp: event.timeStamp,
|
||||
lastX: touch.clientX,
|
||||
lastTimestamp: event.timeStamp,
|
||||
velocityX: 0,
|
||||
@@ -1843,6 +1843,10 @@ export default function App() {
|
||||
const handleMobileWorkspaceTouchMove = (event: TargetedTouchEvent<HTMLElement>) => {
|
||||
const swipe = mobileWorkspaceSwipeRef.current;
|
||||
if (!swipe) return;
|
||||
if (event.touches.length !== 1 || hasTextSelection() || (swipe.direction === null && event.timeStamp - swipe.startTimestamp > 350)) {
|
||||
resetMobileWorkspaceSwipe();
|
||||
return;
|
||||
}
|
||||
const touch = findTouch(event.touches, swipe.touchIdentifier);
|
||||
if (!touch) return;
|
||||
|
||||
@@ -1890,7 +1894,7 @@ export default function App() {
|
||||
swipe.velocityX = ((touch.clientX - swipe.lastX) / elapsedMs) * 1000;
|
||||
}
|
||||
resetMobileWorkspaceSwipe();
|
||||
if (direction === null) return;
|
||||
if (direction === null || hasTextSelection()) return;
|
||||
|
||||
const directionalDistance = deltaX * direction;
|
||||
const directionalVelocity = swipe.velocityX * direction;
|
||||
@@ -2200,8 +2204,9 @@ export default function App() {
|
||||
const menuWidth = 176;
|
||||
const menuHeight = 48;
|
||||
const padding = 8;
|
||||
const x = Math.min(event.clientX, window.innerWidth - menuWidth - padding);
|
||||
const y = Math.min(event.clientY, window.innerHeight - menuHeight - padding);
|
||||
const anchor = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const x = Math.min(anchor.left, window.innerWidth - menuWidth - padding);
|
||||
const y = Math.min(anchor.bottom, window.innerHeight - menuHeight - padding);
|
||||
setContextMenu({
|
||||
kind: "message",
|
||||
chatId: selectedItem.id,
|
||||
@@ -3504,7 +3509,7 @@ export default function App() {
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
"workspace-sidebar glass-panel fixed inset-y-0 left-0 z-40 flex w-[86vw] max-w-80 shrink-0 flex-col border-r border-violet-300/18 transition-transform md:static md:z-auto md:w-80 md:max-w-none",
|
||||
"workspace-sidebar glass-panel fixed inset-y-0 left-0 z-40 flex w-[86vw] max-w-80 shrink-0 flex-col overflow-y-auto overscroll-contain border-r border-violet-300/18 transition-transform md:static md:z-auto md:w-80 md:max-w-none",
|
||||
isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
||||
)}
|
||||
>
|
||||
@@ -3727,11 +3732,12 @@ export default function App() {
|
||||
</aside>
|
||||
|
||||
<main
|
||||
className="workspace-content glass-panel relative flex min-w-0 flex-1 touch-pan-y flex-col overflow-hidden md:touch-auto"
|
||||
className="workspace-content glass-panel relative flex min-w-0 flex-1 flex-col overflow-hidden"
|
||||
onTouchStart={handleMobileWorkspaceTouchStart}
|
||||
onTouchMove={handleMobileWorkspaceTouchMove}
|
||||
onTouchEnd={handleMobileWorkspaceTouchEnd}
|
||||
onTouchCancel={handleMobileWorkspaceTouchCancel}
|
||||
onContextMenu={resetMobileWorkspaceSwipe}
|
||||
>
|
||||
{isAgentMode ? (
|
||||
<AgentConversation room={agentRoom} onOpenSidebar={() => setIsMobileSidebarOpen(true)} isVisible={!isMobileSidebarOpen && !isQuickQuestionOpen} />
|
||||
@@ -3749,8 +3755,11 @@ export default function App() {
|
||||
<Menu className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1>
|
||||
{!isSearchMode ? <p className="truncate text-xs text-violet-100/58 md:hidden" title={`${getProviderLabel(provider)} · ${model || "No model"}`}>
|
||||
{getProviderLabel(provider)} · {model || "No model"}
|
||||
</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-end gap-2">
|
||||
@@ -3764,8 +3773,8 @@ export default function App() {
|
||||
aria-label="Open chat settings"
|
||||
>
|
||||
<Settings2 className="h-4 w-4 shrink-0" />
|
||||
<span className="hidden shrink-0 sm:inline">Settings</span>
|
||||
<span className="hidden min-w-0 max-w-[18rem] truncate text-xs font-medium text-violet-100/58 sm:inline">
|
||||
<span className="hidden shrink-0 md:inline">Settings</span>
|
||||
<span className="hidden min-w-0 max-w-[18rem] truncate text-xs font-medium text-violet-100/58 md:inline">
|
||||
{getProviderLabel(provider)} · {model || "No model"}
|
||||
</span>
|
||||
</Button>
|
||||
@@ -3796,7 +3805,7 @@ export default function App() {
|
||||
messages={displayMessages}
|
||||
isLoading={isLoadingSelection}
|
||||
isSending={isSendingActiveChat}
|
||||
onMessageContextMenu={openMessageContextMenu}
|
||||
onMessageActions={openMessageContextMenu}
|
||||
/>
|
||||
) : (
|
||||
<SearchResultsPanel
|
||||
|
||||
@@ -6,7 +6,7 @@ import { disableAgentBrowserPush, enableAgentBrowserPush, getAgentPushState, obs
|
||||
export function AgentNotifications({ onLogout }: { onLogout: () => Promise<void> }) {
|
||||
const [state, setState] = useState(getAgentPushState), [signingOut, setSigningOut] = useState(false);
|
||||
useEffect(() => observeAgentPush(setState), []);
|
||||
return <details className="shrink-0 border-t border-violet-300/15 px-3 py-2 text-xs text-violet-100/75">
|
||||
return <details className="max-h-[50dvh] shrink-0 overflow-y-auto overscroll-contain border-t border-violet-300/15 px-3 py-2 text-xs text-violet-100/75">
|
||||
<summary className="flex cursor-pointer items-center gap-2 rounded py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring"><Bell className="h-4 w-4" />Agent notifications</summary>
|
||||
<div className="space-y-2 pb-1 pt-2">
|
||||
<p>Notifications include Agent message text and may appear on your lock screen.</p>
|
||||
@@ -18,6 +18,15 @@ export function AgentNotifications({ onLogout }: { onLogout: () => Promise<void>
|
||||
<Button className="h-8 text-xs" variant="ghost" disabled={state.busy} onClick={() => void refreshAgentBrowserPush()}>Refresh</Button>
|
||||
<Button className="h-8 text-xs" variant="ghost" disabled={signingOut} onClick={() => { setSigningOut(true); void onLogout().finally(() => setSigningOut(false)); }}>Sign out</Button>
|
||||
</div>
|
||||
<details>
|
||||
<summary className="cursor-pointer py-2 underline">Android / GrapheneOS help</summary>
|
||||
<div className="space-y-2 pb-2">
|
||||
<p>Vanadium uses Google Play services for Web Push. On GrapheneOS, sandboxed Google Play must be installed in the same profile as Vanadium, with network access and a battery optimization exception for Play services.</p>
|
||||
<p>Allow notifications for Sybil in browser site settings and for Vanadium in Android settings. Then reload Sybil and enable notifications again. Permission alone does not mean the browser can register for push.</p>
|
||||
<p>If you choose not to use Google Play, you need a browser with an independent Web Push service. Sybil cannot replace the browser's push service or detect whether Play services is installed.</p>
|
||||
<p><a className="underline" href="https://grapheneos.org/usage#sandboxed-google-play" target="_blank" rel="noopener noreferrer">GrapheneOS setup guide</a>{" · "}<a className="underline" href="https://chromium.googlesource.com/chromium/src/+/main/components/gcm_driver/android/java/src/org/chromium/components/gcm_driver/GoogleCloudMessagingV2.java" target="_blank" rel="noopener noreferrer">Chromium push implementation</a></p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</details>;
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
||||
import { getMessageAttachments, type Message } from "@/lib/api";
|
||||
import { asStreamingAssistantMetadata } from "@/lib/chat-stream-presentation";
|
||||
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
||||
import { ChevronDown, ChevronUp, Globe2, Link2, Wrench } from "lucide-preact";
|
||||
import { ChevronDown, ChevronUp, Globe2, Link2, MoreHorizontal, Wrench } from "lucide-preact";
|
||||
|
||||
type Props = {
|
||||
messages: Message[];
|
||||
isLoading: boolean;
|
||||
isSending: boolean;
|
||||
onMessageContextMenu?: (event: MouseEvent, messageId: string) => void;
|
||||
onMessageActions?: (event: MouseEvent, messageId: string) => void;
|
||||
};
|
||||
|
||||
type ToolLogMetadata = {
|
||||
@@ -533,7 +533,7 @@ export function StreamingAssistantPresentation({ message, isSending }: { message
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageContextMenu }: Props) {
|
||||
export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageActions }: Props) {
|
||||
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-"));
|
||||
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
|
||||
const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]);
|
||||
@@ -604,13 +604,8 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
|
||||
"min-w-0 space-y-3",
|
||||
isUser
|
||||
? "message-bubble message-bubble--user max-w-[87%] md:max-w-[78%]"
|
||||
: "max-w-[85%] text-base leading-7 text-violet-50"
|
||||
: "w-full max-w-full md:w-auto md:max-w-[85%] text-base leading-7 text-violet-50"
|
||||
)}
|
||||
onContextMenu={
|
||||
message.role === "assistant" && !message.id.startsWith("temp-") && onMessageContextMenu
|
||||
? (event) => onMessageContextMenu(event, message.id)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{attachments.length ? <ChatAttachmentList attachments={attachments} tone={isUser ? "user" : "assistant"} /> : null}
|
||||
{streamingAssistantMetadata ? (
|
||||
@@ -628,6 +623,13 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
|
||||
className={cn("[&_a]:text-inherit [&_a]:underline", !isUser && "leading-[1.82] text-violet-50")}
|
||||
/>
|
||||
) : null}
|
||||
{message.role === "assistant" && !message.id.startsWith("temp-") && onMessageActions ? (
|
||||
<button type="button" aria-label="Message actions" title="Message actions"
|
||||
className="flex h-9 w-9 items-center justify-center rounded text-muted-foreground hover:bg-violet-400/12 hover:text-violet-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={event => onMessageActions(event, message.id)}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -462,6 +462,17 @@ textarea {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.md-table-scroll,
|
||||
.md-content pre,
|
||||
.md-content blockquote {
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
|
||||
.md-content blockquote {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.md-content > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getApiBaseUrl, getAuthToken } from "./api";
|
||||
import { ensureServiceWorker } from "../pwa";
|
||||
import { browserPushOperation } from "./agent-push-errors.ts";
|
||||
import {
|
||||
AGENT_PUSH_STORAGE_KEY, AgentPushClient, agentPushAuthorityScope, agentPushCursor, agentPushSourceId,
|
||||
createAgentPresenceReporter, createAgentPushController, parseAgentPushConsent, type AgentPushView, type PushSubscriptionHandle,
|
||||
@@ -24,17 +25,12 @@ async function consentScope() {
|
||||
return digest;
|
||||
}
|
||||
const listeners = new Set<(state: AgentPushView) => void>();
|
||||
function bounded<T>(promise: Promise<T>, timeoutMs = 10000): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error("Browser notification operation timed out.")), timeoutMs);
|
||||
promise.then(value => { clearTimeout(timeout); resolve(value); }, () => { clearTimeout(timeout); reject(new Error("Browser notification operation failed.")); });
|
||||
});
|
||||
}
|
||||
const subscriptionHandle = (subscription: PushSubscription): PushSubscriptionHandle => ({
|
||||
toJSON: () => subscription.toJSON(), unsubscribe: () => bounded(subscription.unsubscribe()),
|
||||
toJSON: () => subscription.toJSON(), unsubscribe: () => browserPushOperation(() => subscription.unsubscribe(), "subscription"),
|
||||
});
|
||||
const readConsent = () => { try { return parseAgentPushConsent(localStorage.getItem(AGENT_PUSH_STORAGE_KEY)); } catch { return null; } };
|
||||
const support = () => {
|
||||
if (!window.isSecureContext) return "insecure" as const;
|
||||
const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||
const standalone = window.matchMedia("(display-mode: standalone)").matches || window.matchMedia("(display-mode: fullscreen)").matches
|
||||
|| (navigator as Navigator & { standalone?: boolean }).standalone === true;
|
||||
@@ -79,11 +75,14 @@ const controller = createAgentPushController({
|
||||
requestPermission: () => Notification.requestPermission(),
|
||||
subscription: async () => {
|
||||
if (!("serviceWorker" in navigator)) return null;
|
||||
const registration = await bounded(navigator.serviceWorker.getRegistration("/"));
|
||||
const subscription = registration ? await bounded(registration.pushManager.getSubscription()) : null;
|
||||
const registration = await browserPushOperation(() => navigator.serviceWorker.getRegistration("/"), "worker");
|
||||
const subscription = registration ? await browserPushOperation(() => registration.pushManager.getSubscription(), "subscription") : null;
|
||||
return subscription ? subscriptionHandle(subscription) : null;
|
||||
},
|
||||
subscribe: async key => subscriptionHandle(await bounded((await ensureServiceWorker()).pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: decodePublicKey(key) }))),
|
||||
subscribe: async key => {
|
||||
const registration = await browserPushOperation(ensureServiceWorker, "worker", 20000);
|
||||
return subscriptionHandle(await browserPushOperation(() => registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: decodePublicKey(key) }), "subscription"));
|
||||
},
|
||||
clearNotifications: () => postWorker({ type: "sybil-agent-logout", version: 1 }),
|
||||
},
|
||||
});
|
||||
@@ -91,7 +90,7 @@ export const getAgentPushState = () => controller.state();
|
||||
export function observeAgentPush(listener: (state: AgentPushView) => void) { listeners.add(listener); listener(controller.state()); return () => { listeners.delete(listener); }; }
|
||||
export const enableAgentBrowserPush = () => controller.enable();
|
||||
export const disableAgentBrowserPush = () => controller.disable();
|
||||
export const refreshAgentBrowserPush = () => controller.refresh();
|
||||
export const refreshAgentBrowserPush = () => controller.refresh(true);
|
||||
|
||||
const presence = createAgentPresenceReporter({
|
||||
snapshot: () => ({ visibility: !document.hidden && document.visibilityState === "visible" ? "visible" : "hidden", focused: document.hasFocus() }),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/** Only messages constructed here may reach the UI; provider bodies can contain secrets. */
|
||||
export class AgentPushError extends Error {}
|
||||
|
||||
export type AgentPushStage = "permission" | "worker" | "subscription" | "storage" | "lock" | "service";
|
||||
|
||||
export function agentPushFailure(cause: unknown, stage: AgentPushStage): AgentPushError {
|
||||
if (cause instanceof AgentPushError) return cause;
|
||||
const name = cause instanceof Error || (typeof DOMException !== "undefined" && cause instanceof DOMException) ? cause.name : "";
|
||||
if (stage === "storage" || name === "QuotaExceededError") return new AgentPushError("Browser storage is unavailable. Allow site storage and free some space, then try again.");
|
||||
if (stage === "worker") return new AgentPushError("The notification worker could not start. Reload Sybil and try again. If this continues, check that this browser allows service workers and site storage.");
|
||||
if (stage === "lock") return new AgentPushError("Another Sybil tab is still updating notifications. Close other Sybil tabs, then try again.");
|
||||
if ((stage === "permission" || stage === "subscription") && ["NotAllowedError", "SecurityError"].includes(name)) {
|
||||
return new AgentPushError("The browser did not allow notifications. Allow notifications for Sybil in site settings and for the browser in system settings, then try again.");
|
||||
}
|
||||
if (stage === "permission") return new AgentPushError("The browser could not request notification permission. Check browser site settings and system settings, then reload Sybil and try again.");
|
||||
if (stage === "subscription") {
|
||||
if (name === "InvalidStateError") return new AgentPushError("The browser has an incompatible notification subscription. Turn off notifications for Sybil in site settings, reload, then enable them again.");
|
||||
if (["InvalidAccessError", "DataError", "TypeError"].includes(name)) return new AgentPushError("The browser rejected the server's notification key. Ask the server administrator to check Web Push configuration.");
|
||||
if (name === "NotSupportedError") return new AgentPushError("This browser exposes notification APIs but cannot create a push subscription. Update it or use a browser with a working Web Push service. See Android / GrapheneOS help below.");
|
||||
const detail = ["AbortError", "NetworkError", "TimeoutError"].includes(name) ? ` (${name})` : "";
|
||||
return new AgentPushError(`The browser could not connect to its push service${detail}. Check your connection and browser push support, then try again. On Vanadium, see Android / GrapheneOS help below.`);
|
||||
}
|
||||
return new AgentPushError("The Sybil notification service could not be reached. Check your connection and try again.");
|
||||
}
|
||||
|
||||
export function agentPushHttpFailure(status: number): AgentPushError {
|
||||
if (status === 401 || status === 403) return new AgentPushError("Your sign-in is no longer authorized. Sign out and sign in again to manage notifications.");
|
||||
if (status === 400 || status === 413) return new AgentPushError("The server rejected the notification subscription. Refresh Sybil and retry; if it continues, ask the server administrator to check Web Push configuration.");
|
||||
if (status === 409) return new AgentPushError("This notification registration has expired. Refresh and enable notifications again to create a new registration.");
|
||||
if (status === 429) return new AgentPushError("The notification service is busy. Wait a moment, then try again.");
|
||||
if (status === 503) return new AgentPushError("Web Push is unavailable on the Sybil server. Ask the server administrator to check notification configuration and service health.");
|
||||
return new AgentPushError("The Sybil server could not save notification settings. Try again; if it continues, ask the server administrator to check service health.");
|
||||
}
|
||||
|
||||
/** Keep DOMException names for diagnosis, but never expose their arbitrary messages. */
|
||||
export function browserPushOperation<T>(operation: () => Promise<T>, stage: AgentPushStage, timeoutMs = 10000): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(agentPushFailure(new DOMException("", "TimeoutError"), stage)), timeoutMs);
|
||||
const fail = (cause: unknown) => { clearTimeout(timeout); reject(agentPushFailure(cause, stage)); };
|
||||
try { operation().then(value => { clearTimeout(timeout); resolve(value); }, fail); }
|
||||
catch (cause) { fail(cause); }
|
||||
});
|
||||
}
|
||||
+43
-24
@@ -1,3 +1,5 @@
|
||||
import { AgentPushError, agentPushFailure, agentPushHttpFailure, type AgentPushStage } from "./agent-push-errors.ts";
|
||||
|
||||
/** Browser-only push contracts. Secrets stay in page request headers, never worker messages. */
|
||||
export const AGENT_PUSH_STORAGE_KEY = "sybil.agent.push.v1";
|
||||
export const AGENT_PUSH_CHANGED_EVENT = "sybil-agent-push-changed";
|
||||
@@ -18,8 +20,9 @@ export function parseAgentPushConfig(value: unknown): AgentPushConfig {
|
||||
|| (config.quarantined !== undefined && typeof config.quarantined !== "boolean") || !policy
|
||||
|| typeof policy.presenceHeartbeatMs !== "number" || policy.presenceHeartbeatMs < 5000 || policy.presenceHeartbeatMs > 30000
|
||||
|| typeof policy.presenceLeaseMs !== "number" || policy.presenceLeaseMs < 30000 || policy.presenceLeaseMs > 120000
|
||||
|| (config.webPushAvailable && config.vapidPublicKey === undefined)
|
||||
|| (config.vapidPublicKey !== undefined && (typeof config.vapidPublicKey !== "string" || !/^[A-Za-z0-9_-]{87}$/.test(config.vapidPublicKey)))) {
|
||||
throw new Error("Notification configuration is unavailable.");
|
||||
throw new AgentPushError("The server returned invalid notification configuration. Refresh Sybil; if it continues, ask the server administrator to check Web Push configuration.");
|
||||
}
|
||||
return { ...config, quarantined: config.quarantined === true } as AgentPushConfig;
|
||||
}
|
||||
@@ -36,28 +39,28 @@ export class AgentPushClient {
|
||||
constructor(options: { baseUrl: string; token: () => string | null; fetch?: typeof globalThis.fetch }) { this.options = options; }
|
||||
private async request(path: string, method = "GET", body?: unknown, keepalive = false, expectedScope?: string): Promise<unknown> {
|
||||
const token = this.options.token();
|
||||
if (!token) throw new Error("Sign in to manage notifications.");
|
||||
if (!token) throw new AgentPushError("Sign in to manage notifications.");
|
||||
const controller = new AbortController(), timeout = setTimeout(() => controller.abort(), 8000);
|
||||
try {
|
||||
// Capture the bearer before hashing, then use that exact bearer for the
|
||||
// request. A concurrent sign-in must not turn old-authority cleanup into
|
||||
// a new-authority DELETE whose204 falsely clears a durable tombstone.
|
||||
if (expectedScope !== undefined && expectedScope !== await agentPushAuthorityScope(this.options.baseUrl, token)) throw new Error("Notification authority changed.");
|
||||
if (expectedScope !== undefined && expectedScope !== await agentPushAuthorityScope(this.options.baseUrl, token)) throw new AgentPushError("The signed-in server changed. Refresh and enable notifications again.");
|
||||
const response = await (this.options.fetch || globalThis.fetch)(`${this.options.baseUrl.replace(/\/+$/, "")}/v1/agent${path}`, {
|
||||
method, headers: { Authorization: `Bearer ${token}`, ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }), cache: "no-store", redirect: "error", credentials: "omit", signal: controller.signal, keepalive,
|
||||
});
|
||||
// Never echo server/provider bodies: they may contain endpoint or key material.
|
||||
if (!response.ok) throw new Error(response.status === 401 ? "Sign in to manage notifications." : "Notification settings could not be saved. Try again.");
|
||||
if (!response.ok) throw agentPushHttpFailure(response.status);
|
||||
return response.status === 204 ? undefined : await response.json();
|
||||
} catch { throw new Error("Notification service is unavailable. Try again when connected."); }
|
||||
} catch (cause) { throw agentPushFailure(cause, "service"); }
|
||||
finally { clearTimeout(timeout); }
|
||||
}
|
||||
async config() { return parseAgentPushConfig(await this.request("/push/config")); }
|
||||
async register(id: string, subscription: PushSubscriptionJSON, expectedScope?: string) {
|
||||
if (!agentPushUuid(id)) throw new Error("Invalid notification installation.");
|
||||
const value = object(await this.request(`/push/installations/${id}`, "PUT", { transport: "web", enabled: true, subscription }, false, expectedScope));
|
||||
if (!value || value.installationId !== id || value.transport !== "web" || value.enabled !== true || !Number.isSafeInteger(value.version) || Number(value.version) < 1) throw new Error("Notification registration was not acknowledged.");
|
||||
if (!value || value.installationId !== id || value.transport !== "web" || value.enabled !== true || !Number.isSafeInteger(value.version) || Number(value.version) < 1) throw new AgentPushError("The Sybil server did not acknowledge the notification registration. Refresh and try again.");
|
||||
}
|
||||
async revoke(id: string, expectedScope?: string) {
|
||||
if (!agentPushUuid(id)) throw new Error("Invalid notification installation.");
|
||||
@@ -94,7 +97,7 @@ export function parseAgentPushConsent(raw: string | null): AgentPushConsent | nu
|
||||
export type AgentPushView = { busy: boolean; enabled: boolean; canEnable: boolean; message: string; error: string | null };
|
||||
export type PushSubscriptionHandle = Pick<PushSubscription, "toJSON" | "unsubscribe">;
|
||||
type PushPlatform = {
|
||||
support: () => "supported" | "unsupported" | "install";
|
||||
support: () => "supported" | "unsupported" | "install" | "insecure";
|
||||
permission: () => NotificationPermission;
|
||||
requestPermission: () => Promise<NotificationPermission>;
|
||||
subscription: () => Promise<PushSubscriptionHandle | null>;
|
||||
@@ -109,15 +112,16 @@ export function createAgentPushController(options: {
|
||||
uuid: () => string; lock: <T>(operation: () => Promise<T>) => Promise<T>; changed: (view: AgentPushView) => void;
|
||||
}) {
|
||||
let config: AgentPushConfig | null = null, busy = false, error: string | null = null, localRevocation: AgentPushConsent | null = null;
|
||||
let refreshError: string | null = null;
|
||||
let scope: string | null = null;
|
||||
let registeredSubscription: string | null = null;
|
||||
const currentConsent = () => localRevocation || options.read();
|
||||
const matchesContext = (record: AgentPushConsent) => !!scope && record.scope === scope
|
||||
&& agentPushSourceId(record.sourceId) && record.sourceId === config?.sourceId;
|
||||
const transportAvailable = () => !!config?.enabled && config.webPushAvailable && !config.quarantined && agentPushSourceId(config.sourceId);
|
||||
const transportAvailable = () => !!config?.enabled && config.webPushAvailable && !!config.vapidPublicKey && !config.quarantined && agentPushSourceId(config.sourceId);
|
||||
const refreshContext = async () => {
|
||||
const nextScope = await options.scope(), nextConfig = await options.api.config();
|
||||
if (nextScope !== await options.scope()) { scope = null; config = null; throw new Error("Notification authority changed."); }
|
||||
if (nextScope !== await options.scope()) { scope = null; config = null; throw new AgentPushError("The signed-in server changed. Refresh and enable notifications again."); }
|
||||
scope = nextScope; config = nextConfig;
|
||||
};
|
||||
const recheckContext = async (record: AgentPushConsent) => {
|
||||
@@ -130,14 +134,17 @@ export function createAgentPushController(options: {
|
||||
const cleanupFull = (consent?.pendingRevocations?.length || 0) >= MAX_AGENT_PUSH_REVOCATIONS;
|
||||
const enabled = consent?.enabled === true && !consent.revocationPending && permission === "granted";
|
||||
const message = support === "install" ? "On iPhone or iPad, add Sybil to your Home Screen, then enable notifications there."
|
||||
: support === "insecure" ? "Notifications require a secure connection. Open Sybil over HTTPS, then try again."
|
||||
: support === "unsupported" ? "This browser does not support notifications here. Use a supported browser over HTTPS."
|
||||
: permission === "denied" ? "Notifications are blocked. Allow them in browser settings, then try again."
|
||||
: permission === "denied" ? "Notifications are blocked. Allow Sybil in browser site settings and allow the browser's notifications in system settings, then refresh."
|
||||
: cleanupFull ? "Notification cleanup is full. Sign in to a previous server to finish removing old registrations before enabling more."
|
||||
: consent?.revocationPending ? "Notifications are off here. Server removal will retry when connected."
|
||||
: config?.quarantined ? "Notification delivery is paused after an Agent source change. Ask the server administrator to reconcile the source before enabling notifications."
|
||||
: config?.enabled && config.webPushAvailable && !agentPushSourceId(config.sourceId) ? "The Agent notification source is not ready yet. Refresh in a moment; if it continues, ask the server administrator to check the Agent connection."
|
||||
: !available ? "Notifications are not configured on this server yet."
|
||||
: enabled ? "Notifications are on for this browser."
|
||||
: "Get an alert for unread Agent replies when all your web sessions are inactive.";
|
||||
return { busy, enabled, canEnable: !busy && available && support === "supported" && permission !== "denied" && !consent?.revocationPending && !cleanupFull, message, error };
|
||||
return { busy, enabled, canEnable: !busy && available && support === "supported" && permission !== "denied" && !consent?.revocationPending && !cleanupFull, message, error: error || refreshError };
|
||||
};
|
||||
const publish = () => options.changed(state());
|
||||
const write = (consent: AgentPushConsent) => { options.write(consent); publish(); };
|
||||
@@ -195,7 +202,7 @@ export function createAgentPushController(options: {
|
||||
await revoke(pending);
|
||||
};
|
||||
const disable = async () => {
|
||||
error = null;
|
||||
error = null; refreshError = null;
|
||||
const record = { ...consent(), enabled: false, revocationPending: true, revision: options.uuid() };
|
||||
// Synchronous durable intent is necessary before logout clears page credentials.
|
||||
registeredSubscription = null;
|
||||
@@ -216,8 +223,9 @@ export function createAgentPushController(options: {
|
||||
}
|
||||
finally { busy = false; publish(); }
|
||||
};
|
||||
const refresh = async () => {
|
||||
const refresh = async (clearError = false) => {
|
||||
if (busy) return;
|
||||
if (clearError) error = null;
|
||||
try {
|
||||
await refreshContext();
|
||||
await options.lock(async () => {
|
||||
@@ -246,18 +254,18 @@ export function createAgentPushController(options: {
|
||||
const stillMatching = await recheckContext(record);
|
||||
if (currentConsent()?.revision !== record.revision) { await retireAtOriginalAuthority(record); return; }
|
||||
if (!stillMatching) { await invalidate(record); return; }
|
||||
} catch { await invalidate(record); throw new Error("Notification service is unavailable."); }
|
||||
} catch (cause) { await invalidate(record); throw cause; }
|
||||
registeredSubscription = serialized;
|
||||
}
|
||||
}
|
||||
});
|
||||
error = null;
|
||||
} catch { error = "Notification service is unavailable. Try again when connected."; }
|
||||
refreshError = null;
|
||||
} catch (cause) { refreshError = agentPushFailure(cause, "service").message; }
|
||||
publish();
|
||||
};
|
||||
const enable = async () => {
|
||||
if (!state().canEnable || state().enabled || busy || !config?.vapidPublicKey || !scope || !agentPushSourceId(config.sourceId)) return;
|
||||
error = null;
|
||||
error = null; refreshError = null;
|
||||
// Explicit revocation permanently retires the old UUID on the server. Each
|
||||
// new opt-in has a new identity, so a delayed old PUT cannot resurrect it.
|
||||
const record: AgentPushConsent = { version: 1, installationId: options.uuid(), revision: options.uuid(), scope, sourceId: config.sourceId,
|
||||
@@ -267,35 +275,46 @@ export function createAgentPushController(options: {
|
||||
// Must execute in the original click stack, before locks, worker startup or fetches.
|
||||
let permission: Promise<NotificationPermission>;
|
||||
try { permission = options.platform.requestPermission(); }
|
||||
catch { error = "Notifications could not be requested. Check browser settings and try again."; publish(); return; }
|
||||
catch (cause) { error = agentPushFailure(cause, "permission").message; publish(); return; }
|
||||
busy = true; publish();
|
||||
let stage: AgentPushStage = "permission";
|
||||
try {
|
||||
if (await permission !== "granted") { error = "Notifications were not allowed. You can change this in browser settings."; return; }
|
||||
const decision = await permission;
|
||||
if (decision !== "granted") { error = decision === "denied"
|
||||
? "Notifications were not allowed. Allow Sybil in browser site settings and allow the browser's notifications in system settings, then refresh."
|
||||
: "The notification prompt was dismissed. Tap Enable notifications again and choose Allow."; return; }
|
||||
stage = "lock";
|
||||
await options.lock(async () => {
|
||||
stage = "service";
|
||||
const unchanged = () => options.read()?.revision === record.revision;
|
||||
if (!unchanged()) return;
|
||||
const matching = await recheckContext(record);
|
||||
if (!unchanged()) return;
|
||||
if (!matching || config?.vapidPublicKey !== publicKey) { await invalidate(record); return; }
|
||||
stage = "subscription";
|
||||
const subscription = await options.platform.subscription() || await options.platform.subscribe(publicKey);
|
||||
if (!unchanged()) { await subscription.unsubscribe(); return; }
|
||||
// A tombstone also covers a lost registration response or failed local storage write.
|
||||
const pending = { ...record, enabled: false, revocationPending: true }; write(pending);
|
||||
const pending = { ...record, enabled: false, revocationPending: true };
|
||||
try {
|
||||
stage = "storage"; write(pending);
|
||||
stage = "service";
|
||||
const matchingBeforeRegister = await recheckContext(record);
|
||||
if (!unchanged() || !matchingBeforeRegister || config?.vapidPublicKey !== publicKey) { await revoke(pending); return; }
|
||||
await options.api.register(record.installationId, subscription.toJSON(), record.scope);
|
||||
if (!unchanged()) { await retireAtOriginalAuthority(record); await subscription.unsubscribe(); return; }
|
||||
const matchingAfterRegister = await recheckContext(record);
|
||||
if (!unchanged() || !matchingAfterRegister || config?.vapidPublicKey !== publicKey) { await revoke(pending); return; }
|
||||
write({ ...record, enabled: true, revocationPending: false });
|
||||
stage = "storage"; write({ ...record, enabled: true, revocationPending: false });
|
||||
registeredSubscription = JSON.stringify(subscription.toJSON());
|
||||
} catch {
|
||||
} catch (cause) {
|
||||
// Retain cleanup intent even when storage fails after subscribing.
|
||||
if (options.read()?.revision === record.revision) localRevocation = pending;
|
||||
await revoke(pending);
|
||||
throw new Error("Notifications could not be enabled. Try again when connected.");
|
||||
throw agentPushFailure(cause, stage);
|
||||
}
|
||||
});
|
||||
} catch { error = "Notifications could not be enabled. Try again when connected."; }
|
||||
} catch (cause) { error = agentPushFailure(cause, stage).message; }
|
||||
finally { busy = false; publish(); }
|
||||
};
|
||||
return { state, refresh, enable, disable };
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Text, controls and nested scrollers own their gestures, even at a scroll edge. */
|
||||
export function ownsMobileGesture(target: EventTarget | null, workspace: HTMLElement): boolean {
|
||||
if (!(target instanceof Element)) return true;
|
||||
if (target.closest("input, textarea, select, button, a, summary, [contenteditable]:not([contenteditable='false']), [role='button'], [role='dialog'], [data-mobile-swipe-ignore], .md-content, .message-bubble, .agent-room__plain-message, pre, code, table, blockquote")) return true;
|
||||
for (let element: Element | null = target; element && element !== workspace; element = element.parentElement) {
|
||||
if (element.scrollWidth > element.clientWidth + 1 && /auto|scroll/.test(getComputedStyle(element).overflowX)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function hasTextSelection(): boolean {
|
||||
const selection = window.getSelection();
|
||||
return !!selection && !selection.isCollapsed;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Production bundle/worker, mobile Chromium; all API and push-provider operations are fixtures.
|
||||
// Use the same PLAYWRIGHT_MODULE, CHROME_EXECUTABLE and WEB_TEST_URL as agent-push.browser.mjs.
|
||||
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || "playwright-core");
|
||||
const target = new URL(process.env.WEB_TEST_URL || "http://127.0.0.1:5197/");
|
||||
assert.ok(target.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(target.hostname));
|
||||
const browser = await chromium.launch({ executablePath: process.env.CHROME_EXECUTABLE || undefined, headless: true,
|
||||
...(process.env.BROWSER_NO_SANDBOX === "1" ? { args: ["--no-sandbox"] } : {}) });
|
||||
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
||||
const sourceId = "4599d348-e053-4937-987d-1392746fb2ce";
|
||||
const config = { version: 1, enabled: true, webPushAvailable: true, apnsAvailable: false, sourceId, quarantined: false,
|
||||
vapidPublicKey: "B".repeat(87), policy: { presenceHeartbeatMs: 15000, presenceLeaseMs: 60000 } };
|
||||
let registerStatus = 200, configStatus = 200, puts = 0;
|
||||
const errors = [];
|
||||
await context.addInitScript(() => {
|
||||
localStorage.setItem("sybil_admin_token", "mobile-notification-fixture");
|
||||
window.__permission = "default"; window.__decision = "default"; window.__subscribed = false; window.__subscribes = 0;
|
||||
window.__subscribeError = null; window.__workerError = false; window.__subscribeHang = false; window.__permissionGesture = false;
|
||||
Object.defineProperty(Notification, "permission", { configurable: true, get: () => window.__permission });
|
||||
Notification.requestPermission = () => {
|
||||
window.__permissionGesture = navigator.userActivation.isActive;
|
||||
window.__permission = window.__decision; return Promise.resolve(window.__permission);
|
||||
};
|
||||
const register = navigator.serviceWorker.register.bind(navigator.serviceWorker);
|
||||
navigator.serviceWorker.register = (...args) => window.__workerError ? Promise.reject(new Error("private-worker-detail")) : register(...args);
|
||||
const subscription = { toJSON: () => ({ endpoint: "https://fcm.googleapis.com/fcm/send/private-endpoint", keys: { p256dh: "B".repeat(87), auth: "A".repeat(22) } }),
|
||||
unsubscribe: async () => { window.__subscribed = false; return true; } };
|
||||
PushManager.prototype.getSubscription = async () => window.__subscribed ? subscription : null;
|
||||
PushManager.prototype.subscribe = async () => {
|
||||
window.__subscribes++;
|
||||
if (window.__subscribeError) throw new DOMException("private-provider-key", window.__subscribeError);
|
||||
if (window.__subscribeHang) return await new Promise(() => {});
|
||||
window.__subscribed = true; return subscription;
|
||||
};
|
||||
});
|
||||
await context.route("**/*", async route => {
|
||||
const request = route.request(), url = new URL(request.url()), path = url.pathname;
|
||||
if (url.origin !== target.origin) { await route.abort(); return; }
|
||||
if (!path.startsWith("/api/")) { await route.continue(); return; }
|
||||
let value = {}, status = 200;
|
||||
if (path.endsWith("/push/config")) { value = config; status = configStatus; }
|
||||
else if (path.includes("/push/installations/")) {
|
||||
if (request.method() === "DELETE") { await route.fulfill({ status: 204 }); return; }
|
||||
puts++; status = registerStatus;
|
||||
value = status === 200 ? { installationId: path.split("/").at(-1), transport: "web", enabled: true, version: 1 } : { error: "private-server-token" };
|
||||
} else if (path.endsWith("/auth/session")) value = { mode: "token", authenticated: true };
|
||||
else if (path.endsWith("/workspace-items")) value = { items: [] };
|
||||
else if (path.endsWith("/models")) value = { providers: {} };
|
||||
else if (path.endsWith("/chat-tools")) value = { tools: [] };
|
||||
else if (path.endsWith("/active-runs")) value = { chats: [], searches: [] };
|
||||
else if (path.endsWith("/read-state")) value = { sourceId, cursor: "0", updatedAt: null };
|
||||
await route.fulfill({ status, json: value });
|
||||
});
|
||||
await context.routeWebSocket("**/api/v1/agent/ws", socket => socket.close());
|
||||
const page = await context.newPage(); page.on("pageerror", error => errors.push(error.message));
|
||||
const enable = page.getByRole("button", { name: "Enable notifications", exact: true });
|
||||
const refresh = page.getByRole("button", { name: "Refresh", exact: true });
|
||||
const alert = page.locator(".workspace-sidebar [role=alert]");
|
||||
const waitEnable = () => page.waitForFunction(() => Array.from(document.querySelectorAll("button")).some(el => el.textContent === "Enable notifications" && !el.disabled));
|
||||
async function attempt(pattern) {
|
||||
await refresh.click(); await waitEnable(); await enable.click();
|
||||
await page.waitForFunction(pattern => {
|
||||
const el = document.querySelector(".workspace-sidebar [role=alert]");
|
||||
return el && new RegExp(pattern).test(el.textContent);
|
||||
}, pattern.source);
|
||||
assert.match(await alert.textContent(), pattern);
|
||||
assert.equal(await page.evaluate(() => window.__permissionGesture), true);
|
||||
assert.equal(await page.getByRole("button", { name: "Turn off notifications", exact: true }).count(), 0);
|
||||
assert.doesNotMatch(await page.locator(".workspace-sidebar").textContent(), /private-worker-detail|private-provider-key|private-server-token|private-endpoint/);
|
||||
}
|
||||
try {
|
||||
await page.goto(target.href, { waitUntil: "networkidle" });
|
||||
await page.getByRole("button", { name: "Open sidebar", exact: true }).click();
|
||||
await page.getByText("Agent notifications", { exact: true }).click();
|
||||
await attempt(/prompt was dismissed/); assert.equal(puts, 0);
|
||||
await page.evaluate(() => { window.__decision = "denied"; });
|
||||
await attempt(/not allowed/); assert.equal(puts, 0); assert.ok(await enable.isDisabled());
|
||||
await page.evaluate(() => { window.__permission = "default"; window.__decision = "granted"; window.__workerError = true; });
|
||||
await attempt(/worker could not start/); assert.equal(puts, 0);
|
||||
await page.evaluate(() => { window.__workerError = false; });
|
||||
for (const [name, pattern] of [["AbortError", /push service \(AbortError\)/], ["NotAllowedError", /site settings.*system settings/],
|
||||
["NotSupportedError", /cannot create a push subscription/], ["InvalidStateError", /incompatible notification subscription/]]) {
|
||||
await page.evaluate(name => { window.__subscribeError = name; }, name);
|
||||
await attempt(pattern); assert.equal(puts, 0);
|
||||
// Successful foreground config refresh must not dismiss an opt-in diagnosis.
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("online")));
|
||||
await page.waitForTimeout(150);
|
||||
assert.match(await alert.textContent(), pattern);
|
||||
}
|
||||
await page.evaluate(() => { window.__subscribeError = null; window.__subscribeHang = true; });
|
||||
await page.clock.install();
|
||||
await refresh.click(); await waitEnable();
|
||||
const before = await page.evaluate(() => window.__subscribes);
|
||||
await enable.click(); await page.waitForFunction(before => window.__subscribes > before, before);
|
||||
await page.clock.fastForward(10001);
|
||||
await alert.filter({ hasText: "TimeoutError" }).waitFor(); assert.equal(puts, 0);
|
||||
await page.clock.resume();
|
||||
await page.evaluate(() => { window.__subscribeHang = false; });
|
||||
for (const [status, pattern] of [[401, /sign-in.*authorized/], [403, /sign-in.*authorized/], [400, /rejected.*subscription/],
|
||||
[409, /expired/], [429, /busy/], [503, /unavailable on the Sybil server/]]) {
|
||||
registerStatus = status; await attempt(pattern);
|
||||
assert.equal(await page.evaluate(() => window.__subscribed), false, "failed registration rolls back the subscription");
|
||||
}
|
||||
registerStatus = 200; configStatus = 503;
|
||||
await refresh.click(); await alert.filter({ hasText: "unavailable on the Sybil server" }).waitFor();
|
||||
configStatus = 200;
|
||||
await refresh.click(); await waitEnable(); await enable.click();
|
||||
await page.getByRole("button", { name: "Turn off notifications", exact: true }).waitFor();
|
||||
assert.equal(await alert.count(), 0);
|
||||
await page.getByText("Android / GrapheneOS help", { exact: true }).click();
|
||||
await page.getByRole("link", { name: "GrapheneOS setup guide" }).waitFor();
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
|
||||
if (process.env.PUSH_ERROR_SCREENSHOT) await page.screenshot({ path: process.env.PUSH_ERROR_SCREENSHOT });
|
||||
for (const viewport of [{ width: 320, height: 568 }, { width: 390, height: 450 }]) {
|
||||
await page.setViewportSize(viewport);
|
||||
const signOut = page.getByRole("button", { name: "Sign out", exact: true });
|
||||
await signOut.scrollIntoViewIfNeeded();
|
||||
assert.ok(await signOut.evaluate(el => {
|
||||
const box = el.getBoundingClientRect(); return box.top >= 0 && box.bottom <= innerHeight;
|
||||
}), "notification controls remain reachable with long help and a short viewport");
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
|
||||
}
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.getByRole("button", { name: "Turn off notifications", exact: true }).click(); await waitEnable();
|
||||
await page.evaluate(() => { delete window.PushManager; });
|
||||
await refresh.click(); await page.getByText(/This browser does not support notifications here/).waitFor();
|
||||
assert.ok(await enable.isDisabled());
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(window, "isSecureContext", { configurable: true, value: true });
|
||||
Object.defineProperty(navigator, "userAgent", { configurable: true, value: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)" });
|
||||
});
|
||||
await refresh.click(); await page.getByText(/On iPhone or iPad, add Sybil to your Home Screen/).waitFor();
|
||||
assert.ok(await enable.isDisabled());
|
||||
await page.evaluate(() => { Object.defineProperty(window, "isSecureContext", { configurable: true, value: false }); });
|
||||
await refresh.click(); await page.getByText(/Notifications require a secure connection/).waitFor();
|
||||
assert.ok(await enable.isDisabled());
|
||||
assert.deepEqual(errors, []);
|
||||
console.log("PASS mobile permission dismissal/denial, worker/subscription failures, timeout, HTTP statuses, rollback, retained diagnostics, recovery, unsupported/insecure browsers and GrapheneOS guidance");
|
||||
} finally { await context.close(); await browser.close(); }
|
||||
@@ -75,7 +75,7 @@ const expectEventually = async predicate => { for (let i = 0; i < 100; i++) { if
|
||||
try {
|
||||
await page.goto(target.href, { waitUntil: "networkidle" });
|
||||
await page.getByText("Agent notifications", { exact: true }).click();
|
||||
await page.getByText("Notifications are not configured on this server yet.", { exact: true }).waitFor();
|
||||
await page.getByText(/The Agent notification source is not ready yet/).waitFor();
|
||||
assert.equal(await page.getByRole("button", { name: "Enable notifications", exact: true }).isDisabled(), true, "unknown source cannot opt in");
|
||||
assert.deepEqual(await page.evaluate(() => window.__pushCalls), []);
|
||||
config.sourceId = sourceId;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
MAX_AGENT_PUSH_REVOCATIONS, parseAgentPushConfig, parseAgentPushConsent,
|
||||
} from "../src/lib/agent-push.ts";
|
||||
import { agentAuthFrame, applyAgentEvent, createAgentRoomState, parseAgentRoomEvent } from "../src/lib/agent-room.ts";
|
||||
import { AgentPushError, browserPushOperation } from "../src/lib/agent-push-errors.ts";
|
||||
|
||||
const sourceId = "4599d348-e053-4937-987d-1392746fb2ce";
|
||||
const replacementSourceId = "22222222-2222-4222-8222-222222222222";
|
||||
@@ -120,7 +121,72 @@ test("a stuck cross-tab lock cannot prevent server retirement or local logout cl
|
||||
test("synchronous permission API failures remain a recoverable opt-in error", async () => {
|
||||
const f = fixture({ platform: { requestPermission: () => { throw new Error("user gesture rejected"); } } });
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.equal(f.record().enabled, false); assert.match(f.controller.state().error, /could not be requested/);
|
||||
assert.equal(f.record().enabled, false); assert.match(f.controller.state().error, /browser.*system settings/);
|
||||
});
|
||||
|
||||
test("browser subscription failures retain safe diagnoses across background refreshes", async () => {
|
||||
for (const [name, message] of [["AbortError", /push service \(AbortError\)/], ["NotAllowedError", /site settings.*system settings/],
|
||||
["NotSupportedError", /cannot create a push subscription/], ["InvalidStateError", /incompatible notification subscription/], ["InvalidAccessError", /notification key/]]) {
|
||||
const f = fixture({ platform: { subscribe: () => browserPushOperation(() => Promise.reject(new DOMException("private-endpoint-and-key", name)), "subscription") } });
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.match(f.controller.state().error, message);
|
||||
assert.equal(f.controller.state().enabled, false);
|
||||
assert.ok(!f.calls.some(([name]) => name === "register"));
|
||||
await f.controller.refresh();
|
||||
assert.match(f.controller.state().error, message, "heartbeat cannot erase a failed opt-in");
|
||||
assert.doesNotMatch(JSON.stringify(f.states), /private-endpoint-and-key/);
|
||||
await f.controller.refresh(true);
|
||||
assert.equal(f.controller.state().error, null, "explicit refresh clears the previous attempt");
|
||||
}
|
||||
});
|
||||
|
||||
test("worker startup and subscription timeouts have distinct actionable errors", async () => {
|
||||
for (const [stage, message] of [["worker", /worker could not start/], ["subscription", /push service \(TimeoutError\)/]]) {
|
||||
const f = fixture({ platform: { subscribe: () => browserPushOperation(() => new Promise(() => {}), stage, 5) } });
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.match(f.controller.state().error, message); assert.equal(f.controller.state().busy, false);
|
||||
assert.equal(f.controller.state().enabled, false);
|
||||
}
|
||||
});
|
||||
|
||||
test("dismissed prompts and insecure contexts do not attempt a subscription", async () => {
|
||||
const f = fixture({ platform: { requestPermission: async () => "default" } });
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.match(f.controller.state().error, /dismissed/); assert.equal(f.calls.length, 0);
|
||||
f.setSupport("insecure");
|
||||
assert.equal(f.controller.state().canEnable, false); assert.match(f.controller.state().message, /HTTPS/);
|
||||
});
|
||||
|
||||
test("notification API errors retain status-specific guidance through rollback without echoing bodies", async () => {
|
||||
for (const [status, message] of [[401, /sign-in.*authorized/], [403, /sign-in.*authorized/], [400, /rejected.*subscription/],
|
||||
[409, /expired/], [429, /busy/], [503, /unavailable on the Sybil server/], [500, /server.*save notification settings/]]) {
|
||||
const api = new AgentPushClient({ baseUrl: "https://sybil.example", token: () => "private-auth-token",
|
||||
fetch: async () => new Response(JSON.stringify({ error: "private-endpoint-and-key" }), { status }) });
|
||||
const f = fixture({ api: { register: (id, subscription) => api.register(id, subscription) } });
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.match(f.controller.state().error, message); assert.equal(f.controller.state().enabled, false);
|
||||
assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
|
||||
assert.doesNotMatch(JSON.stringify(f.states), /private-auth-token|private-endpoint-and-key/);
|
||||
}
|
||||
});
|
||||
|
||||
test("failed storage after subscribing still unsubscribes and retains cleanup intent", async () => {
|
||||
const f = fixture(); let writes = 0;
|
||||
const write = f.options.write;
|
||||
f.options.write = value => { if (++writes > 1) throw new DOMException("private-storage-detail", "QuotaExceededError"); write(value); };
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.match(f.controller.state().error, /storage.*free some space/);
|
||||
assert.equal(f.controller.state().enabled, false);
|
||||
assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
|
||||
assert.ok(f.calls.some(([name]) => name === "revoke"));
|
||||
assert.ok(!f.calls.some(([name]) => name === "register"));
|
||||
assert.doesNotMatch(JSON.stringify(f.states), /private-storage-detail/);
|
||||
});
|
||||
|
||||
test("server errors during refresh are distinct from unavailable browser push", async () => {
|
||||
const f = fixture({ api: { config: async () => { throw new AgentPushError("Web Push is unavailable on the Sybil server."); } } });
|
||||
await f.controller.refresh();
|
||||
assert.match(f.controller.state().error, /Sybil server/); assert.equal(f.controller.state().canEnable, false);
|
||||
});
|
||||
|
||||
test("subscription rotation upserts the same installation; permission revocation removes it", async () => {
|
||||
@@ -418,6 +484,7 @@ test("read frames validate source/cursor and preserve existing read semantics",
|
||||
assert.throws(() => parseAgentRoomEvent(JSON.stringify({ type: "read_state", cursor: "1", updatedAt: null, sourceId: "https://evil.test" })));
|
||||
assert.equal(parseAgentPushConsent('{"enabled":true}'), null);
|
||||
assert.throws(() => parseAgentPushConfig({ ...configuration(), policy: { presenceHeartbeatMs: 1, presenceLeaseMs: 60000 } }));
|
||||
assert.throws(() => parseAgentPushConfig({ ...configuration(), vapidPublicKey: undefined }), /invalid notification configuration/);
|
||||
});
|
||||
|
||||
test("API keeps credentials in headers, validates receipts, handles 204 and redacts provider bodies", async () => {
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
|
||||
// Run against a local production preview after npm run build. No live API calls.
|
||||
// PLAYWRIGHT_MODULE=/path/playwright-core/index.mjs CHROME_EXECUTABLE=/path/chrome
|
||||
// WEB_TEST_URL=http://127.0.0.1:5197 node tests/mobile-workspace.browser.mjs
|
||||
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || "playwright-core");
|
||||
const target = new URL(process.env.WEB_TEST_URL || "http://127.0.0.1:5197/");
|
||||
assert.ok(target.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(target.hostname));
|
||||
const baseline = process.env.MOBILE_BASELINE === "1";
|
||||
const artifactDir = process.env.MOBILE_ARTIFACT_DIR;
|
||||
if (artifactDir) await mkdir(artifactDir, { recursive: true });
|
||||
const browser = await chromium.launch({ executablePath: process.env.CHROME_EXECUTABLE || undefined, headless: true,
|
||||
...(process.env.BROWSER_NO_SANDBOX === "1" ? { args: ["--no-sandbox"] } : {}) });
|
||||
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true,
|
||||
permissions: ["clipboard-read", "clipboard-write"] });
|
||||
const sourceId = "4599d348-e053-4937-987d-1392746fb2ce";
|
||||
const date = "2026-11-02T12:00:00.000Z";
|
||||
const markdown = [
|
||||
"Mobile answer with selectable text. " + "This regular chat response should use the available width. ".repeat(7),
|
||||
"| Name | Description with a very wide column | Another long heading | Final column |\n| --- | --- | --- | --- |\n| Alpha | Wide table content | More content | End |",
|
||||
"> A quotation with selectable text and a nested horizontal code block:\n>\n> ```text\n> " + "quoted-unbroken-code-".repeat(22) + "\n> ```",
|
||||
"```js\nconst longLine = '" + "horizontal-code-".repeat(28) + "';\n```",
|
||||
"A final paragraph with a [native link menu](https://example.test/document).",
|
||||
].join("\n\n");
|
||||
const chat = { id: "mobile-chat", title: "Mobile QA conversation with a long title", parentChatId: null, titleGenerationPending: false,
|
||||
createdAt: date, updatedAt: date, starred: false, starredAt: null, initiatedProvider: "openai", initiatedModel: "mobile-model-one",
|
||||
lastUsedProvider: "openai", lastUsedModel: "mobile-model-one", additionalSystemPrompt: null, enabledTools: [],
|
||||
messages: [{ id: "mobile-assistant", role: "assistant", content: markdown, createdAt: date, name: null, metadata: null }] };
|
||||
const idle = { state: "idle", isThinking: false, queued: 0, backgroundWorkers: 0, tools: [], queuedMessages: [] };
|
||||
const errors = [], calls = [];
|
||||
let readCursor = "0";
|
||||
await context.addInitScript(() => localStorage.setItem("sybil_admin_token", "mobile-fixture-token"));
|
||||
await context.route("**/*", async route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== target.origin) { await route.abort(); return; }
|
||||
if (!url.pathname.startsWith("/api/")) { await route.continue(); return; }
|
||||
const path = url.pathname, method = route.request().method();
|
||||
calls.push({ path, method });
|
||||
let value;
|
||||
if (path.endsWith("/auth/session")) value = { mode: "token", authenticated: true };
|
||||
else if (path.endsWith("/workspace-items")) value = { items: [{ ...chat, type: "chat" }] };
|
||||
else if (path.endsWith("/models")) value = { providers: { openai: { models: ["mobile-model-one", "mobile-model-two"], loadedAt: date, error: null } } };
|
||||
else if (path.endsWith("/chat-tools") || path.endsWith("/agent/tools")) value = { tools: [] };
|
||||
else if (path.endsWith("/active-runs")) value = { chats: [], searches: [] };
|
||||
else if (path.endsWith("/chats/mobile-chat")) {
|
||||
if (method === "PATCH") Object.assign(chat, route.request().postDataJSON());
|
||||
value = { chat };
|
||||
} else if (path.endsWith("/agent/messages")) value = { conversationId: "1", messages: [{ ...chat.messages[0], id: "outbound:1", metadata: { entryId: 1 } }], cursor: "1", beforeCursor: null, hasMore: false };
|
||||
else if (path.endsWith("/agent")) value = { id: "1", title: "Sybil Agent", activity: idle, protocol: "pi-room.v1", websocket: "/v1/agent/ws" };
|
||||
else if (path.endsWith("/read-state")) {
|
||||
if (method === "PUT") readCursor = route.request().postDataJSON().cursor;
|
||||
value = { sourceId, cursor: readCursor, updatedAt: null };
|
||||
}
|
||||
else if (path.endsWith("/push/config")) value = { version: 1, enabled: false, webPushAvailable: false, apnsAvailable: false, sourceId, quarantined: false, policy: { presenceHeartbeatMs: 15000, presenceLeaseMs: 60000 } };
|
||||
else if (path.includes("/presence/")) value = { expiresAt: date };
|
||||
else value = {};
|
||||
await route.fulfill({ json: value });
|
||||
});
|
||||
await context.routeWebSocket("**/api/v1/agent/ws", socket => socket.onMessage(raw => {
|
||||
if (JSON.parse(String(raw)).type === "auth") socket.send(JSON.stringify({ type: "ready", conversationId: "1" }));
|
||||
}));
|
||||
const page = await context.newPage();
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
const cdp = await context.newCDPSession(page);
|
||||
async function openSidebar() {
|
||||
const sidebar = page.locator(".workspace-sidebar");
|
||||
if (await sidebar.evaluate(el => el.getBoundingClientRect().right <= 0)) {
|
||||
await page.getByRole("button", { name: "Open sidebar", exact: true }).click();
|
||||
}
|
||||
}
|
||||
async function selectChat() {
|
||||
await openSidebar();
|
||||
await page.getByRole("button", { name: new RegExp(chat.title) }).click();
|
||||
await page.locator("main h1").filter({ hasText: chat.title }).waitFor();
|
||||
await page.locator("main .md-content").waitFor();
|
||||
await page.waitForFunction(() => document.querySelector(".workspace-sidebar").getBoundingClientRect().right <= 1);
|
||||
}
|
||||
async function drag(x, y, dx, dy = 0) {
|
||||
await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
|
||||
for (let step = 1; step <= 8; step++) {
|
||||
await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: x + dx * step / 8, y: y + dy * step / 8 }] });
|
||||
await page.waitForTimeout(25);
|
||||
}
|
||||
await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
async function scrollHorizontally(locator) {
|
||||
await locator.scrollIntoViewIfNeeded();
|
||||
await locator.evaluate(el => { el.scrollLeft = 0; });
|
||||
const box = await locator.boundingBox();
|
||||
const x = Math.min(box.x + box.width - 30, 350), y = box.y + Math.min(box.height / 2, 30);
|
||||
await drag(x, y, -130);
|
||||
}
|
||||
async function contextMenuCanceled(locator) {
|
||||
return locator.evaluate(el => !el.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 100, clientY: 200 })));
|
||||
}
|
||||
try {
|
||||
await page.goto(target.href, { waitUntil: "networkidle" });
|
||||
await selectChat();
|
||||
const metrics = await page.locator("main .md-content").evaluate(el => ({
|
||||
width: el.getBoundingClientRect().width, parentWidth: el.parentElement.parentElement.getBoundingClientRect().width,
|
||||
touchAction: getComputedStyle(document.querySelector("main")).touchAction,
|
||||
modelVisible: Array.from(document.querySelectorAll("main header p")).some(el => el.textContent.includes("mobile-model-one") && el.getBoundingClientRect().height > 0),
|
||||
}));
|
||||
if (artifactDir) await page.screenshot({ path: `${artifactDir}/regular-390.png` });
|
||||
const canceled = await contextMenuCanceled(page.locator("main .md-content p").first());
|
||||
if (baseline) { await page.getByRole("button", { name: "Fork", exact: true }).waitFor(); await page.keyboard.press("Escape"); }
|
||||
const table = page.locator("main .md-table-scroll");
|
||||
await scrollHorizontally(table);
|
||||
const stayed = await page.locator("main h1").textContent() === chat.title;
|
||||
const scrolled = stayed ? await table.evaluate(el => el.scrollLeft) : 0;
|
||||
console.log(JSON.stringify({ baseline, metrics, contextMenuCanceled: canceled, tableScrollLeft: scrolled, stayedInChat: stayed }));
|
||||
if (baseline) {
|
||||
await openSidebar(); await page.locator('[data-testid="agent-sidebar-item"]').click();
|
||||
await page.locator(".agent-room .md-content").waitFor();
|
||||
await page.waitForFunction(() => document.querySelector(".workspace-sidebar").getBoundingClientRect().right <= 1);
|
||||
console.log("Agent width", await page.locator(".agent-room .md-content").evaluate(el => el.getBoundingClientRect().width));
|
||||
if (artifactDir) await page.screenshot({ path: `${artifactDir}/agent-390.png` });
|
||||
} else {
|
||||
assert.equal(metrics.touchAction, "auto");
|
||||
assert.equal(metrics.modelVisible, true);
|
||||
assert.ok(Math.abs(metrics.width - metrics.parentWidth) < 2, "compact answer uses full transcript width");
|
||||
assert.equal(canceled, false, "native menu is not canceled");
|
||||
assert.ok(stayed && scrolled > 50, "trusted horizontal pan scrolls the table without navigation");
|
||||
// Even at either end of nested scrollers, pans belong to the content.
|
||||
for (const locator of [table, page.locator("main blockquote pre"), page.locator("main .md-content > pre")]) {
|
||||
await scrollHorizontally(locator);
|
||||
assert.equal(await page.locator("main h1").textContent(), chat.title);
|
||||
assert.ok(await locator.evaluate(el => el.scrollLeft > 50));
|
||||
await locator.evaluate(el => { el.scrollLeft = el.scrollWidth; });
|
||||
await locator.scrollIntoViewIfNeeded();
|
||||
let box = await locator.boundingBox();
|
||||
await drag(box.x + box.width - 30, box.y + 20, -130);
|
||||
assert.equal(await page.locator("main h1").textContent(), chat.title);
|
||||
await locator.evaluate(el => { el.scrollLeft = 0; });
|
||||
box = await locator.boundingBox();
|
||||
await drag(box.x + 30, box.y + 20, 130);
|
||||
assert.ok(await page.locator(".workspace-sidebar").evaluate(el => el.getBoundingClientRect().right <= 0));
|
||||
}
|
||||
const paragraph = page.locator("main .md-content p").first();
|
||||
await paragraph.scrollIntoViewIfNeeded();
|
||||
await paragraph.evaluate(el => {
|
||||
const range = document.createRange(); range.selectNodeContents(el);
|
||||
window.getSelection().removeAllRanges(); window.getSelection().addRange(range);
|
||||
});
|
||||
await page.keyboard.press("Control+c");
|
||||
assert.match(await page.evaluate(() => navigator.clipboard.readText()), /Mobile answer/);
|
||||
assert.equal(await contextMenuCanceled(paragraph), false);
|
||||
assert.equal(await contextMenuCanceled(page.locator("main .md-content a")), false);
|
||||
await page.keyboard.press("Escape");
|
||||
// Selection, multi-touch, long-press and touchcancel must not become navigation.
|
||||
for (const scenario of ["selection", "multi", "hold", "cancel"]) {
|
||||
await page.locator("main header").evaluate((el, scenario) => {
|
||||
if (scenario !== "selection") window.getSelection().removeAllRanges();
|
||||
const touch = (x, id = 1) => new Touch({ identifier: id, target: el, clientX: x, clientY: 20 });
|
||||
const send = (type, touches, changed = touches, time = 0) => {
|
||||
const event = new TouchEvent(type, { bubbles: true, cancelable: true, touches, changedTouches: changed });
|
||||
Object.defineProperty(event, "timeStamp", { value: 1000 + time }); el.dispatchEvent(event);
|
||||
};
|
||||
send("touchstart", [touch(120)]);
|
||||
if (scenario === "multi") send("touchstart", [touch(120), touch(160, 2)]);
|
||||
if (scenario === "cancel") send("touchcancel", [], [touch(120)]);
|
||||
send("touchmove", [touch(280)], undefined, scenario === "hold" ? 500 : 100);
|
||||
send("touchend", [], [touch(280)], 600);
|
||||
}, scenario);
|
||||
assert.ok(await page.locator(".workspace-sidebar").evaluate(el => el.getBoundingClientRect().right <= 0), scenario);
|
||||
}
|
||||
await page.evaluate(() => window.getSelection().removeAllRanges());
|
||||
const composer = page.locator("main textarea");
|
||||
assert.equal(await contextMenuCanceled(composer), false);
|
||||
await page.evaluate(() => navigator.clipboard.writeText("native pasted draft"));
|
||||
await composer.focus(); await page.keyboard.press("Control+v");
|
||||
assert.equal(await composer.inputValue(), "native pasted draft");
|
||||
assert.equal(calls.filter(call => call.method === "POST").length, 0);
|
||||
// Explicit message actions preserve Fork without replacing the native menu.
|
||||
await page.getByRole("button", { name: "Message actions", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Fork", exact: true }).waitFor();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("button", { name: "Open chat settings" }).click();
|
||||
await page.getByRole("dialog").waitFor();
|
||||
await page.getByPlaceholder("Select or type model", { exact: true }).fill("mobile-model-two");
|
||||
await page.getByPlaceholder("Select or type model", { exact: true }).press("Enter");
|
||||
await page.getByRole("button", { name: "Save", exact: true }).first().click();
|
||||
await page.getByRole("dialog").waitFor({ state: "detached" });
|
||||
assert.match(await page.locator("main header p").textContent(), /mobile-model-two/);
|
||||
for (const width of [320, 390, 430, 767]) {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false, `${width}px page overflow`);
|
||||
assert.ok(await page.locator("main header p").isVisible());
|
||||
if (artifactDir) await page.screenshot({ path: `${artifactDir}/regular-${width}.png` });
|
||||
}
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
// Swipes still work in workspace chrome, away from text and scrollable content.
|
||||
await drag(90, 8, 150);
|
||||
await page.waitForFunction(() => document.querySelector(".workspace-sidebar").getBoundingClientRect().left >= 0);
|
||||
await page.locator('[data-testid="agent-sidebar-item"]').click();
|
||||
await page.locator(".agent-room .md-content").waitFor();
|
||||
await page.waitForFunction(() => document.querySelector(".workspace-sidebar").getBoundingClientRect().right <= 1);
|
||||
console.log("Agent width", await page.locator(".agent-room .md-content").evaluate(el => el.getBoundingClientRect().width));
|
||||
if (artifactDir) await page.screenshot({ path: `${artifactDir}/agent-390.png` });
|
||||
await scrollHorizontally(page.locator(".agent-room .md-table-scroll"));
|
||||
assert.ok(await page.locator(".agent-room .md-table-scroll").evaluate(el => el.scrollLeft > 50));
|
||||
assert.equal(await contextMenuCanceled(page.locator(".agent-room .md-content p").first()), false);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
|
||||
// Keyboard-height approximation: both composers remain inside a shorter viewport.
|
||||
await page.setViewportSize({ width: 390, height: 450 });
|
||||
assert.ok(await page.locator(".agent-room textarea").evaluate(el => el.getBoundingClientRect().bottom <= innerHeight));
|
||||
await selectChat();
|
||||
assert.ok(await composer.evaluate(el => el.getBoundingClientRect().bottom <= innerHeight));
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
|
||||
assert.ok(await page.locator("main header button").filter({ hasText: "mobile-model-one" }).isVisible());
|
||||
assert.equal(await contextMenuCanceled(paragraph), false);
|
||||
const manifest = await page.evaluate(async () => (await fetch("/manifest.webmanifest")).json());
|
||||
assert.equal(manifest.display, "standalone"); assert.ok(!manifest.display_override?.includes("fullscreen"));
|
||||
const viewport = await page.locator('meta[name="viewport"]').getAttribute("content");
|
||||
assert.match(viewport, /user-scalable=no/);
|
||||
assert.match(viewport, /maximum-scale=1\.0(?:,|$)/);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await drag(300, 8, -160);
|
||||
await page.getByRole("heading", { name: "New chat", exact: true }).waitFor();
|
||||
assert.deepEqual(errors, []);
|
||||
console.log("PASS mobile horizontal scrolling/edges, native menu/copy/paste, selection/multitouch/cancellation, compact widths/models, Agent layout, shorter viewport, desktop and PWA settings");
|
||||
}
|
||||
} finally { await context.close(); await browser.close(); }
|
||||
Reference in New Issue
Block a user