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
+2 -1
View File
@@ -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
View File
@@ -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>
+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(); }