Add aligned assistant timestamps and Copy/Fork actions
This commit is contained in:
@@ -13,12 +13,14 @@ The production bundle was exercised with local API/socket fixtures in touch-enab
|
|||||||
| Regular assistant width | 304.30px of a 358px transcript at 390px viewport | 358px; desktop width rule retained |
|
| Regular assistant width | 304.30px of a 358px transcript at 390px viewport | 358px; desktop width rule retained |
|
||||||
| Agent message width | 287.52px | 287.52px; bubble/avatar styling retained |
|
| Agent message width | 287.52px | 287.52px; bubble/avatar styling retained |
|
||||||
| Compact model | Hidden | Provider and active model beneath title; updates after changing model in settings |
|
| Compact model | Hidden | Provider and active model beneath title; updates after changing model in settings |
|
||||||
| Context menu | Assistant context-menu event canceled for a Fork-only menu | Native event allowed for text, links and composer; explicit message-actions button opens Fork |
|
| Context menu | Assistant context-menu event canceled for a Fork-only menu | Native event allowed for text, links and composer; Copy and Fork are available in the reply footer |
|
||||||
| Zoom | Viewport disabled user scaling | Fixed-scale viewport retained (`maximum-scale=1.0`, `user-scalable=no`) |
|
| Zoom | Viewport disabled user scaling | Fixed-scale viewport retained (`maximum-scale=1.0`, `user-scalable=no`) |
|
||||||
| Notification failures | Browser exception names and server statuses replaced by a generic error; background refresh erased errors | Stage-specific, redacted recovery guidance, retained until explicit retry/refresh |
|
| Notification failures | Browser exception names and server statuses replaced by a generic error; background refresh erased errors | Stage-specific, redacted recovery guidance, retained until explicit retry/refresh |
|
||||||
|
|
||||||
Text owns gestures across regular chat, search markdown and Agent. Dynamic horizontal scrollers and code/table/quote elements are protected even at both ends. Selections, multi-touch, long-press starts and touch cancellation abandon navigation tracking. Blank workspace chrome still supports sidebar/new-chat swipes. Notification help is bounded and scrollable; sidebar content remains reachable on shorter screens.
|
Text owns gestures across regular chat, search markdown and Agent. Dynamic horizontal scrollers and code/table/quote elements are protected even at both ends. Selections, multi-touch, long-press starts and touch cancellation abandon navigation tracking. Blank workspace chrome still supports sidebar/new-chat swipes. Notification help is bounded and scrollable; sidebar content remains reachable on shorter screens.
|
||||||
|
|
||||||
|
The assistant-footer follow-up replaces the standalone ellipsis with a subtle local date/time from the saved message's `createdAt`, followed by Copy and Fork. The timestamp and first action icon align with the paragraph on mobile and desktop. Copy preserves Markdown and reports clipboard failures without interfering with native selection. Fork uses the existing message-specific endpoint and is disabled while the request is pending. Agent bubbles and streaming placeholders retain their existing presentation; API contracts are unchanged.
|
||||||
|
|
||||||
## Notification diagnosis and official sources
|
## Notification diagnosis and official sources
|
||||||
|
|
||||||
A read-only authenticated request to the running server's `GET /v1/agent/push/config` returned HTTP 200 with `enabled=true`, `webPushAvailable=true`, a known source, `quarantined=false`, and a public key present. Credentials/key values were not printed. This rules out disabled transport configuration at the time of inspection; it does not prove delivery or identify the actual phone's failed step.
|
A read-only authenticated request to the running server's `GET /v1/agent/push/config` returned HTTP 200 with `enabled=true`, `webPushAvailable=true`, a known source, `quarantined=false`, and a public key present. Credentials/key values were not printed. This rules out disabled transport configuration at the time of inspection; it does not prove delivery or identify the actual phone's failed step.
|
||||||
@@ -34,7 +36,7 @@ Permission and subscription are separate: [`PushManager.subscribe()`](https://de
|
|||||||
## Automated verification
|
## Automated verification
|
||||||
|
|
||||||
- `npm run build` and `npm test` in `web`.
|
- `npm run build` and `npm test` in `web`.
|
||||||
- `mobile-workspace.browser.mjs`: trusted CDP touch pans on tables, nested quotation code and code blocks; both scroll edges; selection/clipboard copy and text paste; uncanceled context-menu events; explicit Fork menu; synthetic multi-touch/long-press/cancel scenarios; both navigation directions; model change; widths 320, 390, 430 and 767; 1280px desktop check; 450px-height composer checks; unchanged Agent width; manifest and zoom settings. Screenshots were inspected.
|
- `mobile-workspace.browser.mjs`: trusted CDP touch pans on tables, nested quotation code and code blocks; both scroll edges; selection/clipboard copy and text paste; uncanceled context-menu events; aligned saved timestamp, Copy success/failure/retry and direct Fork with the correct message ID; synthetic multi-touch/long-press/cancel scenarios; both navigation directions; model change; widths 320, 390, 430 and 767; 1280px desktop check; 450px-height composer checks; unchanged Agent width; manifest and zoom settings. Screenshots were inspected.
|
||||||
- `agent-push-errors.browser.mjs`: mobile permission dismissal/denial, worker failure, `AbortError`, `NotAllowedError`, `NotSupportedError`, `InvalidStateError`, subscription timeout, HTTP 400/401/403/409/429/503, rollback, preserved error after background refresh, successful retry, missing APIs/insecure context, iOS Home Screen guidance, and reachable notification controls at 320×568 and 390×450. API/push-provider outcomes are mocked; worker registration is real except the deliberate failure case.
|
- `agent-push-errors.browser.mjs`: mobile permission dismissal/denial, worker failure, `AbortError`, `NotAllowedError`, `NotSupportedError`, `InvalidStateError`, subscription timeout, HTTP 400/401/403/409/429/503, rollback, preserved error after background refresh, successful retry, missing APIs/insecure context, iOS Home Screen guidance, and reachable notification controls at 320×568 and 390×450. API/push-provider outcomes are mocked; worker registration is real except the deliberate failure case.
|
||||||
- Existing `agent-push.browser.mjs`: real worker activation/notification display/IndexedDB and read-close, explicit consent, source changes, multiple sessions, credential rotation, revocation and offline logout with mocked push provider/backend.
|
- Existing `agent-push.browser.mjs`: real worker activation/notification display/IndexedDB and read-close, explicit consent, source changes, multiple sessions, credential rotation, revocation and offline logout with mocked push provider/backend.
|
||||||
- Existing `pwa.browser.mjs`: production service-worker activation/reload and authentication flows.
|
- Existing `pwa.browser.mjs`: production service-worker activation/reload and authentication flows.
|
||||||
|
|||||||
+2
-1
@@ -43,6 +43,7 @@ Default dev URL: `http://localhost:5173`
|
|||||||
prefixes, so `plan` also finds `planning`.
|
prefixes, so `plan` also finds `planning`.
|
||||||
- Right panel:
|
- Right panel:
|
||||||
- Chat mode: transcript + provider/model controls.
|
- 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.
|
- Search mode: top AI answer block + Google-style Exa results view.
|
||||||
- Composer adapts to the active item:
|
- Composer adapts to the active item:
|
||||||
- Chat sends `POST /v1/chat-completions/stream` (SSE).
|
- 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.
|
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.
|
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;
|
item: SidebarSelection;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
} | {
|
|
||||||
kind: "message";
|
|
||||||
chatId: string;
|
|
||||||
messageId: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
};
|
};
|
||||||
type RenameChatDialogState = {
|
type RenameChatDialogState = {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
@@ -2199,24 +2193,6 @@ export default function App() {
|
|||||||
setContextMenu({ kind: "sidebar", item, x: Math.max(padding, x), y: Math.max(padding, y) });
|
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) => {
|
const handleRenameChatSubmit = async (event?: Event) => {
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
if (!renameChatDialog || isRenamingChat) return;
|
if (!renameChatDialog || isRenamingChat) return;
|
||||||
@@ -3819,7 +3795,8 @@ export default function App() {
|
|||||||
messages={displayMessages}
|
messages={displayMessages}
|
||||||
isLoading={isLoadingSelection}
|
isLoading={isLoadingSelection}
|
||||||
isSending={isSendingActiveChat}
|
isSending={isSendingActiveChat}
|
||||||
onMessageActions={openMessageContextMenu}
|
onForkMessage={selectedItem?.kind === "chat" ? messageId => void handleForkChat(selectedItem.id, messageId) : undefined}
|
||||||
|
isForking={isForkingChat}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<SearchResultsPanel
|
<SearchResultsPanel
|
||||||
@@ -3906,65 +3883,51 @@ export default function App() {
|
|||||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
{contextMenu.kind === "message" ? (
|
<button
|
||||||
<button
|
type="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"
|
||||||
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 handleToggleStar(contextMenu.item)}
|
||||||
onClick={() => void handleForkChat(contextMenu.chatId, contextMenu.messageId)}
|
>
|
||||||
disabled={isForkingChat}
|
<Star
|
||||||
>
|
className={cn(
|
||||||
<GitFork className="h-3.5 w-3.5" />
|
"h-3.5 w-3.5",
|
||||||
Fork
|
isSidebarTargetStarred(contextMenu.item)
|
||||||
</button>
|
? "fill-amber-300 text-amber-300"
|
||||||
) : (
|
: ""
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{isSidebarTargetStarred(contextMenu.item) ? "Unstar" : "Star"}
|
||||||
|
</button>
|
||||||
|
{contextMenu.item.kind === "chat" ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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 handleToggleStar(contextMenu.item)}
|
onClick={() => void handleForkChat(contextMenu.item.id)}
|
||||||
|
disabled={isForkingChat || isItemRunning(contextMenu.item)}
|
||||||
>
|
>
|
||||||
<Star
|
<GitFork className="h-3.5 w-3.5" />
|
||||||
className={cn(
|
Fork
|
||||||
"h-3.5 w-3.5",
|
|
||||||
isSidebarTargetStarred(contextMenu.item)
|
|
||||||
? "fill-amber-300 text-amber-300"
|
|
||||||
: ""
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{isSidebarTargetStarred(contextMenu.item) ? "Unstar" : "Star"}
|
|
||||||
</button>
|
</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
|
<button
|
||||||
type="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"
|
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 handleDeleteFromContextMenu()}
|
onClick={() => openRenameChatDialog(contextMenu.item.id)}
|
||||||
disabled={isDeleteBlocked(contextMenu.item)}
|
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
{isRootChat(contextMenu.item) ? "Delete group" : "Delete"}
|
Rename
|
||||||
</button>
|
</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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{isChatSettingsOpen ? (
|
{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 type { ComponentChildren, JSX } from "preact";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
||||||
|
import { AssistantMessageFooter } from "@/components/chat/assistant-message-footer";
|
||||||
import { getMessageAttachments, type Message } from "@/lib/api";
|
import { getMessageAttachments, type Message } from "@/lib/api";
|
||||||
import { asStreamingAssistantMetadata, isInlineAgentReplyPending } from "@/lib/chat-stream-presentation";
|
import { asStreamingAssistantMetadata, isInlineAgentReplyPending } from "@/lib/chat-stream-presentation";
|
||||||
import { AgentBubble } from "@/components/agent/agent-conversation";
|
import { AgentBubble } from "@/components/agent/agent-conversation";
|
||||||
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
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 = {
|
type Props = {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isSending: boolean;
|
isSending: boolean;
|
||||||
onMessageActions?: (event: MouseEvent, messageId: string) => void;
|
onForkMessage?: (messageId: string) => void;
|
||||||
|
isForking?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ToolLogMetadata = {
|
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 hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-"));
|
||||||
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
|
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
|
||||||
const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(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")}
|
className={cn("[&_a]:text-inherit [&_a]:underline", !isUser && "leading-[1.82] text-violet-50")}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{message.role === "assistant" && !message.id.startsWith("temp-") && onMessageActions ? (
|
{message.role === "assistant" && !message.id.startsWith("temp-") ? (
|
||||||
<button type="button" aria-label="Message actions" title="Message actions"
|
<AssistantMessageFooter message={message} onFork={onForkMessage} isForking={isForking} />
|
||||||
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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</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: [],
|
lastUsedProvider: "openai", lastUsedModel: "mobile-model-one", additionalSystemPrompt: null, enabledTools: [],
|
||||||
messages: [{ id: "mobile-assistant", role: "assistant", content: markdown, createdAt: date, name: null, metadata: null }] };
|
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 idle = { state: "idle", isThinking: false, queued: 0, backgroundWorkers: 0, tools: [], queuedMessages: [] };
|
||||||
const errors = [], calls = [];
|
const errors = [], calls = [], forkRequests = [];
|
||||||
|
let forkedChat;
|
||||||
let readCursor = "0";
|
let readCursor = "0";
|
||||||
await context.addInitScript(() => localStorage.setItem("sybil_admin_token", "mobile-fixture-token"));
|
await context.addInitScript(() => localStorage.setItem("sybil_admin_token", "mobile-fixture-token"));
|
||||||
await context.route("**/*", async route => {
|
await context.route("**/*", async route => {
|
||||||
@@ -39,14 +40,20 @@ await context.route("**/*", async route => {
|
|||||||
calls.push({ path, method });
|
calls.push({ path, method });
|
||||||
let value;
|
let value;
|
||||||
if (path.endsWith("/auth/session")) value = { mode: "token", authenticated: true };
|
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("/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("/chat-tools") || path.endsWith("/agent/tools")) value = { tools: [] };
|
||||||
else if (path.endsWith("/active-runs")) value = { chats: [], searches: [] };
|
else if (path.endsWith("/active-runs")) value = { chats: [], searches: [] };
|
||||||
else if (path.endsWith("/chats/mobile-chat")) {
|
else if (path.endsWith("/chats/mobile-chat")) {
|
||||||
if (method === "PATCH") Object.assign(chat, route.request().postDataJSON());
|
if (method === "PATCH") Object.assign(chat, route.request().postDataJSON());
|
||||||
value = { chat };
|
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("/agent")) value = { id: "1", title: "Sybil Agent", activity: idle, protocol: "pi-room.v1", websocket: "/v1/agent/ws" };
|
||||||
else if (path.endsWith("/read-state")) {
|
else if (path.endsWith("/read-state")) {
|
||||||
if (method === "PUT") readCursor = route.request().postDataJSON().cursor;
|
if (method === "PUT") readCursor = route.request().postDataJSON().cursor;
|
||||||
@@ -95,6 +102,24 @@ async function scrollHorizontally(locator) {
|
|||||||
async function contextMenuCanceled(locator) {
|
async function contextMenuCanceled(locator) {
|
||||||
return locator.evaluate(el => !el.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 100, clientY: 200 })));
|
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 {
|
try {
|
||||||
await page.goto(target.href, { waitUntil: "networkidle" });
|
await page.goto(target.href, { waitUntil: "networkidle" });
|
||||||
await selectChat();
|
await selectChat();
|
||||||
@@ -173,10 +198,24 @@ try {
|
|||||||
await composer.focus(); await page.keyboard.press("Control+v");
|
await composer.focus(); await page.keyboard.press("Control+v");
|
||||||
assert.equal(await composer.inputValue(), "native pasted draft");
|
assert.equal(await composer.inputValue(), "native pasted draft");
|
||||||
assert.equal(calls.filter(call => call.method === "POST").length, 0);
|
assert.equal(calls.filter(call => call.method === "POST").length, 0);
|
||||||
// Explicit message actions preserve Fork without replacing the native menu.
|
// The footer uses the saved date and exposes Copy/Fork without a custom menu.
|
||||||
await page.getByRole("button", { name: "Message actions", exact: true }).click();
|
await checkFooterAlignment();
|
||||||
await page.getByRole("button", { name: "Fork", exact: true }).waitFor();
|
const copy = page.getByRole("button", { name: "Copy message", exact: true });
|
||||||
await page.keyboard.press("Escape");
|
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("button", { name: "Open chat settings" }).click();
|
||||||
await page.getByRole("dialog").waitFor();
|
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 }).fill("mobile-model-two");
|
||||||
@@ -188,6 +227,7 @@ try {
|
|||||||
await page.setViewportSize({ width, height: 844 });
|
await page.setViewportSize({ width, height: 844 });
|
||||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false, `${width}px page overflow`);
|
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false, `${width}px page overflow`);
|
||||||
assert.ok(await page.locator("main header p").isVisible());
|
assert.ok(await page.locator("main header p").isVisible());
|
||||||
|
await checkFooterAlignment();
|
||||||
if (artifactDir) await page.screenshot({ path: `${artifactDir}/regular-${width}.png` });
|
if (artifactDir) await page.screenshot({ path: `${artifactDir}/regular-${width}.png` });
|
||||||
}
|
}
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
@@ -212,15 +252,20 @@ try {
|
|||||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
|
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.ok(await page.locator("main header button").filter({ hasText: "mobile-model-one" }).isVisible());
|
||||||
assert.equal(await contextMenuCanceled(paragraph), false);
|
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());
|
const manifest = await page.evaluate(async () => (await fetch("/manifest.webmanifest")).json());
|
||||||
assert.equal(manifest.display, "standalone"); assert.ok(!manifest.display_override?.includes("fullscreen"));
|
assert.equal(manifest.display, "standalone"); assert.ok(!manifest.display_override?.includes("fullscreen"));
|
||||||
const viewport = await page.locator('meta[name="viewport"]').getAttribute("content");
|
const viewport = await page.locator('meta[name="viewport"]').getAttribute("content");
|
||||||
assert.match(viewport, /user-scalable=no/);
|
assert.match(viewport, /user-scalable=no/);
|
||||||
assert.match(viewport, /maximum-scale=1\.0(?:,|$)/);
|
assert.match(viewport, /maximum-scale=1\.0(?:,|$)/);
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.waitForFunction(() => document.querySelector(".workspace-sidebar").getBoundingClientRect().right <= 1);
|
||||||
await drag(300, 8, -160);
|
await drag(300, 8, -160);
|
||||||
await page.getByRole("heading", { name: "New chat", exact: true }).waitFor();
|
await page.getByRole("heading", { name: "New chat", exact: true }).waitFor();
|
||||||
assert.deepEqual(errors, []);
|
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(); }
|
} finally { await context.close(); await browser.close(); }
|
||||||
|
|||||||
Reference in New Issue
Block a user