Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/server/agent/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7167,9 +7167,9 @@ export class SessionManager {
};
try {
const continuationPrompt =
"[SYSTEM: The infrastructure server restarted while you were mid-turn. " +
"The infrastructure server restarted while you were mid-turn. " +
"Your previous work has been preserved. Please continue where you left off. " +
"Do NOT start over — review your recent messages and resume from the exact point of interruption.]";
"Do NOT start over — review your recent messages and resume from the exact point of interruption.";
await dispatchTrackedSystemPrompt(session, continuationPrompt, {
source: "system",
whenReady: true,
Expand Down
62 changes: 55 additions & 7 deletions src/ui/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -4009,6 +4009,7 @@ html {
align-items: flex-end;
min-width: 0;
max-width: 100%;
padding-top: 2px;
}

.prompt-bubble-shell {
Expand All @@ -4030,12 +4031,13 @@ html {
width: fit-content;
min-width: 0;
max-width: 100%;
margin-top: 7px;
/* Keep the floating badge fully inside paint containment while overlapping the bubble. */
margin-top: 14px;
padding-top: 14px;
}

.user-message-container--labelled::before {
top: 18px;
top: 19px;
}

.prompt-author-badge {
Expand All @@ -4048,27 +4050,68 @@ html {
width: max-content;
max-width: calc(100% - 18px);
min-width: 0;
min-height: 22px;
height: 24px;
margin-left: 10px;
padding: 1px 7px 1px 3px;
transform: translateY(-56%);
border: 1px solid var(--border);
border-radius: 6px;
background: var(--card);
box-shadow: 0 2px 5px color-mix(in oklch, var(--foreground) 9%, transparent);
box-shadow: 0 1px 4px color-mix(in oklch, var(--foreground) 8%, transparent);
color: var(--muted-foreground);
line-height: 1;
}

.prompt-author-badge:not(.prompt-author-badge--link) {
pointer-events: none;
}

.prompt-author-badge--link {
cursor: pointer;
text-decoration: none;
transition: border-color 120ms ease, box-shadow 120ms ease;
}

.prompt-author-badge--link:hover {
border-color: color-mix(in oklch, var(--primary) 40%, var(--border));
box-shadow: 0 1px 5px color-mix(in oklch, var(--foreground) 12%, transparent);
}

.prompt-author-badge--link:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}

.prompt-author-avatar {
display: grid;
place-items: center;
flex: none;
width: 20px;
height: 17px;
height: 20px;
overflow: visible;
}

.prompt-author-avatar > span {
display: contents;
}

.prompt-author-initial {
display: grid;
place-items: center;
flex: none;
width: 16px;
height: 16px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--secondary);
color: var(--secondary-foreground);
font-size: 7px;
font-weight: 850;
}

.prompt-author-initial::before {
content: attr(data-initial);
}

.prompt-author-name {
flex: 1 1 auto;
min-width: 0;
Expand All @@ -4078,6 +4121,7 @@ html {
color: var(--foreground);
font-size: 10px;
font-weight: 670;
line-height: 1.3;
}

.prompt-author-divider,
Expand All @@ -4086,7 +4130,11 @@ html {
}

.prompt-author-divider {
color: var(--border);
width: 1px;
height: 12px;
overflow: hidden;
background: var(--border);
color: transparent;
}

.prompt-author-kind {
Expand Down
16 changes: 9 additions & 7 deletions src/ui/bobbit-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ export interface SidebarBobbitOptions {
unread?: boolean;
/** Disable the idle breathing loop for actual sidebar session/staff rows. */
disableIdleBreathing?: boolean;
/** Center the complete sprite group in its fixed-width container. */
centerInContainer?: boolean;
}

// ============================================================================
Expand Down Expand Up @@ -675,13 +677,14 @@ export function renderStaticSidebarBobbitCanvas(opts: {
unread: false,
noDesaturate: true,
disableIdleBreathing: true,
centerInContainer: true,
hueRotate: opts.hueRotate,
accessory: opts.accessory,
})}</span>`;
}

export function renderSidebarBobbitCanvas(opts: SidebarBobbitOptions): TemplateResult {
const { status, isCompacting = false, hueRotate = 0, isSelected = false, isAborting = false, noDesaturate = false, unread = false, disableIdleBreathing = false } = opts;
const { status, isCompacting = false, hueRotate = 0, isSelected = false, isAborting = false, noDesaturate = false, unread = false, disableIdleBreathing = false, centerInContainer = false } = opts;
const acc = opts.accessory ?? NO_ACCESSORY;
const hasAccessory = acc.id !== "none";
const addsHeight = acc.addsHeight;
Expand Down Expand Up @@ -797,8 +800,8 @@ export function renderSidebarBobbitCanvas(opts: SidebarBobbitOptions): TemplateR
let accCssW = 0;
let accCssH = 0;
let accLeft = 0;
let sidebarOriginX = 0;
const sidebarContainerWidth = 20;
let sidebarOriginX = centerInContainer ? (sidebarContainerWidth - cssW) / 2 : 0;
if (hasAccessory) {
const spriteData = SPRITE_ACCESSORIES[acc.id];
if (spriteData && spriteData.pixels.length > 0) {
Expand All @@ -814,11 +817,10 @@ export function renderSidebarBobbitCanvas(opts: SidebarBobbitOptions): TemplateR
const srcW = maxX - xShift + 1;
const srcH = maxY - yShift + 1;

// Preserve coordinate alignment between body + accessory, but use spare
// sidebar wrapper width when an accessory has left-of-body pixels (e.g.
// headset at x=-1). Other accessories keep the historical x=0 body origin
// so right-hand tools don't shift further into the clipped edge.
if (xShift < 0) {
// Preserve coordinate alignment between body + accessory. Static author
// avatars center the complete group; sidebar rows retain their historical
// left alignment except for accessories with left-of-body pixels.
if (centerInContainer || xShift < 0) {
const groupRight = Math.max(BODY_WIDTH, maxX + 1);
const groupWidth = (groupRight - xShift) * S;
const groupLeft = groupWidth <= sidebarContainerWidth
Expand Down
57 changes: 38 additions & 19 deletions src/ui/components/Messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,28 +308,47 @@ export class UserMessage extends LitElement {
}

const isAgent = presentation.kind === "agent";
const authorBadgeContent = isAgent ? html`
<span class="prompt-author-avatar" aria-hidden="true">
${renderStaticSidebarBobbitCanvas({
hueRotate: this.authorAppearance?.hueRotate ?? 0,
accessory: getAccessoryDef(this.authorAppearance?.accessoryId),
})}
</span>
<span class="prompt-author-name">${presentation.normalizedAgentLabel}</span>
<span class="prompt-author-divider" aria-hidden="true">|</span>
<span class="prompt-author-kind">Agent</span>
` : html`
<span
class="prompt-author-initial"
aria-hidden="true"
data-initial=${presentation.kind === "user" ? "U" : "S"}
></span>
<span class="prompt-author-name">${presentation.visibleName}</span>
`;
const originSessionId = isAgent ? this.authorAppearance?.sessionId : undefined;
const authorBadge = originSessionId ? html`
<a
class="prompt-author-badge prompt-author-badge--link"
href=${`#/session/${encodeURIComponent(originSessionId)}`}
aria-label=${presentation.accessibleName}
title=${presentation.visibleName}
>
${authorBadgeContent}
</a>
` : html`
<div
class="prompt-author-badge"
aria-label=${presentation.accessibleName}
title=${presentation.visibleName}
>
${authorBadgeContent}
</div>
`;
return html`
<div class="prompt-row prompt-row--labelled flex justify-start mx-2 sm:mx-4 my-1">
<div class="prompt-bubble-shell">
<div
class="prompt-author-badge"
aria-label=${presentation.accessibleName}
title=${presentation.visibleName}
>
${isAgent ? html`
<span class="prompt-author-avatar" aria-hidden="true">
${renderStaticSidebarBobbitCanvas({
hueRotate: this.authorAppearance?.hueRotate ?? 0,
accessory: getAccessoryDef(this.authorAppearance?.accessoryId),
})}
</span>
<span class="prompt-author-name">${presentation.normalizedAgentLabel}</span>
<span class="prompt-author-divider" aria-hidden="true">|</span>
<span class="prompt-author-kind">Agent</span>
` : html`
<span class="prompt-author-name">${presentation.visibleName}</span>
`}
</div>
${authorBadge}
<div class="user-message-container user-message-container--labelled py-2 px-3 sm:px-4">
${body}
${attachments}
Expand Down
44 changes: 37 additions & 7 deletions tests2/browser/journeys/author-metadata.journey.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ async function spriteDetails(root: import("@playwright/test").Locator) {
if (!sprite) return null;
const images = Array.from(sprite.querySelectorAll(":scope > img")) as HTMLImageElement[];
const hue = /hue-rotate\((-?\d+)deg\)/.exec(sprite.getAttribute("style") ?? "")?.[1];
const rootRect = node.getBoundingClientRect();
const spriteRect = sprite.getBoundingClientRect();
return {
hue: hue === undefined ? undefined : Number(hue),
imageCount: images.length,
Expand All @@ -102,6 +104,8 @@ async function spriteDetails(root: import("@playwright/test").Locator) {
.map((element) => getComputedStyle(element).animationName),
hasBlinkLayer: !!sprite.querySelector(".bobbit-sidebar-unread-blink"),
hasSaturation: /saturate\(/.test(sprite.getAttribute("style") ?? ""),
rootHeight: rootRect.height,
verticalCenterDelta: (spriteRect.top + spriteRect.height / 2) - (rootRect.top + rootRect.height / 2),
};
});
}
Expand Down Expand Up @@ -157,6 +161,8 @@ async function expectStaticSpriteMatchesSidebar(
expect(avatar!.animationNames.every((name) => name === "none")).toBe(true);
expect(avatar!.hasBlinkLayer).toBe(false);
expect(avatar!.hasSaturation).toBe(false);
expect(avatar!.rootHeight).toBeCloseTo(20, 1);
expect(Math.abs(avatar!.verticalCenterDelta)).toBeLessThanOrEqual(1);
await expectOpenCenterEyes(decorativeAvatar);

if (expectedAccessorySrc) {
Expand All @@ -172,13 +178,26 @@ async function expectStaticSpriteMatchesSidebar(
return avatar!.accessorySrc!;
}

async function expectNoHorizontalOverflow(page: Page): Promise<void> {
async function expectAuthorBadgeGeometry(page: Page): Promise<void> {
const geometry = await page.evaluate(() => ({
viewportWidth: window.innerWidth,
documentWidth: document.documentElement.scrollWidth,
rows: Array.from(document.querySelectorAll("user-message .prompt-row--labelled")).map((row) => {
const rect = row.getBoundingClientRect();
return { left: rect.left, right: rect.right, width: rect.width };
const rowRect = row.getBoundingClientRect();
const badgeRect = row.querySelector(".prompt-author-badge")!.getBoundingClientRect();
const bubble = row.querySelector(".user-message-container--labelled")!;
const bubbleRect = bubble.getBoundingClientRect();
return {
left: rowRect.left,
right: rowRect.right,
width: rowRect.width,
badgeTop: badgeRect.top,
badgeHeight: badgeRect.height,
rowTop: rowRect.top,
badgeBottom: badgeRect.bottom,
bubbleTop: bubbleRect.top,
chevronTop: getComputedStyle(bubble, "::before").top,
};
}),
}));
expect(geometry.rows.length).toBeGreaterThanOrEqual(3);
Expand All @@ -187,6 +206,10 @@ async function expectNoHorizontalOverflow(page: Page): Promise<void> {
expect(row.left).toBeGreaterThanOrEqual(-0.5);
expect(row.right).toBeLessThanOrEqual(geometry.viewportWidth + 0.5);
expect(row.width).toBeGreaterThan(0);
expect(row.badgeTop).toBeGreaterThanOrEqual(row.rowTop);
expect(row.badgeBottom).toBeGreaterThan(row.bubbleTop);
expect(row.badgeHeight).toBeCloseTo(24, 1);
expect(row.chevronTop).toBe("19px");
}
}

Expand Down Expand Up @@ -251,7 +274,7 @@ test.describe("Journey: Author metadata", () => {
body: JSON.stringify({
title: AGENT_LABEL,
colorIndex: AGENT_COLOR_INDEX,
accessory: "headset",
accessory: "crown",
}),
});
expect(appearancePatch.status, await appearancePatch.clone().text()).toBe(200);
Expand Down Expand Up @@ -315,7 +338,9 @@ test.describe("Journey: Author metadata", () => {

const systemBubble = promptBubble(page, systemPrompt);
await expectBadgeText(systemBubble, "System");
await expect(systemBubble.locator('.prompt-author-badge [aria-hidden="true"]')).toHaveCount(0);
await expect(humanBubble.locator(".prompt-author-initial")).toHaveAttribute("data-initial", "U");
await expect(systemBubble.locator(".prompt-author-initial")).toHaveAttribute("data-initial", "S");
await expect(systemBubble.locator(".prompt-author-avatar")).toHaveCount(0);
await expect(page.locator("assistant-message .prompt-author-badge")).toHaveCount(0);
expect(normalizedText(await humanBubble.locator(".prompt-author-badge").innerText())).not.toContain("Human");
expect(normalizedText(await systemBubble.locator(".prompt-author-badge").innerText())).not.toContain("Bobbit");
Expand All @@ -332,7 +357,7 @@ test.describe("Journey: Author metadata", () => {
return { overflow: style.overflow, textOverflow: style.textOverflow, whiteSpace: style.whiteSpace };
});
expect(nameStyle).toEqual({ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" });
await expectNoHorizontalOverflow(page);
await expectAuthorBadgeGeometry(page);

const skillButton = humanBubble.locator("skill-chip .skill-chip-pill");
await skillButton.click();
Expand All @@ -352,8 +377,13 @@ test.describe("Journey: Author metadata", () => {
await expect(reloadedHuman.locator(".file-mention-chip-pill")).toBeVisible();
await expect(reloadedHuman.locator("attachment-tile")).toBeVisible();
await expectStaticSpriteMatchesSidebar(page, sourceSessionId, reloadedAgent, accessorySrc);
await expectNoHorizontalOverflow(page);
await expectAuthorBadgeGeometry(page);
expect(await page.locator("body").innerText()).not.toContain(modelPrefix);

const originLink = reloadedAgent.locator("a.prompt-author-badge");
await expect(originLink).toHaveAttribute("href", `#/session/${sourceSessionId}`);
await originLink.click();
await expect(page).toHaveURL(new RegExp(`#/session/${sourceSessionId}$`));
} finally {
restoreEcho?.();
await apiFetch("/api/preferences", {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,12 @@ describe("SessionManager direct idle prompt lifecycle", () => {
assert.equal(await manager._dispatchBootContinuation(restartSession), true);
assert.equal(restartPrompt.mock.calls.length, 1);
const restartPiText = restartPrompt.mock.calls[0][0];
assert.match(restartPiText, /^\[System\]: .*infrastructure server restarted while you were mid-turn/i);
const restartBaseText =
"The infrastructure server restarted while you were mid-turn. " +
"Your previous work has been preserved. Please continue where you left off. " +
"Do NOT start over — review your recent messages and resume from the exact point of interruption.";
assert.equal(restartPiText, `${systemPrefix}${restartBaseText}`);
assert.equal(restartPiText.match(/\[System\]:/g)?.length, 1, "system author prefix appears exactly once");
const restartBinding = readAuthorSidecar(restartSession.id)[0];
assert.equal(restartBinding.modelText, undefined);
assert.equal(restartBinding.modelPrefix, systemPrefix);
Expand Down
5 changes: 5 additions & 0 deletions tests2/dom/message-author-labels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,11 @@ describe("prompt author badge DOM", () => {
]);
expect(list.textContent).not.toContain("Human");
expect(badges[1].textContent).not.toContain("Bobbit");
expect(badges[0].querySelector(".prompt-author-initial")?.getAttribute("data-initial")).toBe("U");
expect(badges[1].querySelector(".prompt-author-initial")?.getAttribute("data-initial")).toBe("S");
expect(badges[2].querySelector(".prompt-author-initial")).toBeNull();
expect(badges.map((badge) => badge.tagName)).toEqual(["DIV", "DIV", "A"]);
expect(badges[2].getAttribute("href")).toBe("#/session/1ae73f53-dc48-4ca4");
expect(badges[2].getAttribute("title")).toBe("Test Coordinator | Agent");
});

Expand Down
9 changes: 9 additions & 0 deletions tests2/dom/message-author-sprite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,13 @@ describe("message author Bobbit sprite", () => {
);
expect(contexts[0]?.drawCalls).toEqual(expectedBodyDraws);
});

it("centers an unaccessorized author sprite in its fixed-width avatar slot", () => {
render(renderStaticSidebarBobbitCanvas(), host);

const sprite = host.firstElementChild?.firstElementChild as HTMLElement | null;
const body = sprite?.querySelector("img") as HTMLImageElement | null;
expect(sprite?.style.width).toBe("20px");
expect(body?.style.left).toBe("2px");
});
});