Add aligned assistant timestamps and Copy/Fork actions

This commit is contained in:
2026-11-02 20:52:38 -08:00
parent 659e51012b
commit 4b1b606563
6 changed files with 155 additions and 94 deletions
+53 -8
View File
@@ -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(); }