mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
refactor(ui): simplify sidebar hover stability (#128973)
* refactor(ui): simplify sidebar hover lifecycle * refactor(ui): reduce sidebar hover machinery * fix(ui): preserve sidebar row lifecycle invariants * fix(ui): refresh adopted row marquee badges * fix(ui): preserve collapsed project sections * fix(ui): isolate catalog collapse identities * fix(ui): remeasure resized session marquees * fix(ui): migrate legacy catalog collapse groups * fix(ui): keep trailing glyphs inside sidebar endcap
This commit is contained in:
@@ -3980,7 +3980,7 @@ ui/src/components/app-sidebar-session-catalog-render.ts 4
|
||||
ui/src/components/app-sidebar-session-list-render.ts 2
|
||||
ui/src/components/app-sidebar-session-menu-renderers.ts 4
|
||||
ui/src/components/app-sidebar-session-narration.ts 5
|
||||
ui/src/components/app-sidebar-session-row-render.ts 4
|
||||
ui/src/components/app-sidebar-session-row-render.ts 2
|
||||
ui/src/components/app-sidebar-session-section-header.ts 6
|
||||
ui/src/components/app-sidebar.ts 2
|
||||
ui/src/components/app-topbar.ts 1
|
||||
|
||||
@@ -32,27 +32,4 @@ describe("SidebarCatalogMenuController", () => {
|
||||
|
||||
expect(order).toEqual(["dismiss", "open"]);
|
||||
});
|
||||
|
||||
it("does not schedule trigger retargeting while the menu is closed", () => {
|
||||
const controller = new SidebarCatalogMenuController({
|
||||
beforeOpen: vi.fn(),
|
||||
requestUpdate: vi.fn(),
|
||||
terminalAvailable: () => true,
|
||||
navigate: vi.fn(),
|
||||
});
|
||||
const trigger = document.createElement("button");
|
||||
document.body.append(trigger);
|
||||
const queueMicrotaskSpy = vi.spyOn(globalThis, "queueMicrotask");
|
||||
|
||||
try {
|
||||
controller.retargetTrigger(
|
||||
{ catalogId: "codex", hostId: "gateway:local", threadId: "thread-1" },
|
||||
trigger,
|
||||
);
|
||||
expect(queueMicrotaskSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
queueMicrotaskSpy.mockRestore();
|
||||
trigger.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,14 +63,12 @@ export class SidebarCatalogMenuController {
|
||||
if (!(element instanceof HTMLElement) || !this.isOpenFor(key)) {
|
||||
return;
|
||||
}
|
||||
// A catalog refresh can replace the owning row while popup focus is elsewhere.
|
||||
// Retarget only after the old trigger disconnects so dismissal has a live focus anchor.
|
||||
// Catalog adoption replaces the trigger while popup focus is elsewhere.
|
||||
queueMicrotask(() => {
|
||||
if (!element.isConnected || this.trigger?.isConnected || !this.isOpenFor(key)) {
|
||||
return;
|
||||
if (element.isConnected && !this.trigger?.isConnected && this.isOpenFor(key)) {
|
||||
this.trigger = element;
|
||||
this.hooks.requestUpdate();
|
||||
}
|
||||
this.trigger = element;
|
||||
this.hooks.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,51 @@ type SessionCatalogGroupsParams = {
|
||||
isMenuOpen: (key: CatalogSessionKey) => boolean;
|
||||
};
|
||||
|
||||
const CATALOG_CONTROL_SELECTORS = [
|
||||
".sidebar-recent-session__link",
|
||||
"[data-child-session-toggle]",
|
||||
"[data-sidebar-session-pin]",
|
||||
"[data-catalog-session-menu], [data-session-menu]",
|
||||
] as const;
|
||||
|
||||
function catalogRowRef(
|
||||
identityKey: string,
|
||||
sessionKey: string,
|
||||
catalogKey: CatalogSessionKey,
|
||||
menuOpen: boolean,
|
||||
params: SessionCatalogGroupsParams,
|
||||
): ((element: Element | undefined) => void) | undefined {
|
||||
const active = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const activeRow = active?.closest<HTMLElement>("[data-session-key]");
|
||||
const selector = CATALOG_CONTROL_SELECTORS.find((candidate) => active?.matches(candidate));
|
||||
const restoreFocus =
|
||||
selector !== undefined &&
|
||||
(activeRow?.dataset.catalogSessionKey === identityKey ||
|
||||
activeRow?.dataset.sessionKey === identityKey ||
|
||||
activeRow?.dataset.sessionKey === sessionKey);
|
||||
if (!menuOpen && !restoreFocus) {
|
||||
return undefined;
|
||||
}
|
||||
return (element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
if (menuOpen) {
|
||||
params.onCatalogMenuTriggerRendered(
|
||||
catalogKey,
|
||||
element.querySelector(CATALOG_CONTROL_SELECTORS[3]) ?? undefined,
|
||||
);
|
||||
}
|
||||
if (restoreFocus) {
|
||||
queueMicrotask(() => {
|
||||
if (element.isConnected && document.activeElement === document.body) {
|
||||
element.querySelector<HTMLElement>(selector)?.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function renderSessionRunSpinner(showTitle = true) {
|
||||
return html`<span
|
||||
class="session-run-spinner"
|
||||
@@ -306,47 +351,6 @@ export function renderSessionCatalogGroups(params: SessionCatalogGroupsParams) {
|
||||
|
||||
export type SessionCatalogGroupsRenderer = typeof renderSessionCatalogGroups;
|
||||
|
||||
function catalogSessionIdentityKey(
|
||||
catalog: SessionCatalog,
|
||||
host: SessionCatalogHost,
|
||||
session: SessionCatalogSession,
|
||||
): string {
|
||||
return buildCatalogSessionKey({
|
||||
catalogId: catalog.id,
|
||||
hostId: host.hostId,
|
||||
threadId: session.threadId,
|
||||
});
|
||||
}
|
||||
|
||||
function renderCatalogSessionRows(
|
||||
catalog: SessionCatalog,
|
||||
host: SessionCatalogHost,
|
||||
sessions: readonly SessionCatalogSession[],
|
||||
liveRowsByKey: ReadonlyMap<string, GatewaySessionRow>,
|
||||
params: SessionCatalogGroupsParams,
|
||||
projectChild = false,
|
||||
) {
|
||||
return repeat(
|
||||
sessions,
|
||||
(session) => catalogSessionIdentityKey(catalog, host, session),
|
||||
(session) =>
|
||||
renderCatalogSessionRow(catalog, host, session, liveRowsByKey, params, projectChild),
|
||||
);
|
||||
}
|
||||
|
||||
function restoreCatalogControlFocus(element: Element | undefined): void {
|
||||
if (!(element instanceof HTMLAnchorElement || element instanceof HTMLButtonElement)) {
|
||||
return;
|
||||
}
|
||||
// Reordering moves the keyed row through a disconnected state. Restore only
|
||||
// the focus that movement dropped; never override a newer user focus choice.
|
||||
queueMicrotask(() => {
|
||||
if (element.isConnected && document.activeElement === document.body) {
|
||||
element.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderCatalogHostGroup(
|
||||
catalog: SessionCatalog,
|
||||
host: SessionCatalogHost,
|
||||
@@ -362,6 +366,18 @@ function renderCatalogHostGroup(
|
||||
: params.projectGrouping === "person"
|
||||
? groupCatalogSessionsByPerson(host.sessions)
|
||||
: null;
|
||||
const renderRows = (sessions: readonly SessionCatalogSession[], projectChild = false) =>
|
||||
repeat(
|
||||
sessions,
|
||||
(session) =>
|
||||
buildCatalogSessionKey({
|
||||
catalogId: catalog.id,
|
||||
hostId: host.hostId,
|
||||
threadId: session.threadId,
|
||||
}),
|
||||
(session) =>
|
||||
renderCatalogSessionRow(catalog, host, session, liveRowsByKey, params, projectChild),
|
||||
);
|
||||
// Gateway errors stay on the catalog header; node headings remain so remote rows keep their owner.
|
||||
const showHostHeading = host.kind !== "gateway";
|
||||
return html`
|
||||
@@ -391,8 +407,16 @@ function renderCatalogHostGroup(
|
||||
projectGroups.groups,
|
||||
(group) => group.key,
|
||||
(group) => {
|
||||
const sectionId = `catalog-project:${catalog.id}:${host.hostId}:${group.key}`;
|
||||
const collapsed = params.collapsedSections.has(sectionId);
|
||||
const sectionId = `catalog-${group.kind}:${catalog.id}:${host.hostId}:${group.key}`;
|
||||
const legacySectionId = group.legacySectionKey
|
||||
? `catalog-project:${catalog.id}:${host.hostId}:${group.legacySectionKey}`
|
||||
: null;
|
||||
const collapsedSectionId = params.collapsedSections.has(sectionId)
|
||||
? sectionId
|
||||
: legacySectionId && params.collapsedSections.has(legacySectionId)
|
||||
? legacySectionId
|
||||
: null;
|
||||
const collapsed = collapsedSectionId !== null;
|
||||
return html`
|
||||
<div class="sidebar-session-catalog-project" role="listitem">
|
||||
<button
|
||||
@@ -401,7 +425,7 @@ function renderCatalogHostGroup(
|
||||
data-session-catalog-project=${group.key}
|
||||
aria-expanded=${String(!collapsed)}
|
||||
title=${group.title}
|
||||
@click=${() => params.onToggleSection(sectionId)}
|
||||
@click=${() => params.onToggleSection(collapsedSectionId ?? sectionId)}
|
||||
>
|
||||
<span class="sidebar-session-catalog-project__icon" aria-hidden="true"
|
||||
>${collapsed ? icons.chevronRight : icons.chevronDown}</span
|
||||
@@ -418,27 +442,14 @@ function renderCatalogHostGroup(
|
||||
role="list"
|
||||
aria-label=${`${host.label}: ${group.label}`}
|
||||
>
|
||||
${renderCatalogSessionRows(
|
||||
catalog,
|
||||
host,
|
||||
group.sessions,
|
||||
liveRowsByKey,
|
||||
params,
|
||||
true,
|
||||
)}
|
||||
${renderRows(group.sessions, true)}
|
||||
</div>`}
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
)}
|
||||
${renderCatalogSessionRows(
|
||||
catalog,
|
||||
host,
|
||||
projectGroups.ungrouped,
|
||||
liveRowsByKey,
|
||||
params,
|
||||
)}`
|
||||
: renderCatalogSessionRows(catalog, host, host.sessions, liveRowsByKey, params)}
|
||||
${renderRows(projectGroups.ungrouped)}`
|
||||
: renderRows(host.sessions)}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
@@ -460,47 +471,22 @@ function renderCatalogSessionRow(
|
||||
hostId: host.hostId,
|
||||
threadId: session.threadId,
|
||||
} satisfies CatalogSessionKey;
|
||||
const catalogMenuOpen = params.isMenuOpen(catalogKey);
|
||||
const catalogMenuTriggerRef = catalogMenuOpen
|
||||
? (element: Element | undefined) => params.onCatalogMenuTriggerRendered(catalogKey, element)
|
||||
: undefined;
|
||||
const identityKey = catalogSessionIdentityKey(catalog, host, session);
|
||||
const identityKey = buildCatalogSessionKey(catalogKey);
|
||||
const key = session.sessionKey ?? identityKey;
|
||||
const focusedControl =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : undefined;
|
||||
const focusedRow = focusedControl?.closest<HTMLElement>("[data-session-key]");
|
||||
const restoreFocusedControl =
|
||||
focusedRow?.dataset.catalogSessionKey === identityKey ||
|
||||
focusedRow?.dataset.sessionKey === identityKey ||
|
||||
focusedRow?.dataset.sessionKey === key;
|
||||
const focusedControlKind = focusedControl?.matches(".sidebar-recent-session__link")
|
||||
? "link"
|
||||
: focusedControl?.matches("[data-child-session-toggle]")
|
||||
? "child-toggle"
|
||||
: focusedControl?.matches("[data-sidebar-session-pin]")
|
||||
? "pin"
|
||||
: focusedControl?.matches("[data-catalog-session-menu], [data-session-menu]")
|
||||
? "menu"
|
||||
: undefined;
|
||||
const focusRef =
|
||||
restoreFocusedControl && focusedControlKind
|
||||
? (element: Element | undefined) => restoreCatalogControlFocus(element)
|
||||
: undefined;
|
||||
const label = session.name || session.threadId;
|
||||
const menuOpen = params.isMenuOpen(catalogKey);
|
||||
const rowRef = catalogRowRef(identityKey, key, catalogKey, menuOpen, params);
|
||||
const adoptedRow = session.sessionKey ? liveRowsByKey.get(session.sessionKey) : undefined;
|
||||
if (adoptedRow) {
|
||||
const label = session.name || session.threadId;
|
||||
return params.renderLiveRow(adoptedRow, {
|
||||
label,
|
||||
catalogIdentityKey: identityKey,
|
||||
marqueeKey: JSON.stringify([label, session.pullRequest]),
|
||||
catalogMenuOpen,
|
||||
...(catalogMenuTriggerRef ? { catalogMenuTriggerRef } : {}),
|
||||
catalogMenuOpen: menuOpen,
|
||||
...(rowRef ? { rowRef } : {}),
|
||||
...(session.pullRequest ? { pullRequest: session.pullRequest } : {}),
|
||||
...(focusRef && focusedControlKind
|
||||
? { focusedControl: focusedControlKind, restoreControlFocus: focusRef }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
const label = session.name || session.threadId;
|
||||
const meta = formatSidebarTimestamp(timestamp);
|
||||
const routeId = "chat";
|
||||
const target = sessionNavigationTarget({
|
||||
@@ -539,7 +525,6 @@ function renderCatalogSessionRow(
|
||||
: null,
|
||||
(trigger, x, y) => openMenu(x, y, trigger ?? undefined),
|
||||
);
|
||||
// Marquee state lives on the label; reset it without replacing focused row controls.
|
||||
const marqueeLabel = keyed(
|
||||
JSON.stringify([label, session.status, session.pullRequest]),
|
||||
html`<span
|
||||
@@ -548,8 +533,9 @@ function renderCatalogSessionRow(
|
||||
>${label}</span
|
||||
>`,
|
||||
);
|
||||
const row = html`
|
||||
return html`
|
||||
<div
|
||||
${rowRef ? ref(rowRef) : nothing}
|
||||
class="sidebar-recent-session session-row-host sidebar-recent-session--single-line ${active
|
||||
? "sidebar-recent-session--active"
|
||||
: ""} ${projectChild ? "sidebar-recent-session--catalog-project-child" : ""} ${running
|
||||
@@ -565,7 +551,6 @@ function renderCatalogSessionRow(
|
||||
@mouseleave=${stopHoverMarqueeFromEvent}
|
||||
>
|
||||
<a
|
||||
${focusedControlKind === "link" && focusRef ? ref(focusRef) : nothing}
|
||||
href=${withSidebarNavCollapseIntent(href)}
|
||||
class="sidebar-recent-session__link"
|
||||
aria-current=${active ? "page" : nothing}
|
||||
@@ -609,15 +594,13 @@ function renderCatalogSessionRow(
|
||||
<span class="sidebar-recent-session__aside session-row-aside">
|
||||
<span class="session-row-actions">
|
||||
<button
|
||||
${catalogMenuTriggerRef ? ref(catalogMenuTriggerRef) : nothing}
|
||||
${focusedControlKind === "menu" && focusRef ? ref(focusRef) : nothing}
|
||||
class="session-action"
|
||||
data-catalog-session-menu="true"
|
||||
type="button"
|
||||
title=${t("chat.sidebar.openSessionMenu")}
|
||||
aria-label=${t("chat.sidebar.openSessionMenu")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded=${String(catalogMenuOpen)}
|
||||
aria-expanded=${String(menuOpen)}
|
||||
@click=${(event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
const trigger = event.currentTarget as HTMLElement;
|
||||
@@ -631,5 +614,4 @@ function renderCatalogSessionRow(
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -135,13 +135,10 @@ export function visibleCatalogHosts(
|
||||
export type CatalogBackingSessionDisplay = {
|
||||
label: string;
|
||||
catalogIdentityKey: string;
|
||||
marqueeKey?: string;
|
||||
catalogMenuOpen?: boolean;
|
||||
catalogMenuTriggerRef?: (element: Element | undefined) => void;
|
||||
catalogMenuOpen: boolean;
|
||||
rowRef?: (element: Element | undefined) => void;
|
||||
subtitle?: string;
|
||||
pullRequest?: SessionCatalogSession["pullRequest"];
|
||||
focusedControl?: "link" | "child-toggle" | "pin" | "menu";
|
||||
restoreControlFocus?: (element: Element | undefined) => void;
|
||||
};
|
||||
|
||||
export type CatalogSessionMenuRequest = {
|
||||
|
||||
@@ -14,8 +14,8 @@ import { sessionHasBoard } from "../lib/board/provider.ts";
|
||||
import { formatDurationCompact } from "../lib/format.ts";
|
||||
import {
|
||||
restartHoverMarqueeIfHovered,
|
||||
startHoverMarquee,
|
||||
stopHoverMarquee,
|
||||
startHoverMarqueeFromEvent,
|
||||
stopHoverMarqueeFromEvent,
|
||||
} from "../lib/hover-marquee.ts";
|
||||
import { handleContextMenuEvent } from "../lib/keyboard-shortcuts.ts";
|
||||
import { projectPresencePayload } from "../lib/presence-users.ts";
|
||||
@@ -196,18 +196,12 @@ export function renderRecentSession(params: {
|
||||
: session.owner?.actor
|
||||
: undefined;
|
||||
const ownerId = ownerActor?.id?.trim();
|
||||
const presenceProjection =
|
||||
ownerId || display?.marqueeKey
|
||||
? projectPresencePayload(
|
||||
host.sessionData.presencePayload,
|
||||
host.sessionDataContext?.gateway.snapshot.selfUser?.id,
|
||||
host.sessionData.presenceInstanceId,
|
||||
)
|
||||
: undefined;
|
||||
const ownerViewing = ownerId
|
||||
? presenceProjection?.users.some(
|
||||
(user) => user.id === ownerId && user.watchedSessions.includes(session.key),
|
||||
)
|
||||
? projectPresencePayload(
|
||||
host.sessionData.presencePayload,
|
||||
host.sessionDataContext?.gateway.snapshot.selfUser?.id,
|
||||
host.sessionData.presenceInstanceId,
|
||||
).users.some((user) => user.id === ownerId && user.watchedSessions.includes(session.key))
|
||||
: undefined;
|
||||
const gateway = host.sessionDataContext?.gateway;
|
||||
const channelAvatarAuth = {
|
||||
@@ -244,20 +238,6 @@ export function renderRecentSession(params: {
|
||||
const hasTrail = session.isChild && (session.runtimeMs != null || session.startedAt != null);
|
||||
const metaId = hasTrail ? sidebarSessionMetaId(session.key) : undefined;
|
||||
const stateId = trailingDescription ? sidebarSessionStateId(session.key) : undefined;
|
||||
const hasBoard = !session.isChild && sessionHasBoard(session.key);
|
||||
const pullRequest = session.pullRequest ?? display?.pullRequest;
|
||||
const hasApproval = sessionHasPendingApproval(
|
||||
host.sessionData.approvalBadgeSnapshot(),
|
||||
session.key,
|
||||
);
|
||||
const visibleViewerCount = display?.marqueeKey
|
||||
? (presenceProjection?.users.filter(
|
||||
(user) =>
|
||||
user.id !== presenceProjection.selfUserId &&
|
||||
user.id !== renderedOwnerId &&
|
||||
user.watchedSessions.includes(session.key),
|
||||
).length ?? 0)
|
||||
: 0;
|
||||
const openMenuFromEvent = (event: MouseEvent | KeyboardEvent) =>
|
||||
handleContextMenuEvent(
|
||||
event,
|
||||
@@ -302,35 +282,8 @@ export function renderRecentSession(params: {
|
||||
requiredScope: "operator.write",
|
||||
});
|
||||
const rowDraggable = !session.isChild && groupWriteAccess.allowed;
|
||||
// Adopted catalog rows keep their live row node, so replace only the label
|
||||
// whenever live title/endcap geometry changes and remeasure under the pointer.
|
||||
const marqueeKey = display?.marqueeKey
|
||||
? JSON.stringify([
|
||||
display.marqueeKey,
|
||||
session.archived === true,
|
||||
session.forkSource !== undefined,
|
||||
subtitle ?? null,
|
||||
session.childSessionKeys.length,
|
||||
hasBoard,
|
||||
Math.min(visibleViewerCount, 4),
|
||||
session.incognito === true,
|
||||
session.hasAutomation,
|
||||
pullRequest ?? null,
|
||||
hasApproval,
|
||||
session.outboxAttentionCount ?? 0,
|
||||
session.hasComposerDraft === true,
|
||||
session.placementState ?? null,
|
||||
session.diskSpaceStatus ?? null,
|
||||
session.workspaceConflictCount ?? 0,
|
||||
pullRequestState,
|
||||
running,
|
||||
session.status ?? null,
|
||||
session.unread,
|
||||
hasTrail,
|
||||
])
|
||||
: undefined;
|
||||
const marqueeLabelTemplate = html`<span
|
||||
${marqueeKey ? ref(restartHoverMarqueeIfHovered) : nothing}
|
||||
${display ? ref(restartHoverMarqueeIfHovered) : nothing}
|
||||
class="sidebar-recent-session__name hover-marquee"
|
||||
>${session.archived
|
||||
? html`<span
|
||||
@@ -348,10 +301,21 @@ export function renderRecentSession(params: {
|
||||
>`
|
||||
: nothing}${label}</span
|
||||
>`;
|
||||
const marqueeLabel = marqueeKey ? keyed(marqueeKey, marqueeLabelTemplate) : marqueeLabelTemplate;
|
||||
const marqueeLabel = display
|
||||
? keyed(
|
||||
JSON.stringify([
|
||||
label,
|
||||
session.archived === true,
|
||||
session.forkSource !== undefined,
|
||||
session.pullRequest ?? display.pullRequest,
|
||||
]),
|
||||
marqueeLabelTemplate,
|
||||
)
|
||||
: marqueeLabelTemplate;
|
||||
// Always reserve the lead so every title shares the section-label text line.
|
||||
const row = html`
|
||||
<div
|
||||
${display?.rowRef ? ref(display.rowRef) : nothing}
|
||||
class=${rowClass}
|
||||
data-session-key=${session.key}
|
||||
data-catalog-session-key=${display?.catalogIdentityKey ?? nothing}
|
||||
@@ -372,13 +336,10 @@ export function renderRecentSession(params: {
|
||||
}}
|
||||
@contextmenu=${openMenuFromEvent}
|
||||
@keydown=${openMenuFromEvent}
|
||||
@mouseenter=${(event: MouseEvent) => startHoverMarquee(event.currentTarget as HTMLElement)}
|
||||
@mouseleave=${(event: MouseEvent) => stopHoverMarquee(event.currentTarget as HTMLElement)}
|
||||
@mouseenter=${startHoverMarqueeFromEvent}
|
||||
@mouseleave=${stopHoverMarqueeFromEvent}
|
||||
>
|
||||
<a
|
||||
${display?.focusedControl === "link" && display.restoreControlFocus
|
||||
? ref(display.restoreControlFocus)
|
||||
: nothing}
|
||||
href=${withSidebarNavCollapseIntent(session.href)}
|
||||
class="sidebar-recent-session__link"
|
||||
draggable="false"
|
||||
@@ -399,7 +360,7 @@ export function renderRecentSession(params: {
|
||||
<span class="sidebar-recent-session__details">
|
||||
${renderSidebarSessionSubtitle({ subtitle, narration })}
|
||||
<span class="sidebar-recent-session__details-endcap">
|
||||
${hasBoard
|
||||
${!session.isChild && sessionHasBoard(session.key)
|
||||
? html`<span
|
||||
class="sidebar-board-glyph"
|
||||
role="img"
|
||||
@@ -420,8 +381,11 @@ export function renderRecentSession(params: {
|
||||
${renderSessionRowBadges({
|
||||
...session,
|
||||
hasComposerDraft: session.hasComposerDraft === true,
|
||||
pullRequest,
|
||||
hasApproval,
|
||||
pullRequest: session.pullRequest ?? display?.pullRequest,
|
||||
hasApproval: sessionHasPendingApproval(
|
||||
host.sessionData.approvalBadgeSnapshot(),
|
||||
session.key,
|
||||
),
|
||||
})}
|
||||
${trailingIndicator === nothing
|
||||
? trailingDescription
|
||||
@@ -456,9 +420,6 @@ export function renderRecentSession(params: {
|
||||
</a>
|
||||
${session.childSessionKeys.length > 0
|
||||
? html`<button
|
||||
${display?.focusedControl === "child-toggle" && display.restoreControlFocus
|
||||
? ref(display.restoreControlFocus)
|
||||
: nothing}
|
||||
class="sidebar-child-session-toggle ${session.runningChildCount > 0
|
||||
? "sidebar-child-session-toggle--running"
|
||||
: session.failedChildCount > 0
|
||||
@@ -490,9 +451,6 @@ export function renderRecentSession(params: {
|
||||
${session.isChild
|
||||
? nothing
|
||||
: html`<button
|
||||
${display?.focusedControl === "pin" && display.restoreControlFocus
|
||||
? ref(display.restoreControlFocus)
|
||||
: nothing}
|
||||
class="session-action session-action--pin"
|
||||
data-sidebar-session-pin="true"
|
||||
type="button"
|
||||
@@ -505,10 +463,6 @@ export function renderRecentSession(params: {
|
||||
</button>`}
|
||||
<openclaw-tooltip .content=${menuTooltip} .describe=${false} .disabled=${menuOpen}>
|
||||
<button
|
||||
${display?.catalogMenuTriggerRef ? ref(display.catalogMenuTriggerRef) : nothing}
|
||||
${display?.focusedControl === "menu" && display.restoreControlFocus
|
||||
? ref(display.restoreControlFocus)
|
||||
: nothing}
|
||||
class="session-action"
|
||||
data-session-menu="true"
|
||||
type="button"
|
||||
|
||||
@@ -8,8 +8,8 @@ import "../test-helpers/app-sidebar-cases/footer-status.ts";
|
||||
import "../test-helpers/app-sidebar-cases/catalog-compat.ts";
|
||||
import "../test-helpers/app-sidebar-cases/catalog-live-events.ts";
|
||||
import "../test-helpers/app-sidebar-cases/catalog-project-activity.ts";
|
||||
import "../test-helpers/app-sidebar-cases/catalog-row-lifecycle.ts";
|
||||
import "../test-helpers/app-sidebar-cases/catalog-live.ts";
|
||||
import "../test-helpers/app-sidebar-cases/catalog-row-keying.ts";
|
||||
import "../test-helpers/app-sidebar-cases/catalog-reconnect.ts";
|
||||
import "../test-helpers/app-sidebar-cases/catalog-live-errors.ts";
|
||||
import "../test-helpers/app-sidebar-cases/catalog-live-state.ts";
|
||||
|
||||
@@ -487,10 +487,6 @@ describeBrowserLayout("app chrome interaction styles", () => {
|
||||
<span class="sidebar-recent-session__name">Child session</span>
|
||||
<span class="session-row-trail">3m</span>
|
||||
</div>
|
||||
<div class="sidebar-recent-session">
|
||||
<a class="sidebar-recent-session__link">Parent session</a>
|
||||
<button class="sidebar-child-session-toggle" aria-expanded="false">2</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -524,11 +520,6 @@ describeBrowserLayout("app chrome interaction styles", () => {
|
||||
settingsSearch: 16,
|
||||
navItem: 12,
|
||||
});
|
||||
const childToggleSize = await page.$eval(".sidebar-child-session-toggle", (node) => ({
|
||||
height: node.getBoundingClientRect().height,
|
||||
width: node.getBoundingClientRect().width,
|
||||
}));
|
||||
expect(childToggleSize).toEqual({ height: 44, width: 44 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.style.setProperty("--control-ui-text-scale", "1.4");
|
||||
|
||||
@@ -237,7 +237,7 @@ suite.define(() => {
|
||||
const sessions = chatSessionListResponse();
|
||||
const firstSession = expectDefined(sessions.sessions[0], "first chat session fixture");
|
||||
const secondSession = expectDefined(sessions.sessions[1], "second chat session fixture");
|
||||
firstSession.label = "Title fits until actions appear";
|
||||
firstSession.label = "Short";
|
||||
secondSession.label =
|
||||
"Review and repair the intentionally overlong sidebar session title before navigation ".repeat(
|
||||
4,
|
||||
@@ -263,51 +263,10 @@ suite.define(() => {
|
||||
}));
|
||||
expect(layout.scrollWidth, JSON.stringify(layout)).toBeGreaterThan(layout.clientWidth);
|
||||
|
||||
const hoverOnlyRow = page.locator(
|
||||
'.sidebar-recent-session[data-session-key="agent:main:session-a"]',
|
||||
);
|
||||
const hoverOnlyLabel = hoverOnlyRow.locator(".sidebar-recent-session__name");
|
||||
const restingHoverOnlyLayout = await hoverOnlyLabel.evaluate((label) => {
|
||||
const viewport = label.parentElement as HTMLElement;
|
||||
const style = getComputedStyle(viewport);
|
||||
return {
|
||||
scrollWidth: label.scrollWidth,
|
||||
viewportWidth:
|
||||
viewport.clientWidth -
|
||||
(Number.parseFloat(style.paddingLeft) || 0) -
|
||||
(Number.parseFloat(style.paddingRight) || 0),
|
||||
};
|
||||
});
|
||||
expect(
|
||||
restingHoverOnlyLayout.scrollWidth,
|
||||
JSON.stringify(restingHoverOnlyLayout),
|
||||
).toBeLessThanOrEqual(restingHoverOnlyLayout.viewportWidth);
|
||||
await hoverOnlyRow.hover();
|
||||
const hoveredHoverOnlyLayout = await hoverOnlyLabel.evaluate((label) => {
|
||||
const viewport = label.parentElement as HTMLElement;
|
||||
const style = getComputedStyle(viewport);
|
||||
return {
|
||||
scrollWidth: label.scrollWidth,
|
||||
viewportWidth:
|
||||
viewport.clientWidth -
|
||||
(Number.parseFloat(style.paddingLeft) || 0) -
|
||||
(Number.parseFloat(style.paddingRight) || 0),
|
||||
};
|
||||
});
|
||||
expect(
|
||||
hoveredHoverOnlyLayout.scrollWidth,
|
||||
JSON.stringify(hoveredHoverOnlyLayout),
|
||||
).toBeGreaterThan(hoveredHoverOnlyLayout.viewportWidth);
|
||||
await expect
|
||||
.poll(() => hoverOnlyLabel.evaluate((label) => label.classList.value), { timeout: 1_500 })
|
||||
.toContain("hover-marquee--scrolling");
|
||||
await page.mouse.move(0, 0);
|
||||
|
||||
// Freeze the clock so the 500ms hover-intent delay elapses only via
|
||||
// runFor; a ticking clock let slow runners start the marquee before the
|
||||
// "not yet scrolling" asserts below.
|
||||
await pauseVirtualClock(page);
|
||||
|
||||
await recentRow.dispatchEvent("mouseenter");
|
||||
await page.clock.runFor(250);
|
||||
expect(await recentLabel.evaluate((label) => label.classList.value)).not.toContain(
|
||||
@@ -320,7 +279,7 @@ suite.define(() => {
|
||||
"hover-marquee--scrolling",
|
||||
);
|
||||
await recentRow.dispatchEvent("mouseenter");
|
||||
await page.clock.runFor(520);
|
||||
await page.clock.runFor(500);
|
||||
await expect
|
||||
.poll(() => recentLabel.evaluate((label) => label.classList.value), { timeout: 1_500 })
|
||||
.toContain("hover-marquee--scrolling");
|
||||
@@ -424,6 +383,9 @@ suite.define(() => {
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
// Rotation expands the spinner element's square DOMRect even though its
|
||||
// circular ink is unchanged; freeze it while asserting endcap geometry.
|
||||
await page.addStyleTag({ content: ".session-run-spinner { animation: none !important; }" });
|
||||
const busyRow = page.locator(`.sidebar-recent-session[data-session-key="${busyKey}"]`);
|
||||
const plainRow = page.locator(`.sidebar-recent-session[data-session-key="${plainKey}"]`);
|
||||
await busyRow.locator(".session-row-badges").waitFor();
|
||||
@@ -478,12 +440,7 @@ suite.define(() => {
|
||||
// beneath it. Only rows that actually have a subtitle keep the two-line shape.
|
||||
expect(plain.singleLine).toBe(true);
|
||||
expect(plain.height).toBeLessThan(layout.busyHeight);
|
||||
// Badges belong to the second line. Comparing centres keeps this true
|
||||
// whatever size the row's glyphs are; the old top-edge slack was
|
||||
// calibrated to one particular glyph size.
|
||||
expect((layout.badges.top + layout.badges.bottom) / 2).toBeGreaterThan(
|
||||
(layout.name.top + layout.name.bottom) / 2,
|
||||
);
|
||||
expect(layout.badges.top).toBeGreaterThanOrEqual(layout.name.bottom - 1);
|
||||
expect(layout.name.right).toBeGreaterThan(layout.badges.left);
|
||||
expect((layout.badges.top + layout.badges.bottom) / 2).toBeCloseTo(
|
||||
(layout.subtitle.top + layout.subtitle.bottom) / 2,
|
||||
@@ -543,8 +500,6 @@ suite.define(() => {
|
||||
.locator(".sidebar-recent-session__details-endcap")
|
||||
.evaluate((element) => getComputedStyle(element).opacity),
|
||||
)
|
||||
// The actions sit on the title line now, so the second line keeps its
|
||||
// status icons instead of trading them for the buttons on hover.
|
||||
.toBe("1");
|
||||
await expect
|
||||
.poll(() =>
|
||||
@@ -635,404 +590,4 @@ suite.define(() => {
|
||||
await suite.closeBrowserContext(context);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves adopted-row focus across repeated catalog reorders", async () => {
|
||||
const context = await suite.newBrowserContext({});
|
||||
const page = await context.newPage();
|
||||
const firstKey = "agent:main:first-adopted";
|
||||
const secondKey = "agent:main:second-adopted";
|
||||
const catalogResponse = (order: ReadonlyArray<readonly [string, string]>) => ({
|
||||
catalogs: [
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
capabilities: { continueSession: true, archive: true },
|
||||
hosts: [
|
||||
{
|
||||
hostId: "gateway:local",
|
||||
label: "Local Codex",
|
||||
kind: "gateway",
|
||||
connected: true,
|
||||
sessions: order.map(([threadId, sessionKey]) => ({
|
||||
threadId,
|
||||
sessionKey,
|
||||
name: threadId,
|
||||
status: "idle",
|
||||
archived: false,
|
||||
canContinue: true,
|
||||
canArchive: true,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const initialOrder = [
|
||||
["First adopted", firstKey],
|
||||
["Second adopted", secondKey],
|
||||
] as const;
|
||||
const reversedOrder = initialOrder.toReversed();
|
||||
const gateway = await installMockGateway(page, {
|
||||
featureMethods: ["chat.metadata", "chat.startup", "sessions.catalog.list", "sessions.patch"],
|
||||
methodResponses: {
|
||||
"sessions.list": chatSessionListResponse([
|
||||
{
|
||||
key: firstKey,
|
||||
kind: "direct",
|
||||
label: "First adopted",
|
||||
updatedAt: 2,
|
||||
childSessions: ["agent:main:child"],
|
||||
},
|
||||
{ key: secondKey, kind: "direct", label: "Second adopted", updatedAt: 1 },
|
||||
]),
|
||||
"sessions.catalog.list": catalogResponse(initialOrder),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
const catalog = page.locator('[data-session-section="catalog:codex"]');
|
||||
await catalog.waitFor({ state: "visible" });
|
||||
const toggle = catalog.locator(".sidebar-session-group-toggle");
|
||||
if ((await toggle.getAttribute("aria-expanded")) === "false") {
|
||||
await toggle.click();
|
||||
}
|
||||
const rows = catalog.locator(".sidebar-session-catalog-host__sessions > [data-session-key]");
|
||||
for (const [control, selector] of [
|
||||
["link", ".sidebar-recent-session__link"],
|
||||
["child-toggle", "[data-child-session-toggle]"],
|
||||
["pin", "[data-sidebar-session-pin]"],
|
||||
["menu", "[data-session-menu]"],
|
||||
] as const) {
|
||||
const requestCount = (await gateway.getRequests("sessions.catalog.list")).length;
|
||||
await gateway.setMethodResponse("sessions.catalog.list", catalogResponse(initialOrder));
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("sessions.catalog.list")).length)
|
||||
.toBeGreaterThan(requestCount);
|
||||
await expect
|
||||
.poll(() => rows.evaluateAll((elements) => elements.map((row) => row.dataset.sessionKey)))
|
||||
.toEqual(initialOrder.map(([, sessionKey]) => sessionKey));
|
||||
const focusedControl = catalog.locator(`[data-session-key="${firstKey}"] ${selector}`);
|
||||
await focusedControl.focus();
|
||||
await focusedControl.evaluate((element, value) => {
|
||||
element.setAttribute("data-focus-probe", value);
|
||||
}, control);
|
||||
|
||||
for (const order of [reversedOrder, initialOrder, reversedOrder]) {
|
||||
const reorderRequestCount = (await gateway.getRequests("sessions.catalog.list")).length;
|
||||
await gateway.setMethodResponse("sessions.catalog.list", catalogResponse(order));
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("sessions.catalog.list")).length)
|
||||
.toBeGreaterThan(reorderRequestCount);
|
||||
await expect
|
||||
.poll(() =>
|
||||
rows.evaluateAll((elements) => elements.map((row) => row.dataset.sessionKey)),
|
||||
)
|
||||
.toEqual(order.map(([, sessionKey]) => sessionKey));
|
||||
expect(await focusedControl.getAttribute("data-focus-probe")).toBe(control);
|
||||
await expect
|
||||
.poll(() => focusedControl.evaluate((element) => element === document.activeElement))
|
||||
.toBe(true);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await suite.closeBrowserContext(context);
|
||||
}
|
||||
});
|
||||
|
||||
it("resets an adopted catalog marquee when its label becomes short", async () => {
|
||||
const context = await suite.newBrowserContext({ viewport: { height: 900, width: 1280 } });
|
||||
const page = await context.newPage();
|
||||
const sessionKey = "agent:main:adopted-marquee";
|
||||
const catalogResponse = (name: string) => ({
|
||||
catalogs: [
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
capabilities: { continueSession: true, archive: true },
|
||||
hosts: [
|
||||
{
|
||||
hostId: "gateway:local",
|
||||
label: "Local Codex",
|
||||
kind: "gateway",
|
||||
connected: true,
|
||||
sessions: [
|
||||
{
|
||||
threadId: "thread-adopted-marquee",
|
||||
sessionKey,
|
||||
name,
|
||||
status: "idle",
|
||||
archived: false,
|
||||
canContinue: true,
|
||||
canArchive: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const initialName = "Trace every adopted catalog refresh before releasing the sidebar";
|
||||
const gateway = await installMockGateway(page, {
|
||||
featureMethods: ["chat.metadata", "chat.startup", "sessions.catalog.list"],
|
||||
methodResponses: {
|
||||
"sessions.list": chatSessionListResponse([
|
||||
{ key: sessionKey, kind: "direct", label: initialName, updatedAt: 1 },
|
||||
]),
|
||||
"sessions.catalog.list": catalogResponse(initialName),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
const catalog = page.locator('[data-session-section="catalog:codex"]');
|
||||
await catalog.waitFor({ state: "visible" });
|
||||
const toggle = catalog.locator(".sidebar-session-group-toggle");
|
||||
if ((await toggle.getAttribute("aria-expanded")) === "false") {
|
||||
await toggle.click();
|
||||
}
|
||||
const row = catalog.locator(`[data-session-key="${sessionKey}"]`);
|
||||
await row.hover();
|
||||
const menu = row.locator("[data-session-menu]");
|
||||
await expect
|
||||
.poll(() => menu.evaluate((element) => getComputedStyle(element).opacity))
|
||||
.toBe("1");
|
||||
await menu.hover();
|
||||
const label = row.locator(".hover-marquee");
|
||||
await expect
|
||||
.poll(() => label.evaluate((element) => element.classList.value), { timeout: 1_500 })
|
||||
.toContain("hover-marquee--scrolling");
|
||||
|
||||
const requestCount = (await gateway.getRequests("sessions.catalog.list")).length;
|
||||
await gateway.setMethodResponse("sessions.catalog.list", catalogResponse("Short"));
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("sessions.catalog.list")).length)
|
||||
.toBeGreaterThan(requestCount);
|
||||
await expect.poll(() => label.textContent()).toBe("Short");
|
||||
expect(await row.evaluate((element) => element.matches(":hover"))).toBe(true);
|
||||
await expect
|
||||
.poll(() => label.evaluate((element) => element.classList.value))
|
||||
.not.toContain("hover-marquee--scrolling");
|
||||
await expect
|
||||
.poll(() =>
|
||||
label.evaluate((element) => element.style.getPropertyValue("--hover-marquee-shift")),
|
||||
)
|
||||
.toBe("");
|
||||
} finally {
|
||||
await suite.closeBrowserContext(context);
|
||||
}
|
||||
});
|
||||
|
||||
it("remeasures an adopted marquee when live endcap state changes", async () => {
|
||||
const context = await suite.newBrowserContext({ viewport: { height: 900, width: 1280 } });
|
||||
const page = await context.newPage();
|
||||
const sessionKey = "agent:main:adopted-live-marquee";
|
||||
const label = "Trace every adopted session transition before releasing the sidebar";
|
||||
const catalogResponse = {
|
||||
catalogs: [
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
capabilities: { continueSession: true, archive: true },
|
||||
hosts: [
|
||||
{
|
||||
hostId: "gateway:local",
|
||||
label: "Local Codex",
|
||||
kind: "gateway",
|
||||
connected: true,
|
||||
sessions: [
|
||||
{
|
||||
threadId: "thread-adopted-live-marquee",
|
||||
sessionKey,
|
||||
name: label,
|
||||
status: "idle",
|
||||
archived: false,
|
||||
canContinue: true,
|
||||
canArchive: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const sessionResponse = (state: Record<string, unknown> = {}) =>
|
||||
chatSessionListResponse([
|
||||
{
|
||||
key: sessionKey,
|
||||
kind: "direct",
|
||||
label,
|
||||
updatedAt: 1,
|
||||
...state,
|
||||
},
|
||||
]);
|
||||
const gateway = await installMockGateway(page, {
|
||||
featureMethods: ["chat.metadata", "chat.startup", "sessions.catalog.list"],
|
||||
methodResponses: {
|
||||
"sessions.list": sessionResponse(),
|
||||
"sessions.catalog.list": catalogResponse,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
const catalog = page.locator('[data-session-section="catalog:codex"]');
|
||||
await catalog.waitFor({ state: "visible" });
|
||||
const toggle = catalog.locator(".sidebar-session-group-toggle");
|
||||
if ((await toggle.getAttribute("aria-expanded")) === "false") {
|
||||
await toggle.click();
|
||||
}
|
||||
const row = catalog.locator(`[data-session-key="${sessionKey}"]`);
|
||||
await row.hover();
|
||||
const menu = row.locator("[data-session-menu]");
|
||||
await expect
|
||||
.poll(() => menu.evaluate((element) => getComputedStyle(element).opacity))
|
||||
.toBe("1");
|
||||
await menu.hover();
|
||||
const marquee = row.locator(".hover-marquee");
|
||||
await expect
|
||||
.poll(() => marquee.evaluate((element) => element.classList.value), { timeout: 1_500 })
|
||||
.toContain("hover-marquee--scrolling");
|
||||
const idleShift = await marquee.evaluate((element) =>
|
||||
Number.parseFloat(getComputedStyle(element).getPropertyValue("--hover-marquee-shift")),
|
||||
);
|
||||
|
||||
const refreshSessions = async (state: Record<string, unknown>) => {
|
||||
const requestCount = (await gateway.getRequests("sessions.list")).length;
|
||||
await gateway.setMethodResponse("sessions.list", sessionResponse(state));
|
||||
await gateway.emitGatewayEvent("sessions.changed", {
|
||||
reason: "update",
|
||||
sessionKey,
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("sessions.list")).length)
|
||||
.toBeGreaterThan(requestCount);
|
||||
};
|
||||
|
||||
await refreshSessions({
|
||||
activeRunIds: ["run-adopted-live-marquee"],
|
||||
hasActiveRun: true,
|
||||
status: "running",
|
||||
updatedAt: 2,
|
||||
});
|
||||
await row.locator(".session-run-spinner").waitFor();
|
||||
expect(await row.evaluate((element) => element.matches(":hover"))).toBe(true);
|
||||
await expect
|
||||
.poll(() =>
|
||||
marquee.evaluate((element) =>
|
||||
Number.parseFloat(getComputedStyle(element).getPropertyValue("--hover-marquee-shift")),
|
||||
),
|
||||
)
|
||||
.toBeLessThan(idleShift);
|
||||
} finally {
|
||||
await suite.closeBrowserContext(context);
|
||||
}
|
||||
});
|
||||
|
||||
it("restarts a catalog marquee when its hovered label changes", async () => {
|
||||
const context = await suite.newBrowserContext({ viewport: { height: 900, width: 1280 } });
|
||||
const page = await context.newPage();
|
||||
const catalogResponse = (name: string, pullRequest?: { numbers: number[]; state: "open" }) => ({
|
||||
catalogs: [
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
capabilities: { continueSession: true, archive: true },
|
||||
hosts: [
|
||||
{
|
||||
hostId: "gateway:local",
|
||||
label: "Local Codex",
|
||||
kind: "gateway",
|
||||
connected: true,
|
||||
sessions: [
|
||||
{
|
||||
threadId: "thread-hovered",
|
||||
name,
|
||||
status: "idle",
|
||||
archived: false,
|
||||
canContinue: true,
|
||||
canArchive: true,
|
||||
...(pullRequest ? { pullRequest } : {}),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const initialName = "Trace the complete native catalog refresh lifecycle before release";
|
||||
const updatedName = "Verify the rewritten catalog title keeps scrolling under the pointer";
|
||||
const gateway = await installMockGateway(page, {
|
||||
featureMethods: ["chat.metadata", "chat.startup", "sessions.catalog.list"],
|
||||
methodResponses: {
|
||||
"sessions.list": chatSessionListResponse(),
|
||||
"sessions.catalog.list": catalogResponse(initialName),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
const catalog = page.locator('[data-session-section="catalog:codex"]');
|
||||
await catalog.waitFor({ state: "visible" });
|
||||
const toggle = catalog.locator(".sidebar-session-group-toggle");
|
||||
if ((await toggle.getAttribute("aria-expanded")) === "false") {
|
||||
await toggle.click();
|
||||
}
|
||||
const row = catalog.locator('[data-session-key$=":thread-hovered"]');
|
||||
await row.hover();
|
||||
const menu = row.locator("[data-catalog-session-menu]");
|
||||
await expect
|
||||
.poll(() => menu.evaluate((element) => getComputedStyle(element).opacity))
|
||||
.toBe("1");
|
||||
await menu.hover();
|
||||
const initialLabel = row.locator(".hover-marquee");
|
||||
await expect
|
||||
.poll(() => initialLabel.evaluate((element) => element.classList.value), { timeout: 1_500 })
|
||||
.toContain("hover-marquee--scrolling");
|
||||
|
||||
const requestCount = (await gateway.getRequests("sessions.catalog.list")).length;
|
||||
await gateway.setMethodResponse("sessions.catalog.list", catalogResponse(updatedName));
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("sessions.catalog.list")).length)
|
||||
.toBeGreaterThan(requestCount);
|
||||
const label = row.locator(".hover-marquee");
|
||||
await expect.poll(() => label.textContent()).toBe(updatedName);
|
||||
expect(await row.evaluate((element) => element.matches(":hover"))).toBe(true);
|
||||
await expect
|
||||
.poll(() => label.evaluate((element) => element.classList.value), { timeout: 1_500 })
|
||||
.toContain("hover-marquee--scrolling");
|
||||
|
||||
const shiftWithoutBadge = await label.evaluate((element) =>
|
||||
Number.parseFloat(getComputedStyle(element).getPropertyValue("--hover-marquee-shift")),
|
||||
);
|
||||
const badgeRequestCount = (await gateway.getRequests("sessions.catalog.list")).length;
|
||||
await gateway.setMethodResponse(
|
||||
"sessions.catalog.list",
|
||||
catalogResponse(updatedName, { numbers: [125820], state: "open" }),
|
||||
);
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("sessions.catalog.list")).length)
|
||||
.toBeGreaterThan(badgeRequestCount);
|
||||
await row
|
||||
.locator('.session-row-badge--pull-request[data-pull-request-state="open"]')
|
||||
.waitFor();
|
||||
await expect
|
||||
.poll(() => label.evaluate((element) => element.classList.value), { timeout: 1_500 })
|
||||
.toContain("hover-marquee--scrolling");
|
||||
await expect
|
||||
.poll(() =>
|
||||
label.evaluate((element) =>
|
||||
Number.parseFloat(getComputedStyle(element).getPropertyValue("--hover-marquee-shift")),
|
||||
),
|
||||
)
|
||||
.toBeLessThan(shiftWithoutBadge);
|
||||
} finally {
|
||||
await suite.closeBrowserContext(context);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import { expect, it } from "vitest";
|
||||
import type { SessionsCatalogHostEvent } from "../../../packages/gateway-protocol/src/index.ts";
|
||||
import { controlUiSessionPath, installMockGateway } from "../test-helpers/control-ui-e2e.ts";
|
||||
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
|
||||
import { expectHoverMarqueeAfterActionsAppear } from "./session-management.test-support.ts";
|
||||
|
||||
const suite = createControlUiE2eSuite({
|
||||
name: "Codex native session catalog",
|
||||
@@ -358,7 +357,7 @@ suite.define(() => {
|
||||
sessions: [
|
||||
{
|
||||
threadId: "thread-local",
|
||||
name: "Title fits until menu appears",
|
||||
name: "Local planning session",
|
||||
cwd: "/Users/dev/openclaw",
|
||||
status: "idle",
|
||||
archived: false,
|
||||
@@ -452,7 +451,7 @@ suite.define(() => {
|
||||
.evaluateAll((items) => items.map((item) => item.getAttribute("role"))),
|
||||
).toEqual(["listitem", "listitem"]);
|
||||
const openclawProject = section.locator(
|
||||
'[data-session-catalog-project="/Users/dev/openclaw"]',
|
||||
'[data-session-catalog-project="project:/Users/dev/openclaw"]',
|
||||
);
|
||||
const openclawProjectItem = openclawProject.locator("..");
|
||||
const openclawProjectList = openclawProjectItem.locator(":scope > [role=list]");
|
||||
@@ -465,7 +464,6 @@ suite.define(() => {
|
||||
await openclawProject.locator(".sidebar-session-catalog-project__count").textContent(),
|
||||
).toBe("2");
|
||||
const projectRows = section.locator(".sidebar-recent-session--catalog-project-child");
|
||||
const localCatalogRow = section.locator('[data-session-key$=":thread-local"]');
|
||||
await expect.poll(() => projectRows.count()).toBe(3);
|
||||
expect(
|
||||
await openclawProjectList
|
||||
@@ -506,13 +504,8 @@ suite.define(() => {
|
||||
// instead of reserving a phantom second line.
|
||||
for (const metric of threadRowMetrics) {
|
||||
expect(metric.singleLine).toBe(true);
|
||||
expect(metric.height).toBeCloseTo(30, 1);
|
||||
}
|
||||
const collapsedHeight = threadRowMetrics.at(0)?.height ?? Number.NaN;
|
||||
for (const metric of threadRowMetrics) {
|
||||
expect(metric.height).toBeCloseTo(collapsedHeight, 3);
|
||||
}
|
||||
// Collapsed rows sit on the 30px min-height floor; renderer sub-pixels vary.
|
||||
expect(collapsedHeight).toBeCloseTo(30, 1);
|
||||
for (const metric of threadRowMetrics) {
|
||||
expect(metric).toMatchObject({
|
||||
minHeight: "30px",
|
||||
@@ -521,22 +514,6 @@ suite.define(() => {
|
||||
paddingTop: "4px",
|
||||
});
|
||||
}
|
||||
const catalogRow = projectRows.first();
|
||||
await expectHoverMarqueeAfterActionsAppear(catalogRow);
|
||||
const catalogActionReservation = await catalogRow.evaluate((row) => {
|
||||
const text = row.querySelector<HTMLElement>(".sidebar-recent-session__text");
|
||||
const menu = row.querySelector<HTMLElement>("[data-catalog-session-menu]");
|
||||
return {
|
||||
actionCount: row.getAttribute("data-session-row-action-count"),
|
||||
menuWidth: menu?.getBoundingClientRect().width ?? 0,
|
||||
paddingRight: text ? Number.parseFloat(getComputedStyle(text).paddingRight) : 0,
|
||||
};
|
||||
});
|
||||
expect(catalogActionReservation.actionCount).toBe("1");
|
||||
expect(catalogActionReservation.paddingRight).toBeCloseTo(
|
||||
catalogActionReservation.menuWidth + 3,
|
||||
0,
|
||||
);
|
||||
const projectLabelTone = await openclawProject
|
||||
.locator(".sidebar-session-catalog-project__label")
|
||||
.evaluate((label) => {
|
||||
@@ -568,7 +545,7 @@ suite.define(() => {
|
||||
expect(projectLabelTone.distanceToText).toBeLessThan(projectLabelTone.distanceToMuted);
|
||||
expect(
|
||||
await section
|
||||
.locator('[data-session-catalog-project="/Users/dev/other"]')
|
||||
.locator('[data-session-catalog-project="project:/Users/dev/other"]')
|
||||
.locator(".sidebar-session-catalog-project__label")
|
||||
.textContent(),
|
||||
).toBe("other");
|
||||
@@ -646,7 +623,7 @@ suite.define(() => {
|
||||
|
||||
await openclawProject.click();
|
||||
await expect.poll(() => openclawProject.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(await localCatalogRow.count()).toBe(0);
|
||||
expect(await section.getByText("Local planning session", { exact: true }).count()).toBe(0);
|
||||
expect(await section.getByText("Worktree fix session", { exact: true }).count()).toBe(0);
|
||||
expect(await section.getByText("Other project session", { exact: true }).count()).toBe(1);
|
||||
expect(await openclawProject.count()).toBe(1);
|
||||
@@ -658,18 +635,18 @@ suite.define(() => {
|
||||
(key) => JSON.parse(localStorage.getItem(key) ?? "[]"),
|
||||
collapsedSessionSectionsStorageKey,
|
||||
),
|
||||
).toContain("catalog-project:codex:gateway:local:/Users/dev/openclaw");
|
||||
).toContain("catalog-project:codex:gateway:local:project:/Users/dev/openclaw");
|
||||
|
||||
await openclawProject.click();
|
||||
await expect.poll(() => openclawProject.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(await localCatalogRow.count()).toBe(1);
|
||||
expect(await section.getByText("Local planning session", { exact: true }).count()).toBe(1);
|
||||
expect(await section.getByText("Worktree fix session", { exact: true }).count()).toBe(1);
|
||||
expect(
|
||||
await page.evaluate(
|
||||
(key) => JSON.parse(localStorage.getItem(key) ?? "[]"),
|
||||
collapsedSessionSectionsStorageKey,
|
||||
),
|
||||
).not.toContain("catalog-project:codex:gateway:local:/Users/dev/openclaw");
|
||||
).not.toContain("catalog-project:codex:gateway:local:project:/Users/dev/openclaw");
|
||||
|
||||
if (captureUiProofEnabled) {
|
||||
await mkdir(uiProofArtifactDir, { recursive: true });
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { CATALOG_SESSION_CONTINUED_EVENT } from "../lib/sessions/catalog-key.ts";
|
||||
import {
|
||||
chatSessionListResponse,
|
||||
createChatFlowE2eSuite,
|
||||
installMockGateway,
|
||||
} from "./chat-flow.test-support.ts";
|
||||
|
||||
const suite = createChatFlowE2eSuite();
|
||||
|
||||
function catalogResponse(
|
||||
sessions: Array<{
|
||||
threadId: string;
|
||||
name: string;
|
||||
cwd: string;
|
||||
sessionKey?: string;
|
||||
}>,
|
||||
) {
|
||||
return {
|
||||
catalogs: [
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
capabilities: { continueSession: true, archive: true },
|
||||
hosts: [
|
||||
{
|
||||
hostId: "gateway:local",
|
||||
label: "Local Codex",
|
||||
kind: "gateway",
|
||||
connected: true,
|
||||
sessions: sessions.map((session) => ({
|
||||
...session,
|
||||
status: "idle",
|
||||
archived: false,
|
||||
canContinue: true,
|
||||
canArchive: true,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
suite.define(() => {
|
||||
it("preserves a focused catalog row when project groups reorder", async () => {
|
||||
const context = await suite.newBrowserContext({});
|
||||
const page = await context.newPage();
|
||||
const first = { threadId: "thread-project-a", name: "Project A", cwd: "/work/project-a" };
|
||||
const second = { threadId: "thread-project-b", name: "Project B", cwd: "/work/project-b" };
|
||||
const gateway = await installMockGateway(page, {
|
||||
featureMethods: ["chat.metadata", "chat.startup", "sessions.catalog.list"],
|
||||
methodResponses: {
|
||||
"sessions.list": chatSessionListResponse(),
|
||||
"sessions.catalog.list": catalogResponse([first, second]),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
const catalog = page.locator('[data-session-section="catalog:codex"]');
|
||||
await catalog.waitFor({ state: "visible" });
|
||||
const toggle = catalog.locator(".sidebar-session-group-toggle");
|
||||
if ((await toggle.getAttribute("aria-expanded")) === "false") {
|
||||
await toggle.click();
|
||||
}
|
||||
const row = catalog.locator('[data-session-key$=":thread-project-a"]');
|
||||
const menu = row.locator("[data-catalog-session-menu]");
|
||||
await menu.focus();
|
||||
await row.evaluate((element) => element.setAttribute("data-identity-probe", "kept"));
|
||||
|
||||
const requestCount = (await gateway.getRequests("sessions.catalog.list")).length;
|
||||
await gateway.setMethodResponse("sessions.catalog.list", catalogResponse([second, first]));
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("sessions.catalog.list")).length)
|
||||
.toBeGreaterThan(requestCount);
|
||||
await expect
|
||||
.poll(() =>
|
||||
catalog
|
||||
.locator("[data-session-catalog-project]")
|
||||
.evaluateAll((elements) =>
|
||||
elements.map((element) => element.getAttribute("data-session-catalog-project")),
|
||||
),
|
||||
)
|
||||
.toEqual(["/work/project-b", "/work/project-a"]);
|
||||
expect(await row.getAttribute("data-identity-probe")).toBe("kept");
|
||||
await expect
|
||||
.poll(() => menu.evaluate((element) => element === document.activeElement))
|
||||
.toBe(true);
|
||||
} finally {
|
||||
await suite.closeBrowserContext(context);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["Escape", "Tab"])(
|
||||
"returns focus to the adopted row when its open catalog menu closes with %s",
|
||||
async (dismissKey) => {
|
||||
const context = await suite.newBrowserContext({});
|
||||
const page = await context.newPage();
|
||||
const sessionKey = "agent:main:adopted-open-menu";
|
||||
const catalogSession = {
|
||||
threadId: "thread-adopted-open-menu",
|
||||
name: "Adopt while its menu is open",
|
||||
cwd: "/work/openclaw",
|
||||
};
|
||||
await installMockGateway(page, {
|
||||
featureMethods: ["chat.metadata", "chat.startup", "sessions.catalog.list"],
|
||||
methodResponses: {
|
||||
"sessions.list": chatSessionListResponse([
|
||||
{ key: sessionKey, kind: "direct", label: catalogSession.name, updatedAt: 1 },
|
||||
]),
|
||||
"sessions.catalog.list": catalogResponse([catalogSession]),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
const catalog = page.locator('[data-session-section="catalog:codex"]');
|
||||
await catalog.waitFor({ state: "visible" });
|
||||
const toggle = catalog.locator(".sidebar-session-group-toggle");
|
||||
if ((await toggle.getAttribute("aria-expanded")) === "false") {
|
||||
await toggle.click();
|
||||
}
|
||||
const catalogRow = catalog.locator('[data-session-key$=":thread-adopted-open-menu"]');
|
||||
await catalogRow.hover();
|
||||
await catalogRow.locator("[data-catalog-session-menu]").click();
|
||||
const popup = page.locator("openclaw-catalog-session-menu");
|
||||
await popup.waitFor({ state: "visible" });
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => document.activeElement?.localName))
|
||||
.toBe("wa-dropdown-item");
|
||||
|
||||
await page.evaluate(
|
||||
({ eventName, adoptedSessionKey }) => {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent(eventName, {
|
||||
detail: {
|
||||
agentId: "main",
|
||||
catalogId: "codex",
|
||||
hostId: "gateway:local",
|
||||
sessionKey: adoptedSessionKey,
|
||||
threadId: "thread-adopted-open-menu",
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ eventName: CATALOG_SESSION_CONTINUED_EVENT, adoptedSessionKey: sessionKey },
|
||||
);
|
||||
const adoptedMenu = catalog.locator(
|
||||
`[data-session-key="${sessionKey}"] [data-session-menu]`,
|
||||
);
|
||||
await adoptedMenu.waitFor({ state: "attached" });
|
||||
await expect.poll(() => adoptedMenu.getAttribute("aria-expanded")).toBe("true");
|
||||
await popup.getByRole("menuitem").first().press(dismissKey);
|
||||
await popup.waitFor({ state: "detached" });
|
||||
if (dismissKey === "Escape") {
|
||||
await expect
|
||||
.poll(() => adoptedMenu.evaluate((element) => element === document.activeElement))
|
||||
.toBe(true);
|
||||
} else {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const active = document.activeElement;
|
||||
return Boolean(active?.isConnected && active !== document.body);
|
||||
}),
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
} finally {
|
||||
await suite.closeBrowserContext(context);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import path from "node:path";
|
||||
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
|
||||
import type { Locator, Page } from "playwright";
|
||||
import { expect } from "vitest";
|
||||
import type { GatewaySessionRow } from "../api/types.ts";
|
||||
import {
|
||||
controlUiSessionPath,
|
||||
controlUiSessionUrl,
|
||||
@@ -46,8 +45,6 @@ export function sessionRow(
|
||||
pinned?: boolean;
|
||||
pinnedAt?: number;
|
||||
hasActiveRun?: boolean;
|
||||
hasAutomation?: GatewaySessionRow["hasAutomation"];
|
||||
incognito?: GatewaySessionRow["incognito"];
|
||||
unread?: boolean;
|
||||
status?: string;
|
||||
spawnedBy?: string;
|
||||
@@ -167,37 +164,6 @@ export function actionPointerEvents(button: Locator): Promise<string> {
|
||||
return button.evaluate((element) => globalThis.getComputedStyle(element).pointerEvents);
|
||||
}
|
||||
|
||||
function measureMarqueeLabel(
|
||||
label: Locator,
|
||||
): Promise<{ scrollWidth: number; viewportWidth: number }> {
|
||||
return label.evaluate((element) => {
|
||||
const viewport = element.parentElement;
|
||||
if (!(viewport instanceof HTMLElement)) {
|
||||
throw new Error("Marquee label must have an HTMLElement viewport");
|
||||
}
|
||||
const style = getComputedStyle(viewport);
|
||||
return {
|
||||
scrollWidth: element.scrollWidth,
|
||||
viewportWidth:
|
||||
viewport.clientWidth -
|
||||
(Number.parseFloat(style.paddingLeft) || 0) -
|
||||
(Number.parseFloat(style.paddingRight) || 0),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectHoverMarqueeAfterActionsAppear(row: Locator): Promise<void> {
|
||||
const label = row.locator(".sidebar-recent-session__name");
|
||||
const resting = await measureMarqueeLabel(label);
|
||||
expect(resting.scrollWidth, JSON.stringify(resting)).toBeLessThanOrEqual(resting.viewportWidth);
|
||||
await row.hover();
|
||||
const hovered = await measureMarqueeLabel(label);
|
||||
expect(hovered.scrollWidth, JSON.stringify(hovered)).toBeGreaterThan(hovered.viewportWidth);
|
||||
await expect
|
||||
.poll(() => label.evaluate((element) => element.classList.value), { timeout: 1_500 })
|
||||
.toContain("hover-marquee--scrolling");
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a session-menu submenu through the keyboard path. Submenu ARIA is ready
|
||||
* before Web Awesome finishes opening the dropdown, so hovering alone races the
|
||||
|
||||
@@ -16,6 +16,7 @@ const suite = createSessionManagementE2eSuite();
|
||||
suite.define(() => {
|
||||
it("vertically centers session actions in a two-line row", async () => {
|
||||
const context = await suite.browser.newContext({
|
||||
hasTouch: true,
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
@@ -40,100 +41,44 @@ suite.define(() => {
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
const row = page.locator('[data-session-key="agent:main:two-line"]');
|
||||
await row.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await row.hover();
|
||||
const pin = row.getByRole("button", { name: "Unpin session" });
|
||||
const menu = row.getByRole("button", { name: "Open session menu" });
|
||||
await expect.poll(() => actionOpacity(pin)).toBe("1");
|
||||
await pin.hover();
|
||||
await captureUiProof(page, "sidebar-session-actions-centered.png");
|
||||
|
||||
const [rowBounds, titleBounds, subtitleBounds, pinBounds, pinGlyphBounds, menuBounds] =
|
||||
await Promise.all([
|
||||
row.boundingBox(),
|
||||
row.locator(".sidebar-recent-session__name").boundingBox(),
|
||||
row.locator(".sidebar-recent-session__subtitle").boundingBox(),
|
||||
pin.boundingBox(),
|
||||
pin.locator("svg").boundingBox(),
|
||||
menu.boundingBox(),
|
||||
]);
|
||||
if (
|
||||
!rowBounds ||
|
||||
!titleBounds ||
|
||||
!subtitleBounds ||
|
||||
!pinBounds ||
|
||||
!pinGlyphBounds ||
|
||||
!menuBounds
|
||||
) {
|
||||
const [rowBounds, subtitleBounds, pinBounds, menuBounds] = await Promise.all([
|
||||
row.boundingBox(),
|
||||
row.locator(".sidebar-recent-session__subtitle").boundingBox(),
|
||||
pin.boundingBox(),
|
||||
menu.boundingBox(),
|
||||
]);
|
||||
if (!rowBounds || !subtitleBounds || !pinBounds || !menuBounds) {
|
||||
throw new Error("Expected visible two-line session action geometry");
|
||||
}
|
||||
const rowCenter = rowBounds.y + rowBounds.height / 2;
|
||||
const titleCenter = titleBounds.y + titleBounds.height / 2;
|
||||
// Two-line rows anchor the actions to the title line so the subtitle keeps
|
||||
// its own line; the row centre falls between the two lines instead.
|
||||
expect(subtitleBounds.y + subtitleBounds.height / 2).toBeGreaterThan(rowCenter);
|
||||
expect(Math.abs(pinBounds.y + pinBounds.height / 2 - titleCenter)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(menuBounds.y + menuBounds.height / 2 - titleCenter)).toBeLessThanOrEqual(1);
|
||||
expect(pinBounds.width).toBeGreaterThanOrEqual(24);
|
||||
expect(pinBounds.height).toBeGreaterThanOrEqual(24);
|
||||
expect(menuBounds.width).toBeGreaterThanOrEqual(24);
|
||||
expect(menuBounds.height).toBeGreaterThanOrEqual(24);
|
||||
expect(pinGlyphBounds.y + pinGlyphBounds.height).toBeLessThanOrEqual(subtitleBounds.y);
|
||||
const subtitleHitTarget = await page.evaluate(
|
||||
({ x, y }) => {
|
||||
const target = document.elementFromPoint(x, y);
|
||||
return {
|
||||
action: target?.closest(".session-action")?.getAttribute("aria-label") ?? null,
|
||||
link: target?.closest(".sidebar-recent-session__link") !== null,
|
||||
};
|
||||
},
|
||||
{
|
||||
x: pinBounds.x + pinBounds.width / 2,
|
||||
y: subtitleBounds.y + 1,
|
||||
},
|
||||
);
|
||||
expect(subtitleHitTarget).toEqual({ action: null, link: true });
|
||||
expect(subtitleBounds.y).toBeGreaterThan(rowCenter);
|
||||
expect(Math.abs(pinBounds.y + pinBounds.height / 2 - rowCenter)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(menuBounds.y + menuBounds.height / 2 - rowCenter)).toBeLessThanOrEqual(1);
|
||||
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.style.setProperty("--control-ui-text-scale", "1.4");
|
||||
});
|
||||
const [
|
||||
scaledTitleBounds,
|
||||
scaledSubtitleBounds,
|
||||
scaledPinBounds,
|
||||
scaledPinGlyphBounds,
|
||||
scaledMenuBounds,
|
||||
] = await Promise.all([
|
||||
row.locator(".sidebar-recent-session__name").boundingBox(),
|
||||
row.locator(".sidebar-recent-session__subtitle").boundingBox(),
|
||||
pin.boundingBox(),
|
||||
pin.locator("svg").boundingBox(),
|
||||
menu.boundingBox(),
|
||||
]);
|
||||
if (
|
||||
!scaledTitleBounds ||
|
||||
!scaledSubtitleBounds ||
|
||||
!scaledPinBounds ||
|
||||
!scaledPinGlyphBounds ||
|
||||
!scaledMenuBounds
|
||||
) {
|
||||
throw new Error("Expected scaled two-line session action geometry");
|
||||
}
|
||||
const scaledTitleCenter = scaledTitleBounds.y + scaledTitleBounds.height / 2;
|
||||
expect(
|
||||
Math.abs(scaledPinBounds.y + scaledPinBounds.height / 2 - scaledTitleCenter),
|
||||
).toBeLessThanOrEqual(1);
|
||||
expect(
|
||||
Math.abs(scaledMenuBounds.y + scaledMenuBounds.height / 2 - scaledTitleCenter),
|
||||
).toBeLessThanOrEqual(1);
|
||||
expect(scaledPinGlyphBounds.y + scaledPinGlyphBounds.height).toBeLessThanOrEqual(
|
||||
scaledSubtitleBounds.y,
|
||||
);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [title, details] = await Promise.all([
|
||||
row.locator(".sidebar-recent-session__title-row").boundingBox(),
|
||||
row.locator(".sidebar-recent-session__details").boundingBox(),
|
||||
]);
|
||||
return title && details ? title.y + title.height - details.y : Number.POSITIVE_INFINITY;
|
||||
})
|
||||
.toBeLessThanOrEqual(0.5);
|
||||
await captureUiProof(page, "sidebar-session-text-scale-140.png");
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps action-only text widest at rest and keeps active state lit", async () => {
|
||||
it("keeps action-only text stable and active state visible with actions", async () => {
|
||||
const context = await suite.browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
@@ -175,41 +120,23 @@ suite.define(() => {
|
||||
|
||||
await actionOnlyRow.hover();
|
||||
await expect.poll(() => actionOpacity(actionOnlyPin)).toBe("1");
|
||||
const [actionOnlyHoveredTextBounds, actionOnlyHoveredNameBounds, actionOnlyHoveredPinBounds] =
|
||||
await Promise.all([
|
||||
actionOnlyText.boundingBox(),
|
||||
actionOnlyRow.locator(".sidebar-recent-session__name").boundingBox(),
|
||||
actionOnlyPin.boundingBox(),
|
||||
]);
|
||||
await expect
|
||||
.poll(() => actionOnlyText.evaluate((element) => getComputedStyle(element).paddingRight))
|
||||
.toBe("52px");
|
||||
const hoveredTextBounds = await actionOnlyText.boundingBox();
|
||||
|
||||
await page.mouse.move(0, 0);
|
||||
await actionOnlyPin.focus();
|
||||
await expect.poll(() => actionOpacity(actionOnlyPin)).toBe("1");
|
||||
const [actionOnlyFocusedTextBounds, actionOnlyFocusedNameBounds, actionOnlyFocusedPinBounds] =
|
||||
await Promise.all([
|
||||
actionOnlyText.boundingBox(),
|
||||
actionOnlyRow.locator(".sidebar-recent-session__name").boundingBox(),
|
||||
actionOnlyPin.boundingBox(),
|
||||
]);
|
||||
if (
|
||||
!restingTextBounds ||
|
||||
!actionOnlyHoveredTextBounds ||
|
||||
!actionOnlyHoveredNameBounds ||
|
||||
!actionOnlyHoveredPinBounds ||
|
||||
!actionOnlyFocusedTextBounds ||
|
||||
!actionOnlyFocusedNameBounds ||
|
||||
!actionOnlyFocusedPinBounds
|
||||
) {
|
||||
await expect
|
||||
.poll(() => actionOnlyText.evaluate((element) => getComputedStyle(element).paddingRight))
|
||||
.toBe("52px");
|
||||
const focusedTextBounds = await actionOnlyText.boundingBox();
|
||||
if (!restingTextBounds || !hoveredTextBounds || !focusedTextBounds) {
|
||||
throw new Error("Expected visible action-only text geometry");
|
||||
}
|
||||
expect(actionOnlyHoveredTextBounds.width).toBeCloseTo(restingTextBounds.width, 1);
|
||||
expect(actionOnlyFocusedTextBounds.width).toBeCloseTo(restingTextBounds.width, 1);
|
||||
expect(actionOnlyHoveredNameBounds.x + actionOnlyHoveredNameBounds.width).toBeLessThanOrEqual(
|
||||
actionOnlyHoveredPinBounds.x + 1,
|
||||
);
|
||||
expect(actionOnlyFocusedNameBounds.x + actionOnlyFocusedNameBounds.width).toBeLessThanOrEqual(
|
||||
actionOnlyFocusedPinBounds.x + 1,
|
||||
);
|
||||
expect(hoveredTextBounds.width).toBeCloseTo(restingTextBounds.width, 1);
|
||||
expect(focusedTextBounds.width).toBeCloseTo(restingTextBounds.width, 1);
|
||||
|
||||
const row = page.locator('[data-session-key="agent:main:hover-active"]');
|
||||
await row.waitFor({ state: "visible", timeout: 10_000 });
|
||||
@@ -220,20 +147,16 @@ suite.define(() => {
|
||||
await expect.poll(() => actionOpacity(state)).toBe("1");
|
||||
|
||||
await row.hover();
|
||||
// The spinner is the signal that the row is still working; hovering to
|
||||
// reach the actions must not take it away on a two-line row.
|
||||
await expect.poll(() => actionOpacity(state)).toBe("1");
|
||||
await expect.poll(() => state.locator(".session-run-spinner").isVisible()).toBe(true);
|
||||
await expect.poll(() => actionOpacity(pin)).toBe("1");
|
||||
await expect.poll(() => actionOpacity(menu)).toBe("1");
|
||||
|
||||
const [nameBounds, pinBounds, menuBounds, stateBounds] = await Promise.all([
|
||||
const [nameBounds, pinBounds, menuBounds] = await Promise.all([
|
||||
row.locator(".sidebar-recent-session__name").boundingBox(),
|
||||
pin.boundingBox(),
|
||||
menu.boundingBox(),
|
||||
state.boundingBox(),
|
||||
]);
|
||||
if (!nameBounds || !pinBounds || !menuBounds || !stateBounds) {
|
||||
if (!nameBounds || !pinBounds || !menuBounds) {
|
||||
throw new Error("Expected visible hovered action geometry");
|
||||
}
|
||||
expect(nameBounds.y + nameBounds.height / 2).toBeCloseTo(
|
||||
@@ -241,9 +164,6 @@ suite.define(() => {
|
||||
1,
|
||||
);
|
||||
expect(pinBounds.x + pinBounds.width).toBeLessThanOrEqual(menuBounds.x);
|
||||
// The reservation slides the endcap clear instead of hiding it, so the
|
||||
// run spinner and unread dot survive the hover that reveals the buttons.
|
||||
expect(stateBounds.x + stateBounds.width).toBeLessThanOrEqual(pinBounds.x);
|
||||
|
||||
await page.mouse.move(0, 0);
|
||||
await pin.focus();
|
||||
@@ -318,10 +238,10 @@ suite.define(() => {
|
||||
expect(
|
||||
Math.abs(forkBounds.y + forkBounds.height / 2 - (nameBounds.y + nameBounds.height / 2)),
|
||||
).toBeLessThanOrEqual(2);
|
||||
// The actions ride the title's midline now, and the title ends before them.
|
||||
expect(
|
||||
Math.abs(nameBounds.y + nameBounds.height / 2 - (pinBounds.y + pinBounds.height / 2)),
|
||||
).toBeLessThanOrEqual(1);
|
||||
expect(nameBounds.y + nameBounds.height / 2).toBeCloseTo(
|
||||
pinBounds.y + pinBounds.height / 2,
|
||||
1,
|
||||
);
|
||||
expect(nameBounds.x + nameBounds.width).toBeLessThanOrEqual(pinBounds.x + 1);
|
||||
expect(pinBounds.x + pinBounds.width).toBeLessThanOrEqual(menuBounds.x);
|
||||
} finally {
|
||||
@@ -329,82 +249,6 @@ suite.define(() => {
|
||||
}
|
||||
});
|
||||
|
||||
it("lines the whole trailing column up on one pitch", async () => {
|
||||
const context = await suite.browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"sessions.list": sessionsListResponse([
|
||||
sessionRow("agent:main:main", "Main", Date.now()),
|
||||
// A preview gives the row its second line, which is where the endcap
|
||||
// sits directly under the action icons.
|
||||
Object.assign(
|
||||
sessionRow("agent:main:badged", "Badged session", Date.now() - 1, {
|
||||
hasAutomation: true,
|
||||
incognito: true,
|
||||
status: "done",
|
||||
unread: true,
|
||||
}),
|
||||
{ lastMessagePreview: "Kept the endcap lit under the hover actions" },
|
||||
),
|
||||
]),
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
const row = page.locator('[data-session-key="agent:main:badged"]');
|
||||
await row.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await expect.poll(() => row.locator(".session-unread-dot").isVisible()).toBe(true);
|
||||
await row.hover();
|
||||
await expect
|
||||
.poll(() =>
|
||||
row.locator("[data-session-menu]").evaluate((el) => getComputedStyle(el).opacity),
|
||||
)
|
||||
.toBe("1");
|
||||
|
||||
const column = await row.evaluate((element) => {
|
||||
const centres = (root: Element | null, selector: string) =>
|
||||
[...(root?.querySelectorAll(selector) ?? [])]
|
||||
.map((glyph) => glyph.getBoundingClientRect())
|
||||
.filter((rect) => rect.width > 0)
|
||||
.map((rect) => Math.round((rect.left + rect.width / 2) * 10) / 10)
|
||||
.toSorted((left, right) => left - right);
|
||||
return {
|
||||
actions: centres(element, ".session-action svg"),
|
||||
endcap: centres(
|
||||
element.querySelector(".sidebar-recent-session__details-endcap"),
|
||||
"svg, .session-unread-dot, .session-run-spinner",
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// The endcap's bare glyphs and the action icons above them read as one
|
||||
// column, so they need one pitch and one right-hand axis. Sized to their
|
||||
// own boxes the buttons stepped 25px while the badges stepped 20px and
|
||||
// ended 5px further right, so nothing sat under anything.
|
||||
expect(column.endcap.length).toBeGreaterThan(1);
|
||||
expect(column.actions.length).toBeGreaterThan(1);
|
||||
const stepsOf = (centres: number[]) =>
|
||||
centres.slice(1).map((centre, index) => Math.round(centre - (centres[index] as number)));
|
||||
const steps = [...stepsOf(column.endcap), ...stepsOf(column.actions)];
|
||||
for (const step of steps) {
|
||||
expect(step, JSON.stringify(column)).toBe(steps[0]);
|
||||
}
|
||||
expect(column.endcap.at(-1), JSON.stringify(column)).toBeCloseTo(
|
||||
column.actions.at(-1) as number,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the trailing unread dot on one axis with and without a pull-request icon", async () => {
|
||||
const plainKey = "agent:main:unread-plain";
|
||||
const pullRequestKey = "agent:main:unread-pr";
|
||||
@@ -500,89 +344,6 @@ suite.define(() => {
|
||||
}
|
||||
});
|
||||
|
||||
it("draws every row glyph at one size", async () => {
|
||||
const context = await suite.browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"sessions.list": sessionsListResponse([
|
||||
sessionRow("agent:main:main", "Main", Date.now()),
|
||||
sessionRow("agent:main:mixed", "Mixed glyphs", Date.now() - 1, {
|
||||
forkSource: { sessionKey: "agent:main:main", sessionId: "source-session" },
|
||||
hasActiveRun: true,
|
||||
hasAutomation: true,
|
||||
incognito: true,
|
||||
status: "running",
|
||||
unread: true,
|
||||
}),
|
||||
sessionRow("agent:main:archived", "Archived glyph", Date.now() - 2, {
|
||||
archived: true,
|
||||
}),
|
||||
sessionRow("agent:main:queued", "Queued glyph", Date.now() - 3, {
|
||||
hasActiveRun: true,
|
||||
status: "queued",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
sessionKey: "agent:main:archived",
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
const row = page.locator('[data-session-key="agent:main:mixed"]');
|
||||
const archivedRow = page.locator('[data-session-key="agent:main:archived"]');
|
||||
const queuedRow = page.locator('[data-session-key="agent:main:queued"]');
|
||||
await row.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await archivedRow.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await queuedRow.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await row.hover();
|
||||
await expect
|
||||
.poll(() =>
|
||||
row.locator("[data-session-menu]").evaluate((el) => getComputedStyle(el).opacity),
|
||||
)
|
||||
.toBe("1");
|
||||
|
||||
// Badges, fork provenance and the action icons drew themselves at
|
||||
// different sizes on the same line, which reads as broken alignment. The
|
||||
// unread dot is a dot rather than a glyph and keeps its own size.
|
||||
const measured = await page
|
||||
.locator(
|
||||
'[data-session-key="agent:main:mixed"], [data-session-key="agent:main:archived"], [data-session-key="agent:main:queued"]',
|
||||
)
|
||||
.evaluateAll((elements) => {
|
||||
const glyphSizes = new Set<string>();
|
||||
for (const element of elements) {
|
||||
for (const glyph of element.querySelectorAll("svg")) {
|
||||
if (glyph.getBoundingClientRect().width === 0) {
|
||||
continue;
|
||||
}
|
||||
const style = getComputedStyle(glyph);
|
||||
glyphSizes.add(
|
||||
`${Number.parseFloat(style.width)}x${Number.parseFloat(style.height)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const spinner = elements[0]?.querySelector(".session-run-spinner");
|
||||
return {
|
||||
glyphSizes: [...glyphSizes],
|
||||
spinnerWidth: spinner ? Number.parseFloat(getComputedStyle(spinner).width) : null,
|
||||
};
|
||||
});
|
||||
|
||||
expect(measured.glyphSizes.length, JSON.stringify(measured.glyphSizes)).toBe(1);
|
||||
// The ring inks its whole box while the icons only ink 9-10px of theirs,
|
||||
// so it sits three px down rather than matching box for box.
|
||||
const glyphWidth = Number.parseFloat(measured.glyphSizes[0] as string);
|
||||
expect(measured.spinnerWidth).toBe(glyphWidth - 3);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps semantic state beside always-visible touch actions", async () => {
|
||||
const context = await suite.browser.newContext({
|
||||
hasTouch: true,
|
||||
@@ -595,14 +356,10 @@ suite.define(() => {
|
||||
methodResponses: {
|
||||
"sessions.list": sessionsListResponse([
|
||||
sessionRow("agent:main:main", "Main", Date.now()),
|
||||
Object.assign(
|
||||
sessionRow("agent:main:touch-active", "Touch active", Date.now() - 1, {
|
||||
hasActiveRun: true,
|
||||
status: "running",
|
||||
}),
|
||||
{ lastMessagePreview: "Persistent touch subtitle" },
|
||||
),
|
||||
sessionRow("agent:main:touch-idle", "Touch idle", Date.now() - 2),
|
||||
sessionRow("agent:main:touch-active", "Touch active", Date.now() - 1, {
|
||||
hasActiveRun: true,
|
||||
status: "running",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
@@ -611,9 +368,7 @@ suite.define(() => {
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
const row = page.locator('[data-session-key="agent:main:touch-active"]');
|
||||
const singleLineRow = page.locator('[data-session-key="agent:main:touch-idle"]');
|
||||
await row.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await singleLineRow.waitFor({ state: "visible", timeout: 10_000 });
|
||||
const state = row.locator(".session-row-state");
|
||||
const pin = row.getByRole("button", { name: "Pin session" });
|
||||
const menu = row.getByRole("button", { name: "Open session menu" });
|
||||
@@ -622,112 +377,17 @@ suite.define(() => {
|
||||
await expect.poll(() => pin.isVisible()).toBe(true);
|
||||
await expect.poll(() => menu.isVisible()).toBe(true);
|
||||
|
||||
const [rowBounds, stateBounds, pinBounds, menuBounds] = await Promise.all([
|
||||
row.boundingBox(),
|
||||
state.boundingBox(),
|
||||
pin.boundingBox(),
|
||||
menu.boundingBox(),
|
||||
]);
|
||||
if (!rowBounds || !stateBounds || !pinBounds || !menuBounds) {
|
||||
const [stateBounds, pinBounds] = await Promise.all([state.boundingBox(), pin.boundingBox()]);
|
||||
if (!stateBounds || !pinBounds) {
|
||||
throw new Error("Expected visible touch state and action geometry");
|
||||
}
|
||||
expect(rowBounds.height).toBeGreaterThanOrEqual(44);
|
||||
expect(pinBounds.width).toBeGreaterThanOrEqual(44);
|
||||
expect(pinBounds.height).toBeGreaterThanOrEqual(44);
|
||||
expect(menuBounds.width).toBeGreaterThanOrEqual(44);
|
||||
expect(menuBounds.height).toBeGreaterThanOrEqual(44);
|
||||
expect(stateBounds.x + stateBounds.width).toBeLessThanOrEqual(pinBounds.x);
|
||||
const [singleLineRowBounds, singleLineLinkBounds] = await Promise.all([
|
||||
singleLineRow.boundingBox(),
|
||||
singleLineRow.locator(".sidebar-recent-session__link").boundingBox(),
|
||||
]);
|
||||
if (!singleLineRowBounds || !singleLineLinkBounds) {
|
||||
throw new Error("Expected visible single-line touch row geometry");
|
||||
}
|
||||
expect(singleLineLinkBounds.height).toBeGreaterThanOrEqual(singleLineRowBounds.height);
|
||||
const topBandIsLink = await page.evaluate(
|
||||
({ x, y }) =>
|
||||
document.elementFromPoint(x, y)?.closest(".sidebar-recent-session__link") !== null,
|
||||
{
|
||||
x: singleLineRowBounds.x + 20,
|
||||
y: singleLineRowBounds.y + 1,
|
||||
},
|
||||
);
|
||||
expect(topBandIsLink).toBe(true);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("contains touch actions within consecutive pinned rows", async () => {
|
||||
const context = await suite.browser.newContext({
|
||||
hasTouch: true,
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const now = Date.now();
|
||||
await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"sessions.list": sessionsListResponse([
|
||||
sessionRow("agent:main:main", "Main", now),
|
||||
sessionRow("agent:main:pinned-first", "Pinned first", now - 1, {
|
||||
pinned: true,
|
||||
pinnedAt: now - 1,
|
||||
}),
|
||||
sessionRow("agent:main:pinned-second", "Pinned second", now - 2, {
|
||||
pinned: true,
|
||||
pinnedAt: now - 2,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
const firstRow = page.locator(
|
||||
'[data-sidebar-entry="session:agent:main:pinned-first"] .sidebar-recent-session',
|
||||
);
|
||||
const secondRow = page.locator(
|
||||
'[data-sidebar-entry="session:agent:main:pinned-second"] .sidebar-recent-session',
|
||||
);
|
||||
await firstRow.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await secondRow.waitFor({ state: "visible", timeout: 10_000 });
|
||||
const firstMenu = firstRow.getByRole("button", { name: "Open session menu: Pinned first" });
|
||||
const [firstRowBounds, secondRowBounds, firstMenuBounds] = await Promise.all([
|
||||
firstRow.boundingBox(),
|
||||
secondRow.boundingBox(),
|
||||
firstMenu.boundingBox(),
|
||||
]);
|
||||
if (!firstRowBounds || !secondRowBounds || !firstMenuBounds) {
|
||||
throw new Error("Expected visible pinned touch row geometry");
|
||||
}
|
||||
expect(firstRowBounds.height).toBeGreaterThanOrEqual(44);
|
||||
expect(secondRowBounds.height).toBeGreaterThanOrEqual(44);
|
||||
expect(firstMenuBounds.y).toBeGreaterThanOrEqual(firstRowBounds.y);
|
||||
expect(firstMenuBounds.y + firstMenuBounds.height).toBeLessThanOrEqual(
|
||||
firstRowBounds.y + firstRowBounds.height,
|
||||
);
|
||||
const boundaryTarget = await page.evaluate(
|
||||
({ x, y }) =>
|
||||
document
|
||||
.elementFromPoint(x, y)
|
||||
?.closest<HTMLElement>(".sidebar-recent-session")
|
||||
?.getAttribute("data-session-key") ?? null,
|
||||
{
|
||||
x: firstMenuBounds.x + firstMenuBounds.width / 2,
|
||||
y: secondRowBounds.y + 1,
|
||||
},
|
||||
);
|
||||
expect(boundaryTarget).toBe("agent:main:pinned-second");
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not widen desktop session text or dim trailing state under hover actions", async () => {
|
||||
it("does not widen desktop session text when hover actions appear beside trailing state", async () => {
|
||||
const context = await suite.browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
@@ -816,20 +476,6 @@ suite.define(() => {
|
||||
.toBe(true);
|
||||
await expect.poll(() => state.locator(".session-run-spinner").isVisible()).toBe(true);
|
||||
await expect.poll(() => state.locator(".session-unread-dot").count()).toBe(0);
|
||||
const [pullRequestGlyphStyle, actionGlyphStyle] = await Promise.all([
|
||||
state.locator("[data-session-pr-state='open'] svg").evaluate((glyph) => {
|
||||
const style = getComputedStyle(glyph);
|
||||
return { height: style.height, strokeWidth: style.strokeWidth, width: style.width };
|
||||
}),
|
||||
row
|
||||
.locator(".session-action svg")
|
||||
.first()
|
||||
.evaluate((glyph) => {
|
||||
const style = getComputedStyle(glyph);
|
||||
return { height: style.height, strokeWidth: style.strokeWidth, width: style.width };
|
||||
}),
|
||||
]);
|
||||
expect(pullRequestGlyphStyle).toEqual(actionGlyphStyle);
|
||||
const stateLayout = await row.evaluate((element) => {
|
||||
const endcap = element.querySelector<HTMLElement>(
|
||||
".sidebar-recent-session__details-endcap",
|
||||
@@ -868,6 +514,7 @@ suite.define(() => {
|
||||
expect(atom.right).toBeLessThanOrEqual(stateLayout.endcapRight);
|
||||
}
|
||||
const link = row.locator(".sidebar-recent-session__link");
|
||||
const titleRow = row.locator(".sidebar-recent-session__title-row");
|
||||
const pin = row.getByRole("button", { name: "Pin session" });
|
||||
const menu = row.getByRole("button", { name: "Open session menu" });
|
||||
await expect
|
||||
@@ -899,29 +546,24 @@ suite.define(() => {
|
||||
await expect.poll(() => actionOpacity(state)).toBe("1");
|
||||
await expect.poll(() => actionOpacity(pin)).toBe("1");
|
||||
await expect.poll(() => actionOpacity(menu)).toBe("1");
|
||||
await expect
|
||||
.poll(() => titleRow.evaluate((element) => getComputedStyle(element).paddingRight))
|
||||
.toBe("52px");
|
||||
|
||||
const [textBounds, nameBounds, pinBounds, pinGlyphBounds, menuBounds] = await Promise.all([
|
||||
const [textBounds, nameBounds, pinBounds, menuBounds] = await Promise.all([
|
||||
row.locator(".sidebar-recent-session__text").boundingBox(),
|
||||
row.locator(".sidebar-recent-session__name").boundingBox(),
|
||||
pin.boundingBox(),
|
||||
pin.locator("svg").boundingBox(),
|
||||
menu.boundingBox(),
|
||||
]);
|
||||
if (!textBounds || !nameBounds || !pinBounds || !pinGlyphBounds || !menuBounds) {
|
||||
if (!textBounds || !nameBounds || !pinBounds || !menuBounds) {
|
||||
throw new Error("Expected visible combined session action geometry");
|
||||
}
|
||||
expect(textBounds.width).toBeCloseTo(restingTextBounds.width, 1);
|
||||
// A control taller than the title line would paint its hover fill over the
|
||||
// badges that now stay lit directly below it.
|
||||
const detailsBounds = await row.locator(".sidebar-recent-session__details").boundingBox();
|
||||
if (!detailsBounds) {
|
||||
throw new Error("Expected a visible second line");
|
||||
}
|
||||
expect(pinGlyphBounds.y + pinGlyphBounds.height).toBeLessThanOrEqual(detailsBounds.y);
|
||||
// The actions ride the title's midline now, and the title ends before them.
|
||||
expect(
|
||||
Math.abs(nameBounds.y + nameBounds.height / 2 - (pinBounds.y + pinBounds.height / 2)),
|
||||
).toBeLessThanOrEqual(1);
|
||||
expect(nameBounds.y + nameBounds.height / 2).toBeCloseTo(
|
||||
pinBounds.y + pinBounds.height / 2,
|
||||
1,
|
||||
);
|
||||
expect(nameBounds.x + nameBounds.width).toBeLessThanOrEqual(pinBounds.x + 1);
|
||||
expect(pinBounds.x + pinBounds.width).toBeLessThanOrEqual(menuBounds.x);
|
||||
await page.mouse.move(0, 0);
|
||||
@@ -929,6 +571,9 @@ suite.define(() => {
|
||||
await expect.poll(() => actionOpacity(state)).toBe("1");
|
||||
await expect.poll(() => actionOpacity(pin)).toBe("1");
|
||||
await expect.poll(() => actionOpacity(menu)).toBe("1");
|
||||
await expect
|
||||
.poll(() => titleRow.evaluate((element) => getComputedStyle(element).paddingRight))
|
||||
.toBe("52px");
|
||||
|
||||
const [focusedTextBounds, focusedNameBounds, focusedPinBounds, focusedMenuBounds] =
|
||||
await Promise.all([
|
||||
@@ -941,13 +586,10 @@ suite.define(() => {
|
||||
throw new Error("Expected visible focused session action geometry");
|
||||
}
|
||||
expect(focusedTextBounds.width).toBeCloseTo(restingTextBounds.width, 1);
|
||||
expect(
|
||||
Math.abs(
|
||||
focusedNameBounds.y +
|
||||
focusedNameBounds.height / 2 -
|
||||
(focusedPinBounds.y + focusedPinBounds.height / 2),
|
||||
),
|
||||
).toBeLessThanOrEqual(1);
|
||||
expect(focusedNameBounds.y + focusedNameBounds.height / 2).toBeCloseTo(
|
||||
focusedPinBounds.y + focusedPinBounds.height / 2,
|
||||
1,
|
||||
);
|
||||
expect(focusedNameBounds.x + focusedNameBounds.width).toBeLessThanOrEqual(
|
||||
focusedPinBounds.x + 1,
|
||||
);
|
||||
|
||||
@@ -1,112 +1,69 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { startHoverMarquee, stopHoverMarquee } from "./hover-marquee.ts";
|
||||
import { startHoverMarqueeFromEvent, stopHoverMarqueeFromEvent } from "./hover-marquee.ts";
|
||||
|
||||
let pendingFrame: FrameRequestCallback | undefined;
|
||||
function enter(row: HTMLElement) {
|
||||
row.addEventListener("mouseenter", startHoverMarqueeFromEvent, { once: true });
|
||||
row.dispatchEvent(new MouseEvent("mouseenter"));
|
||||
}
|
||||
|
||||
function runPendingFrame(): void {
|
||||
const callback = pendingFrame;
|
||||
pendingFrame = undefined;
|
||||
callback?.(0);
|
||||
function leave(row: HTMLElement) {
|
||||
row.addEventListener("mouseleave", stopHoverMarqueeFromEvent, { once: true });
|
||||
row.dispatchEvent(new MouseEvent("mouseleave"));
|
||||
}
|
||||
|
||||
function buildRow(params: { textWidth: number; labelWidth: number }) {
|
||||
const row = document.createElement("div");
|
||||
const viewport = document.createElement("span");
|
||||
const label = document.createElement("span");
|
||||
label.className = "hover-marquee";
|
||||
label.textContent = "Fix stale iMessage group-allowlist warning copy";
|
||||
viewport.append(label);
|
||||
row.append(viewport);
|
||||
row.append(label);
|
||||
document.body.append(row);
|
||||
Object.defineProperty(label, "clientWidth", { value: params.labelWidth });
|
||||
Object.defineProperty(label, "scrollWidth", { value: params.textWidth });
|
||||
return { row, viewport, label };
|
||||
return { row, label };
|
||||
}
|
||||
|
||||
describe("hover marquee", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
pendingFrame = undefined;
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
|
||||
pendingFrame = callback;
|
||||
return 1;
|
||||
});
|
||||
});
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it("waits before scrolling overflowing labels by the clipped distance", () => {
|
||||
const { row, label } = buildRow({ textWidth: 320, labelWidth: 180 });
|
||||
startHoverMarquee(row);
|
||||
expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe("");
|
||||
runPendingFrame();
|
||||
enter(row);
|
||||
expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe("-140px");
|
||||
expect(label.style.getPropertyValue("--hover-marquee-duration")).toBe("1750ms");
|
||||
vi.advanceTimersByTime(499);
|
||||
expect(label.classList.contains("hover-marquee--scrolling")).toBe(false);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(label.classList.contains("hover-marquee--scrolling")).toBe(true);
|
||||
stopHoverMarquee(row);
|
||||
leave(row);
|
||||
expect(label.classList.contains("hover-marquee--scrolling")).toBe(false);
|
||||
});
|
||||
|
||||
it("cancels the delayed scroll when hover ends early", () => {
|
||||
const { row, label } = buildRow({ textWidth: 320, labelWidth: 180 });
|
||||
startHoverMarquee(row);
|
||||
runPendingFrame();
|
||||
enter(row);
|
||||
vi.advanceTimersByTime(250);
|
||||
stopHoverMarquee(row);
|
||||
leave(row);
|
||||
vi.advanceTimersByTime(250);
|
||||
expect(label.classList.contains("hover-marquee--scrolling")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the original delay when start repeats during hover", () => {
|
||||
const { row, label } = buildRow({ textWidth: 320, labelWidth: 180 });
|
||||
startHoverMarquee(row);
|
||||
runPendingFrame();
|
||||
vi.advanceTimersByTime(250);
|
||||
startHoverMarquee(row);
|
||||
runPendingFrame();
|
||||
vi.advanceTimersByTime(250);
|
||||
expect(label.classList.contains("hover-marquee--scrolling")).toBe(true);
|
||||
});
|
||||
|
||||
it("cancels measurement when hover ends before the next frame", () => {
|
||||
const { row, label } = buildRow({ textWidth: 320, labelWidth: 180 });
|
||||
startHoverMarquee(row);
|
||||
stopHoverMarquee(row);
|
||||
runPendingFrame();
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe("");
|
||||
expect(label.classList.contains("hover-marquee--scrolling")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps short scroll distances readable with a minimum duration", () => {
|
||||
const { row, label } = buildRow({ textWidth: 190, labelWidth: 180 });
|
||||
startHoverMarquee(row);
|
||||
runPendingFrame();
|
||||
enter(row);
|
||||
expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe("-10px");
|
||||
expect(label.style.getPropertyValue("--hover-marquee-duration")).toBe("300ms");
|
||||
});
|
||||
|
||||
it("uses a clipping ancestor's content width", () => {
|
||||
const { row, viewport, label } = buildRow({ textWidth: 190, labelWidth: 220 });
|
||||
viewport.style.overflowX = "hidden";
|
||||
viewport.style.paddingRight = "44px";
|
||||
Object.defineProperty(viewport, "clientWidth", { value: 220 });
|
||||
startHoverMarquee(row);
|
||||
runPendingFrame();
|
||||
expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe("-14px");
|
||||
});
|
||||
|
||||
it("leaves labels that fit untouched", () => {
|
||||
const { row, label } = buildRow({ textWidth: 120, labelWidth: 180 });
|
||||
startHoverMarquee(row);
|
||||
runPendingFrame();
|
||||
enter(row);
|
||||
expect(label.classList.contains("hover-marquee--scrolling")).toBe(false);
|
||||
expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe("");
|
||||
});
|
||||
@@ -114,8 +71,63 @@ describe("hover marquee", () => {
|
||||
it("ignores hosts without a marquee label", () => {
|
||||
const row = document.createElement("div");
|
||||
expect(() => {
|
||||
startHoverMarquee(row);
|
||||
stopHoverMarquee(row);
|
||||
enter(row);
|
||||
leave(row);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("remeasures an active marquee when its available width changes", () => {
|
||||
let resizeCallback: ResizeObserverCallback | undefined;
|
||||
class TestResizeObserver implements ResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeCallback = callback;
|
||||
}
|
||||
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
const callbackObserver: ResizeObserver = {
|
||||
observe() {},
|
||||
unobserve() {},
|
||||
disconnect() {},
|
||||
};
|
||||
vi.stubGlobal("ResizeObserver", TestResizeObserver);
|
||||
let labelWidth = 180;
|
||||
const row = document.createElement("div");
|
||||
row.className = "session-row-host";
|
||||
Object.defineProperty(row, "matches", {
|
||||
value: (selector: string) => selector === ":hover",
|
||||
});
|
||||
const label = document.createElement("span");
|
||||
label.className = "hover-marquee";
|
||||
label.textContent = "Fix stale iMessage group-allowlist warning copy";
|
||||
row.append(label);
|
||||
document.body.append(row);
|
||||
Object.defineProperty(label, "clientWidth", { get: () => labelWidth });
|
||||
Object.defineProperty(label, "scrollWidth", { value: 320 });
|
||||
|
||||
enter(row);
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe("-140px");
|
||||
expect(label.classList.contains("hover-marquee--scrolling")).toBe(true);
|
||||
|
||||
labelWidth = 120;
|
||||
const resizeEntry = {
|
||||
target: label,
|
||||
borderBoxSize: [],
|
||||
contentBoxSize: [],
|
||||
contentRect: label.getBoundingClientRect(),
|
||||
devicePixelContentBoxSize: [],
|
||||
} satisfies ResizeObserverEntry;
|
||||
if (!resizeCallback) {
|
||||
throw new Error("Expected the marquee to observe its label");
|
||||
}
|
||||
resizeCallback([resizeEntry], callbackObserver);
|
||||
|
||||
expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe("-200px");
|
||||
expect(label.classList.contains("hover-marquee--scrolling")).toBe(false);
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(label.classList.contains("hover-marquee--scrolling")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+74
-79
@@ -6,9 +6,8 @@
|
||||
const MARQUEE_SPEED_PX_PER_SEC = 80;
|
||||
const MARQUEE_MIN_DURATION_MS = 300;
|
||||
const MARQUEE_HOVER_DELAY_MS = 500;
|
||||
type PendingMarquee = { frame: number; timer?: number };
|
||||
|
||||
const pendingMarquees = new WeakMap<HTMLElement, PendingMarquee>();
|
||||
const pendingMarquees = new WeakMap<HTMLElement, number>();
|
||||
let marqueeResizeObserver: ResizeObserver | undefined;
|
||||
|
||||
function findMarqueeLabel(host: HTMLElement): HTMLElement | null {
|
||||
return host.classList.contains("hover-marquee")
|
||||
@@ -16,99 +15,83 @@ function findMarqueeLabel(host: HTMLElement): HTMLElement | null {
|
||||
: host.querySelector<HTMLElement>(".hover-marquee");
|
||||
}
|
||||
|
||||
function getMarqueeViewportWidth(label: HTMLElement, host: HTMLElement): number {
|
||||
let width = label.clientWidth;
|
||||
for (
|
||||
let ancestor = label.parentElement;
|
||||
ancestor && ancestor !== host;
|
||||
ancestor = ancestor.parentElement
|
||||
) {
|
||||
const style = getComputedStyle(ancestor);
|
||||
if (style.overflowX !== "hidden" && style.overflowX !== "clip") {
|
||||
continue;
|
||||
}
|
||||
const padding =
|
||||
(Number.parseFloat(style.paddingLeft) || 0) + (Number.parseFloat(style.paddingRight) || 0);
|
||||
width = Math.min(width, Math.max(0, ancestor.clientWidth - padding));
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
function clearPendingMarquee(label: HTMLElement): void {
|
||||
const pending = pendingMarquees.get(label);
|
||||
if (pending === undefined) {
|
||||
return;
|
||||
}
|
||||
window.cancelAnimationFrame(pending.frame);
|
||||
if (pending.timer !== undefined) {
|
||||
window.clearTimeout(pending.timer);
|
||||
}
|
||||
window.clearTimeout(pending);
|
||||
pendingMarquees.delete(label);
|
||||
}
|
||||
|
||||
export function startHoverMarquee(host: HTMLElement): void {
|
||||
const label = findMarqueeLabel(host);
|
||||
if (
|
||||
!label ||
|
||||
label.classList.contains("hover-marquee--scrolling") ||
|
||||
pendingMarquees.has(label)
|
||||
) {
|
||||
return;
|
||||
function observeMarquee(label: HTMLElement): void {
|
||||
if (!marqueeResizeObserver && typeof ResizeObserver === "function") {
|
||||
// Row endcaps can resize an adopted title without replacing its label.
|
||||
// Remeasure the active animation so presence and badge changes cannot clip it.
|
||||
marqueeResizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!(entry.target instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
const resizedLabel = entry.target;
|
||||
const host = resizedLabel.closest<HTMLElement>(".session-row-host");
|
||||
if (!host?.matches(":hover")) {
|
||||
marqueeResizeObserver?.unobserve(resizedLabel);
|
||||
continue;
|
||||
}
|
||||
clearPendingMarquee(resizedLabel);
|
||||
resizedLabel.classList.remove("hover-marquee--scrolling");
|
||||
startHoverMarquee(host);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Catalog renders can reconnect refs while the pointer stays on the row.
|
||||
// Preserve this label's delay; keyed label replacements get fresh state.
|
||||
// Mouseenter fires before hover-only actions finish affecting layout. Measure
|
||||
// on the next frame so the marquee sees the width the user actually sees.
|
||||
const pending: PendingMarquee = {
|
||||
frame: window.requestAnimationFrame(() => {
|
||||
if (pendingMarquees.get(label) !== pending) {
|
||||
return;
|
||||
}
|
||||
// A negative mid-transition indent (re-hover while snapping back) shrinks
|
||||
// scrollWidth; add it back when calculating the clipped distance.
|
||||
const indent = Number.parseFloat(getComputedStyle(label).textIndent) || 0;
|
||||
const shift = label.scrollWidth - indent - getMarqueeViewportWidth(label, host);
|
||||
if (shift <= 1) {
|
||||
pendingMarquees.delete(label);
|
||||
return;
|
||||
}
|
||||
const durationMs = Math.max(
|
||||
MARQUEE_MIN_DURATION_MS,
|
||||
Math.round((shift / MARQUEE_SPEED_PX_PER_SEC) * 1000),
|
||||
);
|
||||
label.style.setProperty("--hover-marquee-shift", `${-shift}px`);
|
||||
label.style.setProperty("--hover-marquee-duration", `${durationMs}ms`);
|
||||
// Keep quick pointer passes quiet; leaving before the timer fires cancels it.
|
||||
pending.timer = window.setTimeout(() => {
|
||||
pendingMarquees.delete(label);
|
||||
label.classList.add("hover-marquee--scrolling");
|
||||
}, MARQUEE_HOVER_DELAY_MS);
|
||||
}),
|
||||
};
|
||||
pendingMarquees.set(label, pending);
|
||||
marqueeResizeObserver?.observe(label);
|
||||
}
|
||||
|
||||
export function stopHoverMarquee(host: HTMLElement): void {
|
||||
function startHoverMarquee(host: HTMLElement): void {
|
||||
const label = findMarqueeLabel(host);
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
observeMarquee(label);
|
||||
if (label.classList.contains("hover-marquee--scrolling")) {
|
||||
return;
|
||||
}
|
||||
clearPendingMarquee(label);
|
||||
// Measure at hover time: labels resize with the sidebar and with hover-only
|
||||
// row actions, so a cached width would drift. A negative mid-transition
|
||||
// indent (re-hover while snapping back) shrinks scrollWidth; add it back.
|
||||
const indent = Number.parseFloat(getComputedStyle(label).textIndent) || 0;
|
||||
const shift = label.scrollWidth - indent - label.clientWidth;
|
||||
if (shift <= 1) {
|
||||
label.style.removeProperty("--hover-marquee-shift");
|
||||
label.style.removeProperty("--hover-marquee-duration");
|
||||
return;
|
||||
}
|
||||
const durationMs = Math.max(
|
||||
MARQUEE_MIN_DURATION_MS,
|
||||
Math.round((shift / MARQUEE_SPEED_PX_PER_SEC) * 1000),
|
||||
);
|
||||
label.style.setProperty("--hover-marquee-shift", `${-shift}px`);
|
||||
label.style.setProperty("--hover-marquee-duration", `${durationMs}ms`);
|
||||
// Keep quick pointer passes quiet; leaving before the timer fires cancels it.
|
||||
pendingMarquees.set(
|
||||
label,
|
||||
window.setTimeout(() => {
|
||||
pendingMarquees.delete(label);
|
||||
label.classList.add("hover-marquee--scrolling");
|
||||
}, MARQUEE_HOVER_DELAY_MS),
|
||||
);
|
||||
}
|
||||
|
||||
function stopHoverMarquee(host: HTMLElement): void {
|
||||
const label = findMarqueeLabel(host);
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
clearPendingMarquee(label);
|
||||
label.classList.remove("hover-marquee--scrolling");
|
||||
}
|
||||
|
||||
export function restartHoverMarqueeIfHovered(element: Element | undefined): void {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
const host = element.isConnected
|
||||
? element.closest<HTMLElement>(".session-row-host")
|
||||
: undefined;
|
||||
if (host?.matches(":hover")) {
|
||||
startHoverMarquee(host);
|
||||
}
|
||||
});
|
||||
marqueeResizeObserver?.unobserve(label);
|
||||
}
|
||||
|
||||
export function startHoverMarqueeFromEvent(event: Event): void {
|
||||
@@ -122,3 +105,15 @@ export function stopHoverMarqueeFromEvent(event: Event): void {
|
||||
stopHoverMarquee(event.currentTarget);
|
||||
}
|
||||
}
|
||||
|
||||
export function restartHoverMarqueeIfHovered(element: Element | undefined): void {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
const host = element.isConnected ? element.closest<HTMLElement>(".session-row-host") : null;
|
||||
if (host?.matches(":hover")) {
|
||||
startHoverMarquee(host);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,7 +27,10 @@ describe("groupCatalogSessionsByProject", () => {
|
||||
session("b-2", "/work/bravo"),
|
||||
]);
|
||||
|
||||
expect(result.groups.map((group) => group.key)).toEqual(["/work/bravo", "/work/alpha"]);
|
||||
expect(result.groups.map((group) => group.key)).toEqual([
|
||||
"project:/work/bravo",
|
||||
"project:/work/alpha",
|
||||
]);
|
||||
expect(result.groups.map((group) => group.label)).toEqual(["bravo", "alpha"]);
|
||||
expect(result.groups[0]?.sessions.map((item) => item.threadId)).toEqual(["b-1", "b-2"]);
|
||||
});
|
||||
@@ -39,8 +42,18 @@ describe("groupCatalogSessionsByProject", () => {
|
||||
]);
|
||||
|
||||
expect(result.groups).toMatchObject([
|
||||
{ key: "custom:Release", label: "Release", sessions: [{ threadId: "grouped" }] },
|
||||
{ key: "/work/openclaw", label: "openclaw", sessions: [{ threadId: "project" }] },
|
||||
{
|
||||
key: "custom:Release",
|
||||
legacySectionKey: "custom:Release",
|
||||
label: "Release",
|
||||
sessions: [{ threadId: "grouped" }],
|
||||
},
|
||||
{
|
||||
key: "project:/work/openclaw",
|
||||
legacySectionKey: "/work/openclaw",
|
||||
label: "openclaw",
|
||||
sessions: [{ threadId: "project" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -50,7 +63,26 @@ describe("groupCatalogSessionsByProject", () => {
|
||||
{ ...session("grouped", "/work/openclaw"), customGroup: "Release" },
|
||||
]);
|
||||
|
||||
expect(result.groups.map((group) => group.key)).toEqual(["custom:Release", "/work/openclaw"]);
|
||||
expect(result.groups.map((group) => group.key)).toEqual([
|
||||
"custom:Release",
|
||||
"project:/work/openclaw",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps custom groups separate from project paths with the same key text", () => {
|
||||
const result = groupCatalogSessionsByProject([
|
||||
{ ...session("grouped"), customGroup: "repo" },
|
||||
session("project", "custom:repo"),
|
||||
]);
|
||||
|
||||
expect(result.groups).toMatchObject([
|
||||
{ key: "custom:repo", sessions: [{ threadId: "grouped" }] },
|
||||
{
|
||||
key: "project:custom:repo",
|
||||
legacySectionKey: "custom:repo",
|
||||
sessions: [{ threadId: "project" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -64,7 +96,7 @@ describe("groupCatalogSessionsByProject", () => {
|
||||
]);
|
||||
|
||||
expect(result.groups).toHaveLength(1);
|
||||
expect(result.groups[0]?.key).toBe(expectedProject);
|
||||
expect(result.groups[0]?.key).toBe(`project:${expectedProject}`);
|
||||
expect(result.groups[0]?.sessions.map((item) => item.threadId)).toEqual(["direct", "worktree"]);
|
||||
});
|
||||
|
||||
@@ -81,13 +113,14 @@ describe("groupCatalogSessionsByProject", () => {
|
||||
it.each([
|
||||
[" /Users/dev/openclaw/// ", "/Users/dev/openclaw", "openclaw"],
|
||||
["C:\\Users\\dev\\openclaw\\", "C:\\Users\\dev\\openclaw", "openclaw"],
|
||||
])("normalizes %s to key %s with label %s", (cwd, expectedKey, expectedLabel) => {
|
||||
])("normalizes %s to project %s with label %s", (cwd, expectedPath, expectedLabel) => {
|
||||
const result = groupCatalogSessionsByProject([session("one", cwd)]);
|
||||
|
||||
expect(result.groups[0]).toMatchObject({
|
||||
key: expectedKey,
|
||||
key: `project:${expectedPath}`,
|
||||
legacySectionKey: expectedPath,
|
||||
label: expectedLabel,
|
||||
title: expectedKey,
|
||||
title: expectedPath,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -114,7 +147,11 @@ describe("groupCatalogSessionsByPerson", () => {
|
||||
{ ...session("one"), createdActor: { type: "human", id: "profile-ada", label: " " } },
|
||||
]);
|
||||
|
||||
expect(result.groups[0]).toMatchObject({ key: "person:profile-ada", label: "profile-ada" });
|
||||
expect(result.groups[0]).toMatchObject({
|
||||
key: "person:profile-ada",
|
||||
legacySectionKey: "person:profile-ada",
|
||||
label: "profile-ada",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves unattributed sessions in the flat ungrouped tail", () => {
|
||||
|
||||
@@ -7,7 +7,11 @@ export function normalizeCatalogProjectGrouping(raw: unknown): CatalogProjectGro
|
||||
}
|
||||
|
||||
type CatalogProjectGroup = {
|
||||
kind: "custom" | "project" | "person";
|
||||
key: string;
|
||||
// Collapse ids predate the group-kind namespace. Read the old suffix until
|
||||
// the next toggle migrates that section to its canonical id.
|
||||
legacySectionKey?: string;
|
||||
label: string;
|
||||
title: string;
|
||||
sessions: SessionCatalogSession[];
|
||||
@@ -22,22 +26,25 @@ export function groupCatalogSessionsByProject(sessions: readonly SessionCatalogS
|
||||
// order depend on the roster's sort.
|
||||
const customGroups: CatalogProjectGroup[] = [];
|
||||
const projectGroups: CatalogProjectGroup[] = [];
|
||||
const groupsByPath = new Map<string, CatalogProjectGroup>();
|
||||
const customGroupsByName = new Map<string, CatalogProjectGroup>();
|
||||
const projectGroupsByPath = new Map<string, CatalogProjectGroup>();
|
||||
const ungrouped: SessionCatalogSession[] = [];
|
||||
|
||||
for (const session of sessions) {
|
||||
const customGroup = session.customGroup?.trim();
|
||||
if (customGroup) {
|
||||
const key = `custom:${customGroup}`;
|
||||
let group = groupsByPath.get(key);
|
||||
let group = customGroupsByName.get(customGroup);
|
||||
if (!group) {
|
||||
group = {
|
||||
kind: "custom",
|
||||
key,
|
||||
legacySectionKey: key,
|
||||
label: customGroup,
|
||||
title: `Custom group: ${customGroup}`,
|
||||
sessions: [],
|
||||
};
|
||||
groupsByPath.set(key, group);
|
||||
customGroupsByName.set(customGroup, group);
|
||||
customGroups.push(group);
|
||||
}
|
||||
group.sessions.push(session);
|
||||
@@ -58,15 +65,17 @@ export function groupCatalogSessionsByProject(sessions: readonly SessionCatalogS
|
||||
ungrouped.push(session);
|
||||
continue;
|
||||
}
|
||||
let group = groupsByPath.get(projectPath);
|
||||
let group = projectGroupsByPath.get(projectPath);
|
||||
if (!group) {
|
||||
group = {
|
||||
key: projectPath,
|
||||
kind: "project",
|
||||
key: `project:${projectPath}`,
|
||||
legacySectionKey: projectPath,
|
||||
label: projectPath.split(/[\\/]/).at(-1) || projectPath,
|
||||
title: projectPath,
|
||||
sessions: [],
|
||||
};
|
||||
groupsByPath.set(projectPath, group);
|
||||
projectGroupsByPath.set(projectPath, group);
|
||||
projectGroups.push(group);
|
||||
}
|
||||
group.sessions.push(session);
|
||||
@@ -95,7 +104,14 @@ export function groupCatalogSessionsByPerson(sessions: readonly SessionCatalogSe
|
||||
let group = groupsById.get(key);
|
||||
if (!group) {
|
||||
const label = actor.label?.trim() || actor.id;
|
||||
group = { key, label, title: `Created by ${label}`, sessions: [] };
|
||||
group = {
|
||||
kind: "person",
|
||||
key,
|
||||
legacySectionKey: key,
|
||||
label,
|
||||
title: `Created by ${label}`,
|
||||
sessions: [],
|
||||
};
|
||||
groupsById.set(key, group);
|
||||
}
|
||||
group.sessions.push(session);
|
||||
|
||||
@@ -2119,6 +2119,42 @@ openclaw-chat-session-rail {
|
||||
}
|
||||
}
|
||||
|
||||
.chat-session-rail__composer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 10px 11px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--secondary) 40%, transparent);
|
||||
}
|
||||
|
||||
.chat-session-rail__prompt {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.chat-session-rail__input {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.chat-session-rail__input:focus {
|
||||
border-color: var(--border-strong);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chat-session-rail__input::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* The docked form: a real column in the pane's flex row. It claims layout space
|
||||
instead of painting over the thread, so nothing here may float, shadow, or
|
||||
blur — those cues would read as an overlay again. */
|
||||
@@ -5627,30 +5663,15 @@ td.data-table-key-col {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* ── Session row primitives ──
|
||||
Shared by the sidebar recents list and the chat session picker. Rows read
|
||||
as plain text lines: attribute badges follow the title, and the trailing
|
||||
aside stacks transient state, relative time, and management actions in one
|
||||
grid cell, so hovering swaps state for actions without any layout shift. */
|
||||
/* Session row primitives shared by sidebar recents and the chat session picker.
|
||||
Reserve hover-action width without moving the row or hiding trailing state. */
|
||||
.session-row-host {
|
||||
/* One size and one stroke for every glyph the row draws. Seven different
|
||||
values used to land on the same line, which read as broken alignment.
|
||||
The row-scoped SVG rule below leaves other surfaces at their own sizes. */
|
||||
/* One grid for everything in the trailing column. The 24px action target sets
|
||||
the desktop pitch; 14px endcap glyphs use the remaining 10px as their gap
|
||||
so both lines share one trailing axis. */
|
||||
--row-action-size: 24px;
|
||||
--row-glyph-gap: 10px;
|
||||
|
||||
/* What the title gives up so the actions have somewhere to land: both boxes
|
||||
plus a hair of clearance from the ellipsis. */
|
||||
--row-actions-reserve: calc(var(--row-action-size) * 2 + 3px);
|
||||
|
||||
--session-row-actions-reserve: 52px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.session-row-host[data-session-row-action-count="1"] {
|
||||
--row-actions-reserve: calc(var(--row-action-size) + 3px);
|
||||
--session-row-actions-reserve: 28px;
|
||||
}
|
||||
|
||||
.session-row-host--draft {
|
||||
@@ -5722,21 +5743,23 @@ td.data-table-key-col {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* One 14px track per glyph puts the 16px PR icon, 11px ring, and 7px unread dot
|
||||
each on the action-icon axis the endcap padding aligns to. Centring them as
|
||||
one box held only for a single glyph: a PR icon ahead of the trailing dot
|
||||
pushed it 3.5px inboard of the dot-only rows. 14px, not the 24px action-button
|
||||
box, which padded a 7px dot with 8.5px a side and broke the 6px rhythm. */
|
||||
.session-row-state {
|
||||
display: inline-grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 14px;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
gap: var(--row-glyph-gap, 6px);
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* The 16px PR glyph inks 1px beyond its 14px track. Inset only that glyph so
|
||||
the other trailing atoms keep their shared axis inside the clipped endcap. */
|
||||
.session-row-state [data-session-pr-state] {
|
||||
position: relative;
|
||||
left: 1px;
|
||||
}
|
||||
|
||||
.sidebar-session-fork-indicator {
|
||||
display: inline-flex;
|
||||
margin-right: 4px;
|
||||
@@ -5757,7 +5780,7 @@ td.data-table-key-col {
|
||||
.session-row-badges {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--row-glyph-gap, 6px);
|
||||
gap: 6px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@@ -5817,14 +5840,16 @@ td.data-table-key-col {
|
||||
|
||||
.session-row-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.session-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--row-action-size);
|
||||
height: var(--row-action-size);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -5832,9 +5857,13 @@ td.data-table-key-col {
|
||||
color: var(--muted);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity var(--duration-fast) ease,
|
||||
background var(--duration-fast) ease,
|
||||
color var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.session-row-host svg {
|
||||
.session-action svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
stroke: currentColor;
|
||||
@@ -5867,21 +5896,20 @@ td.data-table-key-col {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Nothing in the row trades places with the actions any more. Two-line rows put
|
||||
them on the title line; single-line rows reserve their width in the text
|
||||
column, which slides the endcap clear. Fading state here used to prevent an
|
||||
overlap that no longer happens, and it took the unread dot with it the moment
|
||||
the pointer arrived — while the badges beside it stayed, which read as a
|
||||
glitch rather than a deliberate swap. */
|
||||
|
||||
/* Sidebar rows are now the direct management surface after removing the
|
||||
duplicate picker; touch users need pin/menu controls without hover. */
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.sidebar-recent-session.session-row-host {
|
||||
--row-action-size: 44px;
|
||||
--session-row-actions-reserve: 92px;
|
||||
}
|
||||
|
||||
.sidebar-recent-session.session-row-host[data-session-row-action-count="1"] {
|
||||
--session-row-actions-reserve: 48px;
|
||||
}
|
||||
|
||||
.sidebar-recent-session .session-action {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -5898,8 +5926,8 @@ td.data-table-key-col {
|
||||
.session-run-spinner {
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex: 0 0 auto;
|
||||
border: 1.5px solid color-mix(in srgb, var(--run) 28%, transparent);
|
||||
border-top-color: var(--run);
|
||||
|
||||
@@ -1773,8 +1773,6 @@ openclaw-settings-save-indicator:empty {
|
||||
here so gateway threads and native coding catalogs cannot drift apart. */
|
||||
.sidebar-recent-session {
|
||||
--sidebar-child-session-toggle-width: max(34px, calc(36px * var(--control-ui-text-scale)));
|
||||
/* Title line box, shared by the label's line-height and the overlaid actions:
|
||||
both must resolve the same value or the buttons drift off the title. */
|
||||
--sidebar-title-line-height: calc(18px * var(--control-ui-text-scale));
|
||||
|
||||
display: flex;
|
||||
@@ -2079,9 +2077,6 @@ openclaw-settings-save-indicator:empty {
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
/* Actions ride the title line: on a single-line row that is the row centre, and
|
||||
on a two-line row it keeps them off the subtitle. The buttons re-enable their
|
||||
own pointer-events on hover/focus. */
|
||||
.sidebar-recent-session:not(.sidebar-recent-session--child) > .sidebar-recent-session__aside {
|
||||
position: absolute;
|
||||
inset: 50% 2px auto auto;
|
||||
@@ -2089,8 +2084,6 @@ openclaw-settings-save-indicator:empty {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Title line midpoint = the link's top padding plus half the title's line box.
|
||||
Deriving it keeps the buttons on the title when either value changes. */
|
||||
.sidebar-recent-session:not(.sidebar-recent-session--child, .sidebar-recent-session--single-line)
|
||||
> .sidebar-recent-session__link {
|
||||
padding-block: 1px;
|
||||
@@ -2118,21 +2111,17 @@ openclaw-settings-save-indicator:empty {
|
||||
right: calc(var(--sidebar-child-session-toggle-width) + 4px);
|
||||
}
|
||||
|
||||
/* Only the title yields width to the actions. The second line keeps subtitle,
|
||||
badges, state and time visible, which is the point of moving the buttons up. */
|
||||
.sidebar-recent-session:not(
|
||||
.sidebar-recent-session--child,
|
||||
.sidebar-recent-session--single-line
|
||||
):is(:hover, :focus-within)
|
||||
.sidebar-recent-session__title-row {
|
||||
padding-right: var(--row-actions-reserve);
|
||||
padding-right: var(--session-row-actions-reserve);
|
||||
}
|
||||
|
||||
/* Single-line rows put the endcap beside the title, so the reservation has to
|
||||
cover the whole line there rather than the title alone. */
|
||||
.sidebar-recent-session--single-line:not(.sidebar-recent-session--child):is(:hover, :focus-within)
|
||||
.sidebar-recent-session__text {
|
||||
padding-right: var(--row-actions-reserve);
|
||||
padding-right: var(--session-row-actions-reserve);
|
||||
}
|
||||
|
||||
.nav-collapse-toggle__icon {
|
||||
@@ -2987,10 +2976,8 @@ wa-dropdown-item.session-menu__item::part(submenu-icon) {
|
||||
inset: 50% 2px auto auto;
|
||||
}
|
||||
|
||||
/* Always-visible 44px controls span a two-line row, so reserve their width
|
||||
from the whole text column rather than letting them cover the endcap. */
|
||||
.sidebar-recent-session:not(.sidebar-recent-session--child) .sidebar-recent-session__text {
|
||||
padding-right: var(--row-actions-reserve);
|
||||
padding-right: var(--session-row-actions-reserve);
|
||||
}
|
||||
|
||||
.sidebar-recent-session:not(.sidebar-recent-session--child) .sidebar-recent-session__title-row {
|
||||
@@ -4361,24 +4348,17 @@ html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native-
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Action reservations on this viewport and the single-line text column are
|
||||
deliberately not transitioned: the marquee measures their clipping width
|
||||
on the next frame, so an animated width would make it scroll short. */
|
||||
.sidebar-recent-session__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Desktop action hitboxes belong entirely to the title line. Grow that line to
|
||||
the 24px target and reclaim the space from the link padding above. */
|
||||
.sidebar-recent-session:not(.sidebar-recent-session--child, .sidebar-recent-session--single-line)
|
||||
.sidebar-recent-session__title-row {
|
||||
height: max(24px, var(--sidebar-title-line-height));
|
||||
}
|
||||
|
||||
/* Single-line rows lay the text column out in a row, so the title box has to
|
||||
absorb the shrink there the way the bare label used to. */
|
||||
.sidebar-recent-session--single-line .sidebar-recent-session__title-row {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
@@ -4423,17 +4403,13 @@ html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native-
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* The endcap shares the trailing column with the action icons on the line
|
||||
above, and those sit inset inside their hover targets, so it borrows the same
|
||||
inset rather than running flush to the row edge. */
|
||||
.sidebar-recent-session__details-endcap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--row-glyph-gap, 6px);
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
height: 18px;
|
||||
min-width: 0;
|
||||
padding-right: calc((var(--row-action-size) - 14px) / 2);
|
||||
overflow: hidden;
|
||||
flex: 0 1 auto;
|
||||
margin-left: auto;
|
||||
|
||||
@@ -4,6 +4,183 @@ import { createGateway, createSessions, mountSidebar } from "../app-sidebar.ts";
|
||||
import "../../components/app-sidebar.ts";
|
||||
|
||||
describe("AppSidebar project session activity", () => {
|
||||
it("preserves collapsed project sections stored by earlier versions", async () => {
|
||||
localStorage.setItem(
|
||||
"openclaw:sidebar:sessions:collapsed-sections",
|
||||
JSON.stringify(["catalog-project:codex:gateway:local:custom:repo"]),
|
||||
);
|
||||
const gateway = createGateway({} as GatewayBrowserClient);
|
||||
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
|
||||
sidebar.sessionData.sessionCatalogs = [
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
capabilities: { continueSession: true, archive: true },
|
||||
hosts: [
|
||||
{
|
||||
hostId: "gateway:local",
|
||||
label: "Local Codex",
|
||||
kind: "gateway",
|
||||
connected: true,
|
||||
sessions: [
|
||||
{
|
||||
threadId: "custom-group-thread",
|
||||
name: "Custom group session",
|
||||
customGroup: "repo",
|
||||
status: "idle",
|
||||
archived: false,
|
||||
canContinue: true,
|
||||
canArchive: true,
|
||||
},
|
||||
{
|
||||
threadId: "legacy-project-thread",
|
||||
name: "Legacy collapsed project",
|
||||
cwd: "custom:repo",
|
||||
status: "idle",
|
||||
archived: false,
|
||||
canContinue: true,
|
||||
canArchive: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
sidebar.sessionData.requestSessionDataUpdate();
|
||||
await sidebar.updateComplete;
|
||||
|
||||
const customGroup = sidebar.querySelector<HTMLButtonElement>(
|
||||
'[data-session-catalog-project="custom:repo"]',
|
||||
);
|
||||
const project = sidebar.querySelector<HTMLButtonElement>(
|
||||
'[data-session-catalog-project="project:custom:repo"]',
|
||||
);
|
||||
expect(customGroup?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(project?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(sidebar.querySelector('[data-session-key*="custom-group-thread"]')).toBeNull();
|
||||
expect(sidebar.querySelector('[data-session-key*="legacy-project-thread"]')).toBeNull();
|
||||
|
||||
project?.click();
|
||||
await sidebar.updateComplete;
|
||||
expect(customGroup?.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem("openclaw:sidebar:sessions:collapsed-sections") ?? "[]"),
|
||||
).not.toContain("catalog-project:codex:gateway:local:custom:repo");
|
||||
|
||||
project?.click();
|
||||
await sidebar.updateComplete;
|
||||
customGroup?.click();
|
||||
await sidebar.updateComplete;
|
||||
expect(project?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(customGroup?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem("openclaw:sidebar:sessions:collapsed-sections") ?? "[]"),
|
||||
).toEqual([
|
||||
"catalog-project:codex:gateway:local:project:custom:repo",
|
||||
"catalog-custom:codex:gateway:local:custom:repo",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves and migrates collapsed person sections stored by earlier versions", async () => {
|
||||
localStorage.setItem("openclaw:sidebar:sessions:catalog-grouping", "person");
|
||||
const legacySectionId = "catalog-project:codex:gateway:local:person:profile-ada";
|
||||
localStorage.setItem(
|
||||
"openclaw:sidebar:sessions:collapsed-sections",
|
||||
JSON.stringify([legacySectionId]),
|
||||
);
|
||||
const gateway = createGateway({} as GatewayBrowserClient);
|
||||
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
|
||||
sidebar.sessionData.sessionCatalogs = [
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
capabilities: { continueSession: true, archive: true },
|
||||
hosts: [
|
||||
{
|
||||
hostId: "gateway:local",
|
||||
label: "Local Codex",
|
||||
kind: "gateway",
|
||||
connected: true,
|
||||
sessions: [
|
||||
{
|
||||
threadId: "person-thread",
|
||||
name: "Ada's session",
|
||||
createdActor: { type: "human", id: "profile-ada", label: "Ada" },
|
||||
status: "idle",
|
||||
archived: false,
|
||||
canContinue: true,
|
||||
canArchive: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
sidebar.sessionData.requestSessionDataUpdate();
|
||||
await sidebar.updateComplete;
|
||||
|
||||
const person = sidebar.querySelector<HTMLButtonElement>(
|
||||
'[data-session-catalog-project="person:profile-ada"]',
|
||||
);
|
||||
expect(person?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(sidebar.querySelector('[data-session-key*="person-thread"]')).toBeNull();
|
||||
|
||||
person?.click();
|
||||
await sidebar.updateComplete;
|
||||
expect(person?.getAttribute("aria-expanded")).toBe("true");
|
||||
person?.click();
|
||||
await sidebar.updateComplete;
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem("openclaw:sidebar:sessions:collapsed-sections") ?? "[]"),
|
||||
).toEqual(["catalog-person:codex:gateway:local:person:profile-ada"]);
|
||||
});
|
||||
|
||||
it("preserves catalog menu focus when project groups reorder", async () => {
|
||||
const gateway = createGateway({} as GatewayBrowserClient);
|
||||
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
|
||||
const sessions = [
|
||||
{ threadId: "thread-a", name: "Project A", cwd: "/work/a" },
|
||||
{ threadId: "thread-b", name: "Project B", cwd: "/work/b" },
|
||||
];
|
||||
const setCatalog = async (orderedSessions: typeof sessions) => {
|
||||
sidebar.sessionData.sessionCatalogs = [
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
capabilities: { continueSession: true, archive: true },
|
||||
hosts: [
|
||||
{
|
||||
hostId: "gateway:local",
|
||||
label: "Local Codex",
|
||||
kind: "gateway",
|
||||
connected: true,
|
||||
sessions: orderedSessions.map((session) => ({
|
||||
...session,
|
||||
status: "idle" as const,
|
||||
archived: false,
|
||||
canContinue: true,
|
||||
canArchive: true,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
sidebar.sessionData.requestSessionDataUpdate();
|
||||
await sidebar.updateComplete;
|
||||
};
|
||||
await setCatalog(sessions);
|
||||
|
||||
const menu = sidebar.querySelector<HTMLButtonElement>(
|
||||
'[data-session-key*="thread-a"] [data-catalog-session-menu]',
|
||||
);
|
||||
menu?.focus();
|
||||
expect(document.activeElement).toBe(menu);
|
||||
|
||||
await setCatalog(sessions.toReversed());
|
||||
|
||||
expect(document.activeElement).toBe(menu);
|
||||
});
|
||||
|
||||
it("shows thread-style activity indicators", async () => {
|
||||
const gateway = createGateway({} as GatewayBrowserClient);
|
||||
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
|
||||
@@ -53,7 +230,9 @@ describe("AppSidebar project session activity", () => {
|
||||
sidebar.sessionData.requestSessionDataUpdate();
|
||||
await sidebar.updateComplete;
|
||||
|
||||
const project = sidebar.querySelector('[data-session-catalog-project="/work/openclaw"]');
|
||||
const project = sidebar.querySelector(
|
||||
'[data-session-catalog-project="project:/work/openclaw"]',
|
||||
);
|
||||
const active = sidebar.querySelector('[data-session-key*="active-thread"]');
|
||||
const idle = sidebar.querySelector('[data-session-key*="idle-thread"]');
|
||||
const loose = sidebar.querySelector('[data-session-key*="loose-thread"]');
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import {
|
||||
catalogPage,
|
||||
createGateway,
|
||||
createSessions,
|
||||
createSessionsHarness,
|
||||
mountSidebar,
|
||||
} from "../app-sidebar.ts";
|
||||
import "../../components/app-sidebar.ts";
|
||||
|
||||
describe("AppSidebar session catalog row identity", () => {
|
||||
it("does not carry marquee state across material updates or replacements", async () => {
|
||||
const gateway = createGateway({} as GatewayBrowserClient);
|
||||
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
|
||||
sidebar.sessionData.sessionCatalogs = catalogPage([
|
||||
{ threadId: "thread-first", name: "First catalog session" },
|
||||
{ threadId: "thread-second", name: "Second catalog session" },
|
||||
]).catalogs;
|
||||
sidebar.sessionData.requestSessionDataUpdate();
|
||||
await sidebar.updateComplete;
|
||||
|
||||
const firstRow = sidebar.querySelector<HTMLElement>(
|
||||
'[data-session-section="catalog:codex"] [data-session-key$=":thread-first"]',
|
||||
);
|
||||
const firstLabel = firstRow?.querySelector<HTMLElement>(".hover-marquee");
|
||||
const firstMenu = firstRow?.querySelector<HTMLButtonElement>("[data-catalog-session-menu]");
|
||||
firstLabel?.classList.add("hover-marquee--scrolling");
|
||||
firstLabel?.style.setProperty("--hover-marquee-shift", "-80px");
|
||||
firstMenu?.focus();
|
||||
expect(document.activeElement).toBe(firstMenu);
|
||||
|
||||
sidebar.sessionData.sessionCatalogs = catalogPage([
|
||||
{ threadId: "thread-second", name: "Second catalog session" },
|
||||
{ threadId: "thread-first", name: "Renamed catalog session" },
|
||||
]).catalogs;
|
||||
sidebar.sessionData.requestSessionDataUpdate();
|
||||
await sidebar.updateComplete;
|
||||
|
||||
const updatedRow = sidebar.querySelector<HTMLElement>(
|
||||
'[data-session-section="catalog:codex"] [data-session-key$=":thread-first"]',
|
||||
);
|
||||
const updatedLabel = updatedRow?.querySelector<HTMLElement>(".hover-marquee");
|
||||
const updatedMenu = updatedRow?.querySelector<HTMLButtonElement>("[data-catalog-session-menu]");
|
||||
expect(updatedRow).toBe(firstRow);
|
||||
expect(updatedLabel).not.toBe(firstLabel);
|
||||
expect(updatedMenu).toBe(firstMenu);
|
||||
expect(document.activeElement).toBe(updatedMenu);
|
||||
expect(updatedLabel?.classList.contains("hover-marquee--scrolling")).toBe(false);
|
||||
expect(updatedLabel?.style.getPropertyValue("--hover-marquee-shift")).toBe("");
|
||||
updatedLabel?.classList.add("hover-marquee--scrolling");
|
||||
updatedLabel?.style.setProperty("--hover-marquee-shift", "-60px");
|
||||
|
||||
sidebar.sessionData.sessionCatalogs = catalogPage([
|
||||
{ threadId: "thread-third", name: "Replacement catalog session" },
|
||||
]).catalogs;
|
||||
sidebar.sessionData.requestSessionDataUpdate();
|
||||
await sidebar.updateComplete;
|
||||
|
||||
const replacementRow = sidebar.querySelector<HTMLElement>(
|
||||
'[data-session-section="catalog:codex"] .sidebar-recent-session',
|
||||
);
|
||||
const replacementLabel = replacementRow?.querySelector<HTMLElement>(".hover-marquee");
|
||||
expect(replacementRow).not.toBe(updatedRow);
|
||||
expect(replacementLabel?.classList.contains("hover-marquee--scrolling")).toBe(false);
|
||||
expect(replacementLabel?.style.getPropertyValue("--hover-marquee-shift")).toBe("");
|
||||
});
|
||||
|
||||
it("restores menu focus when a catalog thread is adopted", async () => {
|
||||
const adoptedKey = "agent:main:adopted";
|
||||
const gateway = createGateway({} as GatewayBrowserClient);
|
||||
const { sidebar } = await mountSidebar(
|
||||
gateway,
|
||||
createSessions("main", ["agent:main:main", adoptedKey]),
|
||||
);
|
||||
sidebar.sessionData.sessionCatalogs = catalogPage([
|
||||
{ threadId: "thread-adopted", name: "Catalog session" },
|
||||
]).catalogs;
|
||||
sidebar.sessionData.requestSessionDataUpdate();
|
||||
await sidebar.updateComplete;
|
||||
|
||||
const catalogMenu = sidebar.querySelector<HTMLButtonElement>("[data-catalog-session-menu]");
|
||||
catalogMenu?.focus();
|
||||
expect(document.activeElement).toBe(catalogMenu);
|
||||
|
||||
sidebar.sessionData.sessionCatalogs = catalogPage([
|
||||
{ threadId: "thread-adopted", name: "Catalog session", sessionKey: adoptedKey },
|
||||
]).catalogs;
|
||||
sidebar.sessionData.requestSessionDataUpdate();
|
||||
await sidebar.updateComplete;
|
||||
|
||||
const adoptedMenu = sidebar.querySelector<HTMLButtonElement>(
|
||||
`[data-session-key="${adoptedKey}"] [data-session-menu]`,
|
||||
);
|
||||
expect(document.activeElement).toBe(adoptedMenu);
|
||||
});
|
||||
|
||||
it("resets an adopted marquee when its live pull request appears", async () => {
|
||||
const adoptedKey = "agent:main:adopted-pull-request";
|
||||
const gateway = createGateway({} as GatewayBrowserClient);
|
||||
const sessions = createSessionsHarness("main", ["agent:main:main", adoptedKey]);
|
||||
const { sidebar } = await mountSidebar(gateway, sessions.sessions);
|
||||
sidebar.sessionData.sessionCatalogs = catalogPage([
|
||||
{
|
||||
threadId: "thread-adopted-pull-request",
|
||||
name: "Adopted catalog session",
|
||||
sessionKey: adoptedKey,
|
||||
},
|
||||
]).catalogs;
|
||||
sidebar.sessionData.requestSessionDataUpdate();
|
||||
await sidebar.updateComplete;
|
||||
|
||||
const row = sidebar.querySelector<HTMLElement>(`[data-session-key="${adoptedKey}"]`);
|
||||
const label = row?.querySelector<HTMLElement>(".hover-marquee");
|
||||
label?.classList.add("hover-marquee--scrolling");
|
||||
label?.style.setProperty("--hover-marquee-shift", "-80px");
|
||||
|
||||
sessions.sessions.setPullRequestSummary(adoptedKey, { numbers: [125820], state: "open" });
|
||||
await sidebar.updateComplete;
|
||||
|
||||
const updatedRow = sidebar.querySelector<HTMLElement>(`[data-session-key="${adoptedKey}"]`);
|
||||
const updatedLabel = updatedRow?.querySelector<HTMLElement>(".hover-marquee");
|
||||
expect(updatedRow).toBe(row);
|
||||
expect(updatedLabel).not.toBe(label);
|
||||
expect(updatedLabel?.classList.contains("hover-marquee--scrolling")).toBe(false);
|
||||
expect(updatedLabel?.style.getPropertyValue("--hover-marquee-shift")).toBe("");
|
||||
expect(updatedRow?.querySelector(".session-row-badge--pull-request")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("restores menu focus when an adopted catalog thread loses its session", async () => {
|
||||
const adoptedKey = "agent:main:released";
|
||||
const gateway = createGateway({} as GatewayBrowserClient);
|
||||
const { sidebar } = await mountSidebar(
|
||||
gateway,
|
||||
createSessions("main", ["agent:main:main", adoptedKey]),
|
||||
);
|
||||
sidebar.sessionData.sessionCatalogs = catalogPage([
|
||||
{
|
||||
threadId: "thread-released",
|
||||
name: "Adopted catalog session",
|
||||
sessionKey: adoptedKey,
|
||||
},
|
||||
]).catalogs;
|
||||
sidebar.sessionData.requestSessionDataUpdate();
|
||||
await sidebar.updateComplete;
|
||||
|
||||
const adoptedMenu = sidebar.querySelector<HTMLButtonElement>(
|
||||
`[data-session-key="${adoptedKey}"] [data-session-menu]`,
|
||||
);
|
||||
adoptedMenu?.focus();
|
||||
expect(document.activeElement).toBe(adoptedMenu);
|
||||
|
||||
sidebar.sessionData.sessionCatalogs = catalogPage([
|
||||
{ threadId: "thread-released", name: "Native catalog session" },
|
||||
]).catalogs;
|
||||
sidebar.sessionData.requestSessionDataUpdate();
|
||||
await sidebar.updateComplete;
|
||||
|
||||
const nativeMenu = sidebar.querySelector<HTMLButtonElement>(
|
||||
'[data-session-key$=":thread-released"] [data-catalog-session-menu]',
|
||||
);
|
||||
expect(document.activeElement).toBe(nativeMenu);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import {
|
||||
catalogPage,
|
||||
createGateway,
|
||||
createSessions,
|
||||
createSessionsHarness,
|
||||
mountSidebar,
|
||||
} from "../app-sidebar.ts";
|
||||
import "../../components/app-sidebar.ts";
|
||||
|
||||
describe("AppSidebar catalog row lifecycle", () => {
|
||||
it("retargets an open menu when its row is adopted", async () => {
|
||||
const adoptedKey = "agent:main:adopted-menu";
|
||||
const gateway = createGateway({} as GatewayBrowserClient);
|
||||
const { sidebar } = await mountSidebar(
|
||||
gateway,
|
||||
createSessions("main", ["agent:main:main", adoptedKey]),
|
||||
);
|
||||
const setCatalog = async (sessionKey?: string) => {
|
||||
sidebar.sessionData.sessionCatalogs = catalogPage([
|
||||
{ threadId: "thread-adopted-menu", name: "Adopted menu", sessionKey },
|
||||
]).catalogs;
|
||||
sidebar.sessionData.requestSessionDataUpdate();
|
||||
await sidebar.updateComplete;
|
||||
};
|
||||
await setCatalog();
|
||||
sidebar.querySelector<HTMLButtonElement>("[data-catalog-session-menu]")?.click();
|
||||
await sidebar.updateComplete;
|
||||
await setCatalog(adoptedKey);
|
||||
await Promise.resolve();
|
||||
await sidebar.updateComplete;
|
||||
|
||||
const adoptedMenu = sidebar.querySelector<HTMLButtonElement>(
|
||||
`[data-session-key="${adoptedKey}"] [data-session-menu]`,
|
||||
);
|
||||
const popup = sidebar.querySelector<HTMLElement & { trigger?: HTMLElement }>(
|
||||
"openclaw-catalog-session-menu",
|
||||
);
|
||||
expect(adoptedMenu?.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(popup?.trigger).toBe(adoptedMenu);
|
||||
popup?.querySelector<HTMLElement>("wa-dropdown-item")?.focus();
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
await sidebar.updateComplete;
|
||||
expect(document.activeElement).toBe(adoptedMenu);
|
||||
});
|
||||
|
||||
it("clears marquee state when a catalog label changes", async () => {
|
||||
const gateway = createGateway({} as GatewayBrowserClient);
|
||||
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
|
||||
const setLabel = async (name: string) => {
|
||||
sidebar.sessionData.sessionCatalogs = catalogPage([
|
||||
{ threadId: "thread-rename", name },
|
||||
]).catalogs;
|
||||
sidebar.sessionData.requestSessionDataUpdate();
|
||||
await sidebar.updateComplete;
|
||||
};
|
||||
await setLabel("A long catalog session title");
|
||||
const oldLabel = sidebar.querySelector<HTMLElement>(".hover-marquee");
|
||||
oldLabel?.classList.add("hover-marquee--scrolling");
|
||||
oldLabel?.style.setProperty("--hover-marquee-shift", "-80px");
|
||||
await setLabel("Short");
|
||||
|
||||
const updatedLabel = sidebar.querySelector<HTMLElement>(".hover-marquee");
|
||||
expect(updatedLabel).not.toBe(oldLabel);
|
||||
expect(updatedLabel?.classList.contains("hover-marquee--scrolling")).toBe(false);
|
||||
expect(updatedLabel?.style.getPropertyValue("--hover-marquee-shift")).toBe("");
|
||||
});
|
||||
|
||||
it("clears adopted marquee state when its live pull request appears", async () => {
|
||||
const adoptedKey = "agent:main:adopted-pull-request";
|
||||
const gateway = createGateway({} as GatewayBrowserClient);
|
||||
const sessions = createSessionsHarness("main", ["agent:main:main", adoptedKey]);
|
||||
const { sidebar } = await mountSidebar(gateway, sessions.sessions);
|
||||
sidebar.sessionData.sessionCatalogs = catalogPage([
|
||||
{ threadId: "thread-adopted-pr", name: "Adopted session", sessionKey: adoptedKey },
|
||||
]).catalogs;
|
||||
sidebar.sessionData.requestSessionDataUpdate();
|
||||
await sidebar.updateComplete;
|
||||
|
||||
const row = sidebar.querySelector<HTMLElement>(`[data-session-key="${adoptedKey}"]`);
|
||||
const oldLabel = row?.querySelector<HTMLElement>(".hover-marquee");
|
||||
oldLabel?.classList.add("hover-marquee--scrolling");
|
||||
oldLabel?.style.setProperty("--hover-marquee-shift", "-80px");
|
||||
sessions.sessions.setPullRequestSummary(adoptedKey, { numbers: [125820], state: "open" });
|
||||
await sidebar.updateComplete;
|
||||
|
||||
const updatedLabel = row?.querySelector<HTMLElement>(".hover-marquee");
|
||||
expect(updatedLabel).not.toBe(oldLabel);
|
||||
expect(updatedLabel?.classList.contains("hover-marquee--scrolling")).toBe(false);
|
||||
expect(row?.querySelector(".session-row-badge--pull-request")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user