Add aligned assistant timestamps and Copy/Fork actions
This commit is contained in:
+2
-1
@@ -43,6 +43,7 @@ Default dev URL: `http://localhost:5173`
|
||||
prefixes, so `plan` also finds `planning`.
|
||||
- Right panel:
|
||||
- Chat mode: transcript + provider/model controls.
|
||||
- Saved assistant replies show their received date/time in your local timezone, with Copy and Fork actions aligned beneath the reply on mobile and desktop. Copy preserves the message's Markdown; Fork starts a conversation through that reply.
|
||||
- Search mode: top AI answer block + Google-style Exa results view.
|
||||
- Composer adapts to the active item:
|
||||
- Chat sends `POST /v1/chat-completions/stream` (SSE).
|
||||
@@ -65,6 +66,6 @@ Client API contract docs:
|
||||
|
||||
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.
|
||||
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 reply footer provides Copy and 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.
|
||||
|
||||
+37
-74
@@ -121,12 +121,6 @@ type ContextMenuState = {
|
||||
item: SidebarSelection;
|
||||
x: number;
|
||||
y: number;
|
||||
} | {
|
||||
kind: "message";
|
||||
chatId: string;
|
||||
messageId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
type RenameChatDialogState = {
|
||||
chatId: string;
|
||||
@@ -2199,24 +2193,6 @@ export default function App() {
|
||||
setContextMenu({ kind: "sidebar", item, x: Math.max(padding, x), y: Math.max(padding, y) });
|
||||
};
|
||||
|
||||
const openMessageContextMenu = (event: MouseEvent, messageId: string) => {
|
||||
if (selectedItem?.kind !== "chat" || messageId.startsWith("temp-")) return;
|
||||
event.preventDefault();
|
||||
const menuWidth = 176;
|
||||
const menuHeight = 48;
|
||||
const padding = 8;
|
||||
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,
|
||||
messageId,
|
||||
x: Math.max(padding, x),
|
||||
y: Math.max(padding, y),
|
||||
});
|
||||
};
|
||||
|
||||
const handleRenameChatSubmit = async (event?: Event) => {
|
||||
event?.preventDefault();
|
||||
if (!renameChatDialog || isRenamingChat) return;
|
||||
@@ -3819,7 +3795,8 @@ export default function App() {
|
||||
messages={displayMessages}
|
||||
isLoading={isLoadingSelection}
|
||||
isSending={isSendingActiveChat}
|
||||
onMessageActions={openMessageContextMenu}
|
||||
onForkMessage={selectedItem?.kind === "chat" ? messageId => void handleForkChat(selectedItem.id, messageId) : undefined}
|
||||
isForking={isForkingChat}
|
||||
/>
|
||||
) : (
|
||||
<SearchResultsPanel
|
||||
@@ -3906,65 +3883,51 @@ export default function App() {
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
{contextMenu.kind === "message" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-violet-100 transition hover:bg-violet-400/12 disabled:text-muted-foreground"
|
||||
onClick={() => void handleForkChat(contextMenu.chatId, contextMenu.messageId)}
|
||||
disabled={isForkingChat}
|
||||
>
|
||||
<GitFork className="h-3.5 w-3.5" />
|
||||
Fork
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-violet-100 transition hover:bg-violet-400/12"
|
||||
onClick={() => void handleToggleStar(contextMenu.item)}
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
isSidebarTargetStarred(contextMenu.item)
|
||||
? "fill-amber-300 text-amber-300"
|
||||
: ""
|
||||
)}
|
||||
/>
|
||||
{isSidebarTargetStarred(contextMenu.item) ? "Unstar" : "Star"}
|
||||
</button>
|
||||
{contextMenu.item.kind === "chat" ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-violet-100 transition hover:bg-violet-400/12"
|
||||
onClick={() => void handleToggleStar(contextMenu.item)}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-violet-100 transition hover:bg-violet-400/12 disabled:text-muted-foreground"
|
||||
onClick={() => void handleForkChat(contextMenu.item.id)}
|
||||
disabled={isForkingChat || isItemRunning(contextMenu.item)}
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
isSidebarTargetStarred(contextMenu.item)
|
||||
? "fill-amber-300 text-amber-300"
|
||||
: ""
|
||||
)}
|
||||
/>
|
||||
{isSidebarTargetStarred(contextMenu.item) ? "Unstar" : "Star"}
|
||||
<GitFork className="h-3.5 w-3.5" />
|
||||
Fork
|
||||
</button>
|
||||
{contextMenu.item.kind === "chat" ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-violet-100 transition hover:bg-violet-400/12 disabled:text-muted-foreground"
|
||||
onClick={() => void handleForkChat(contextMenu.item.id)}
|
||||
disabled={isForkingChat || isItemRunning(contextMenu.item)}
|
||||
>
|
||||
<GitFork className="h-3.5 w-3.5" />
|
||||
Fork
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-violet-100 transition hover:bg-violet-400/12"
|
||||
onClick={() => openRenameChatDialog(contextMenu.item.id)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Rename
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-rose-300 transition hover:bg-rose-500/12 disabled:text-muted-foreground"
|
||||
onClick={() => void handleDeleteFromContextMenu()}
|
||||
disabled={isDeleteBlocked(contextMenu.item)}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-violet-100 transition hover:bg-violet-400/12"
|
||||
onClick={() => openRenameChatDialog(contextMenu.item.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{isRootChat(contextMenu.item) ? "Delete group" : "Delete"}
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Rename
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-rose-300 transition hover:bg-rose-500/12 disabled:text-muted-foreground"
|
||||
onClick={() => void handleDeleteFromContextMenu()}
|
||||
disabled={isDeleteBlocked(contextMenu.item)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{isRootChat(contextMenu.item) ? "Delete group" : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{isChatSettingsOpen ? (
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { Check, Copy, GitFork } from "lucide-preact";
|
||||
import type { Message } from "@/lib/api";
|
||||
|
||||
const receivedFormat = new Intl.DateTimeFormat(undefined, {
|
||||
year: "numeric", month: "short", day: "numeric", hour: "numeric", minute: "2-digit",
|
||||
});
|
||||
const actionClass = "inline-flex h-10 items-center gap-1.5 rounded-md px-2 text-xs transition-colors hover:bg-violet-400/12 hover:text-violet-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring disabled:cursor-wait disabled:opacity-50";
|
||||
|
||||
export function AssistantMessageFooter({ message, onFork, isForking = false }: {
|
||||
message: Message;
|
||||
onFork?: (messageId: string) => void;
|
||||
isForking?: boolean;
|
||||
}) {
|
||||
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
|
||||
const resetTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
useEffect(() => () => clearTimeout(resetTimer.current), []);
|
||||
|
||||
const copy = async () => {
|
||||
clearTimeout(resetTimer.current);
|
||||
try {
|
||||
await navigator.clipboard.writeText(message.content);
|
||||
setCopyState("copied");
|
||||
resetTimer.current = setTimeout(() => setCopyState("idle"), 2000);
|
||||
} catch {
|
||||
setCopyState("failed");
|
||||
}
|
||||
};
|
||||
const received = new Date(message.createdAt);
|
||||
const hasDate = Number.isFinite(received.getTime());
|
||||
|
||||
return (
|
||||
<div className="text-muted-foreground" data-assistant-footer={message.id} data-mobile-swipe-ignore>
|
||||
{hasDate ? <time className="block text-xs leading-5" dateTime={message.createdAt} title={`Received ${received.toLocaleString()}`}>
|
||||
{receivedFormat.format(received)}
|
||||
</time> : null}
|
||||
<div className="-ml-2 flex flex-wrap items-center gap-1" role="group" aria-label="Message actions">
|
||||
{message.content.trim() ? <button type="button" className={actionClass} onClick={() => void copy()} title="Copy message" aria-label="Copy message">
|
||||
{copyState === "copied" ? <Check className="h-3.5 w-3.5" aria-hidden="true" /> : <Copy className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
<span>{copyState === "copied" ? "Copied" : "Copy"}</span>
|
||||
</button> : null}
|
||||
{onFork ? <button type="button" className={actionClass} onClick={() => onFork(message.id)} disabled={isForking} title="Fork chat from this message" aria-label="Fork chat from this message">
|
||||
<GitFork className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>Fork</span>
|
||||
</button> : null}
|
||||
</div>
|
||||
<span className={copyState === "failed" ? "block text-xs leading-5" : "sr-only"} role="status" aria-live="polite">
|
||||
{copyState === "copied" ? "Message copied." : copyState === "failed" ? "Could not copy. Select the message text and use your browser’s Copy command." : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,17 +2,19 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/ho
|
||||
import type { ComponentChildren, JSX } from "preact";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
||||
import { AssistantMessageFooter } from "@/components/chat/assistant-message-footer";
|
||||
import { getMessageAttachments, type Message } from "@/lib/api";
|
||||
import { asStreamingAssistantMetadata, isInlineAgentReplyPending } from "@/lib/chat-stream-presentation";
|
||||
import { AgentBubble } from "@/components/agent/agent-conversation";
|
||||
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
||||
import { ChevronDown, ChevronUp, Globe2, Link2, MoreHorizontal, Wrench } from "lucide-preact";
|
||||
import { ChevronDown, ChevronUp, Globe2, Link2, Wrench } from "lucide-preact";
|
||||
|
||||
type Props = {
|
||||
messages: Message[];
|
||||
isLoading: boolean;
|
||||
isSending: boolean;
|
||||
onMessageActions?: (event: MouseEvent, messageId: string) => void;
|
||||
onForkMessage?: (messageId: string) => void;
|
||||
isForking?: boolean;
|
||||
};
|
||||
|
||||
type ToolLogMetadata = {
|
||||
@@ -538,7 +540,7 @@ export function StreamingAssistantPresentation({ message, isSending }: { message
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageActions }: Props) {
|
||||
export function ChatMessagesPanel({ messages, isLoading, isSending, onForkMessage, isForking }: Props) {
|
||||
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-"));
|
||||
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
|
||||
const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]);
|
||||
@@ -637,12 +639,8 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageAct
|
||||
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>
|
||||
{message.role === "assistant" && !message.id.startsWith("temp-") ? (
|
||||
<AssistantMessageFooter message={message} onFork={onForkMessage} isForking={isForking} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,8 @@ const chat = { id: "mobile-chat", title: "Mobile QA conversation with a long tit
|
||||
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 = [];
|
||||
const errors = [], calls = [], forkRequests = [];
|
||||
let forkedChat;
|
||||
let readCursor = "0";
|
||||
await context.addInitScript(() => localStorage.setItem("sybil_admin_token", "mobile-fixture-token"));
|
||||
await context.route("**/*", async route => {
|
||||
@@ -39,14 +40,20 @@ await context.route("**/*", async route => {
|
||||
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("/workspace-items")) value = { items: [chat, ...(forkedChat ? [forkedChat] : [])].map(chat => ({ ...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("/chats/mobile-chat/fork")) {
|
||||
forkRequests.push(route.request().postDataJSON());
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
forkedChat = { ...chat, id: "mobile-fork", title: "Forked conversation", parentChatId: chat.id };
|
||||
value = { chat: forkedChat };
|
||||
} else if (path.endsWith("/chats/mobile-fork")) value = { chat: forkedChat };
|
||||
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;
|
||||
@@ -95,6 +102,24 @@ async function scrollHorizontally(locator) {
|
||||
async function contextMenuCanceled(locator) {
|
||||
return locator.evaluate(el => !el.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 100, clientY: 200 })));
|
||||
}
|
||||
async function checkFooterAlignment() {
|
||||
const footer = page.locator('[data-assistant-footer="mobile-assistant"]');
|
||||
await footer.scrollIntoViewIfNeeded();
|
||||
// Leave space for the fixed composer when inspecting the final reply actions.
|
||||
await footer.evaluate(el => el.scrollIntoView({ block: "center" }));
|
||||
const layout = await footer.evaluate(el => ({
|
||||
paragraphLeft: el.parentElement.querySelector(".md-content > p").getBoundingClientRect().left,
|
||||
dateLeft: el.querySelector("time").getBoundingClientRect().left,
|
||||
actionLeft: el.querySelector("button svg").getBoundingClientRect().left,
|
||||
dateColor: getComputedStyle(el.querySelector("time")).color,
|
||||
paragraphColor: getComputedStyle(el.parentElement.querySelector(".md-content")).color,
|
||||
}));
|
||||
assert.ok(Math.abs(layout.dateLeft - layout.paragraphLeft) < 1, "timestamp aligns with reply text");
|
||||
assert.ok(Math.abs(layout.actionLeft - layout.paragraphLeft) < 1, "first action icon aligns with reply text");
|
||||
assert.notEqual(layout.dateColor, layout.paragraphColor, "timestamp uses secondary color");
|
||||
assert.equal(await footer.locator("time").getAttribute("datetime"), date);
|
||||
assert.match(await footer.locator("time").textContent(), /2026/);
|
||||
}
|
||||
try {
|
||||
await page.goto(target.href, { waitUntil: "networkidle" });
|
||||
await selectChat();
|
||||
@@ -173,10 +198,24 @@ try {
|
||||
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");
|
||||
// The footer uses the saved date and exposes Copy/Fork without a custom menu.
|
||||
await checkFooterAlignment();
|
||||
const copy = page.getByRole("button", { name: "Copy message", exact: true });
|
||||
await copy.click();
|
||||
await page.getByText("Message copied.", { exact: true }).waitFor({ state: "attached" });
|
||||
assert.equal(await page.evaluate(() => navigator.clipboard.readText()), markdown);
|
||||
await page.evaluate(() => Object.defineProperty(navigator.clipboard, "writeText", { configurable: true, value: async () => { throw new DOMException("Denied", "NotAllowedError"); } }));
|
||||
await copy.click();
|
||||
await page.getByText(/Could not copy\. Select the message text/).waitFor();
|
||||
await page.evaluate(() => delete navigator.clipboard.writeText);
|
||||
await copy.focus(); await page.keyboard.press("Enter");
|
||||
await page.getByText("Message copied.", { exact: true }).waitFor({ state: "attached" });
|
||||
const fork = page.getByRole("button", { name: "Fork chat from this message", exact: true });
|
||||
await fork.click();
|
||||
assert.equal(await fork.isDisabled(), true);
|
||||
await page.getByRole("heading", { name: "Forked conversation", exact: true }).waitFor();
|
||||
assert.deepEqual(forkRequests, [{ messageId: "mobile-assistant" }]);
|
||||
await selectChat();
|
||||
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");
|
||||
@@ -188,6 +227,7 @@ try {
|
||||
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());
|
||||
await checkFooterAlignment();
|
||||
if (artifactDir) await page.screenshot({ path: `${artifactDir}/regular-${width}.png` });
|
||||
}
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
@@ -212,15 +252,20 @@ try {
|
||||
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);
|
||||
await checkFooterAlignment();
|
||||
await copy.click();
|
||||
assert.equal(await page.evaluate(() => navigator.clipboard.readText()), markdown);
|
||||
if (artifactDir) await page.screenshot({ path: `${artifactDir}/regular-1280.png` });
|
||||
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 page.waitForFunction(() => document.querySelector(".workspace-sidebar").getBoundingClientRect().right <= 1);
|
||||
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");
|
||||
console.log("PASS mobile horizontal scrolling/edges, native menu/copy/paste, selection/multitouch/cancellation, compact widths/models, aligned timestamp and Copy/Fork footer, Agent layout, shorter viewport, desktop and PWA settings");
|
||||
}
|
||||
} finally { await context.close(); await browser.close(); }
|
||||
|
||||
Reference in New Issue
Block a user