From 972e374c4e7098fac3a65c8e1b6143e788c948d8 Mon Sep 17 00:00:00 2001 From: "Josh S." <8768403+SuuBro@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:51:14 +0100 Subject: [PATCH] Polish message author labels Co-authored-by: bobbit-ai Co-Authored-By: Bobbit (Gpt 5.6 Sol) --- src/server/agent/session-manager.ts | 4 +- src/ui/app.css | 62 ++++++++++++++++--- src/ui/bobbit-render.ts | 16 ++--- src/ui/components/Messages.ts | 57 +++++++++++------ .../journeys/author-metadata.journey.spec.ts | 44 ++++++++++--- ...on-manager-direct-prompt-lifecycle.test.ts | 7 ++- tests2/dom/message-author-labels.test.ts | 5 ++ tests2/dom/message-author-sprite.test.ts | 9 +++ 8 files changed, 161 insertions(+), 43 deletions(-) diff --git a/src/server/agent/session-manager.ts b/src/server/agent/session-manager.ts index ccc79b675..5ccf87a62 100755 --- a/src/server/agent/session-manager.ts +++ b/src/server/agent/session-manager.ts @@ -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, diff --git a/src/ui/app.css b/src/ui/app.css index 1c1e5a8ef..d19a25ec9 100644 --- a/src/ui/app.css +++ b/src/ui/app.css @@ -4009,6 +4009,7 @@ html { align-items: flex-end; min-width: 0; max-width: 100%; + padding-top: 2px; } .prompt-bubble-shell { @@ -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 { @@ -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; @@ -4078,6 +4121,7 @@ html { color: var(--foreground); font-size: 10px; font-weight: 670; + line-height: 1.3; } .prompt-author-divider, @@ -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 { diff --git a/src/ui/bobbit-render.ts b/src/ui/bobbit-render.ts index a7a6f1323..399c95e7d 100644 --- a/src/ui/bobbit-render.ts +++ b/src/ui/bobbit-render.ts @@ -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; } // ============================================================================ @@ -675,13 +677,14 @@ export function renderStaticSidebarBobbitCanvas(opts: { unread: false, noDesaturate: true, disableIdleBreathing: true, + centerInContainer: true, hueRotate: opts.hueRotate, accessory: opts.accessory, })}`; } 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; @@ -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) { @@ -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 diff --git a/src/ui/components/Messages.ts b/src/ui/components/Messages.ts index 121f7c601..fa5b36bd7 100644 --- a/src/ui/components/Messages.ts +++ b/src/ui/components/Messages.ts @@ -308,28 +308,47 @@ export class UserMessage extends LitElement { } const isAgent = presentation.kind === "agent"; + const authorBadgeContent = isAgent ? html` + + ${presentation.normalizedAgentLabel} + + Agent + ` : html` + + ${presentation.visibleName} + `; + const originSessionId = isAgent ? this.authorAppearance?.sessionId : undefined; + const authorBadge = originSessionId ? html` + + ${authorBadgeContent} + + ` : html` +
+ ${authorBadgeContent} +
+ `; return html`
-
- ${isAgent ? html` - - ${presentation.normalizedAgentLabel} - - Agent - ` : html` - ${presentation.visibleName} - `} -
+ ${authorBadge}
${body} ${attachments} diff --git a/tests2/browser/journeys/author-metadata.journey.spec.ts b/tests2/browser/journeys/author-metadata.journey.spec.ts index 0606d0e1c..f20064bb1 100644 --- a/tests2/browser/journeys/author-metadata.journey.spec.ts +++ b/tests2/browser/journeys/author-metadata.journey.spec.ts @@ -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, @@ -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), }; }); } @@ -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) { @@ -172,13 +178,26 @@ async function expectStaticSpriteMatchesSidebar( return avatar!.accessorySrc!; } -async function expectNoHorizontalOverflow(page: Page): Promise { +async function expectAuthorBadgeGeometry(page: Page): Promise { 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); @@ -187,6 +206,10 @@ async function expectNoHorizontalOverflow(page: Page): Promise { 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"); } } @@ -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); @@ -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"); @@ -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(); @@ -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", { diff --git a/tests2/core/session-manager-direct-prompt-lifecycle.test.ts b/tests2/core/session-manager-direct-prompt-lifecycle.test.ts index f1907a72a..c460d4210 100644 --- a/tests2/core/session-manager-direct-prompt-lifecycle.test.ts +++ b/tests2/core/session-manager-direct-prompt-lifecycle.test.ts @@ -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); diff --git a/tests2/dom/message-author-labels.test.ts b/tests2/dom/message-author-labels.test.ts index 3328e9d02..dd8de9148 100644 --- a/tests2/dom/message-author-labels.test.ts +++ b/tests2/dom/message-author-labels.test.ts @@ -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"); }); diff --git a/tests2/dom/message-author-sprite.test.ts b/tests2/dom/message-author-sprite.test.ts index 377bff648..f683c1d98 100644 --- a/tests2/dom/message-author-sprite.test.ts +++ b/tests2/dom/message-author-sprite.test.ts @@ -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"); + }); });