From ccb668b3b492df1b9eff45d3cff396d95f5cce48 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 2 Aug 2026 16:58:20 +0200 Subject: [PATCH] Improve sidebar workspace readability and host identity (#2790) * fix(app): align turn footer actions Keep running-turn actions in the same position as completed-turn actions and use the shared small icon size for both copy and fork controls. * feat(app): improve sidebar workspace identity Make dense sidebar workspace rows easier to scan and let each device control how connected hosts are identified. * fix(app): keep host badges readable Use a compact display menu and keep identity color on the host icon so translated controls fit and badge text retains standard contrast. * fix(app): announce selected sidebar badge mode * fix(app): retain icon-only host names for screen readers * fix(app): name hoisted workspace rows * fix(app): retain host identity in workspace labels * fix(app): retain workspace status in row labels * fix(app): defer host appearance until identity resolves * fix(app): serialize host appearance writes * fix(app): serialize host registry writes * fix(app): bound sidebar host badge labels * test(app): match workspace status labels * fix(app): consolidate sidebar identity behavior * test(app): assert status grouping behavior * fix(app): keep sidebar row actions accessible * fix(app): stabilize optimistic message layout --- docs/design.md | 4 +- docs/glossary.md | 1 + .../browser/agent-consecutive-turns.spec.ts | 40 ++ .../app/e2e/browser/host-appearance.spec.ts | 212 ++++++++ .../app/e2e/browser/new-workspace.spec.ts | 15 +- .../e2e/browser/sidebar-context-menu.spec.ts | 40 ++ .../app/e2e/browser/sidebar-model-b.spec.ts | 6 + .../app/e2e/helpers/project-status-badge.ts | 8 +- .../support/helpers/daemon-websocket-gate.ts | 26 + packages/app/e2e/support/helpers/hosts.ts | 113 +++++ packages/app/e2e/support/helpers/sidebar.ts | 104 ++++ .../app/src/agent-stream/turn-footer.test.tsx | 105 ++++ packages/app/src/agent-stream/turn-footer.tsx | 5 +- .../src/components/assistant-fork-menu.tsx | 9 +- packages/app/src/components/diff-stat.tsx | 4 +- packages/app/src/components/left-sidebar.tsx | 7 - packages/app/src/components/message.tsx | 7 +- .../app/src/components/project-icon-view.tsx | 7 +- .../sidebar-workspace-list.test.tsx | 2 + .../src/components/sidebar-workspace-list.tsx | 465 +++++++++++------- .../app/src/components/sidebar/host-badge.tsx | 91 ++++ .../sidebar/project-leading-visual.tsx | 182 +++---- .../components/sidebar/pulsing-status-dot.tsx | 83 ++++ .../sidebar/sidebar-status-list.tsx | 150 +++--- .../sidebar/sidebar-subtitle-project-icon.tsx | 74 --- .../sidebar/sidebar-workspace-menu.tsx | 300 ++++++++--- .../sidebar/sidebar-workspace-row-content.tsx | 372 ++++++++------ .../sidebar/sidebar-workspace-row.tsx | 64 ++- .../sidebar/sidebar-workspace-title.test.ts | 47 +- .../sidebar/sidebar-workspace-title.ts | 23 + .../src/components/workspace-hover-card.tsx | 50 +- ...space-shortcut-targets-subscriber.test.tsx | 2 + .../diagnostics/app-diagnostic-report.test.ts | 2 + packages/app/src/hosts/appearance.test.ts | 153 ++++++ packages/app/src/hosts/appearance.ts | 100 ++++ packages/app/src/i18n/resources/ar.ts | 35 ++ packages/app/src/i18n/resources/en.ts | 37 +- packages/app/src/i18n/resources/es.ts | 35 ++ packages/app/src/i18n/resources/fr.ts | 35 ++ packages/app/src/i18n/resources/ja.ts | 35 ++ packages/app/src/i18n/resources/pt-BR.ts | 35 ++ packages/app/src/i18n/resources/ru.ts | 35 ++ packages/app/src/i18n/resources/zh-CN.ts | 35 ++ packages/app/src/runtime/host-runtime.test.ts | 162 ++++++ packages/app/src/runtime/host-runtime.ts | 79 ++- .../settings/host-appearance-section.tsx | 357 ++++++++++++++ .../app/src/screens/settings/host-page.tsx | 54 +- .../app/src/styles/identity-colors.test.ts | 39 ++ packages/app/src/styles/identity-colors.ts | 57 +++ packages/app/src/styles/theme.ts | 12 +- .../app/src/types/host-connection.test.ts | 61 +++ packages/app/src/types/host-connection.ts | 8 + packages/app/src/utils/project-icon-color.ts | 26 - .../project-status-badge-content.test.ts | 4 +- .../src/utils/project-status-badge-content.ts | 23 +- packages/app/src/utils/status-dot-color.ts | 5 +- .../open-in-file-manager/menu-item.tsx | 18 +- 57 files changed, 3251 insertions(+), 809 deletions(-) create mode 100644 packages/app/e2e/browser/host-appearance.spec.ts create mode 100644 packages/app/e2e/browser/sidebar-context-menu.spec.ts create mode 100644 packages/app/src/agent-stream/turn-footer.test.tsx create mode 100644 packages/app/src/components/sidebar/host-badge.tsx create mode 100644 packages/app/src/components/sidebar/pulsing-status-dot.tsx delete mode 100644 packages/app/src/components/sidebar/sidebar-subtitle-project-icon.tsx create mode 100644 packages/app/src/hosts/appearance.test.ts create mode 100644 packages/app/src/hosts/appearance.ts create mode 100644 packages/app/src/screens/settings/host-appearance-section.tsx create mode 100644 packages/app/src/styles/identity-colors.test.ts create mode 100644 packages/app/src/styles/identity-colors.ts delete mode 100644 packages/app/src/utils/project-icon-color.ts diff --git a/docs/design.md b/docs/design.md index 06316bea3..3eae53e3d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -225,6 +225,8 @@ Status pills are `palette.[300]` foreground on a 10%-alpha background of Status dots — the small filled circles next to a host or agent name — are `borderRadius.full` filled with the status color (`statusSuccess`, `statusWarning`, `statusDanger`, or `foregroundMuted`). They sit in the trailing slot of a sidebar row or as a leading marker on a status pill. +Identity badges — the project icon and the sidebar host badge — do not use the theme palette. They draw from the fixed ten-color identity table in `packages/app/src/styles/identity-colors.ts`, whose hexes are held to one contrast band so a color identifies rather than ranks. Identity color is a fill-only contract: project icons use it as a fill with a white letter, while host badges use it on their icon and retain the normal muted text color. The table is theme-independent by design; do not fork it per theme, and do not add hexes to it without recomputing the band. + The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `packages/app/src/components/agent-list.tsx:607-632`, and `packages/app/src/components/sidebar-workspace-list.tsx:2889-2894` are drift to be removed. New code uses ``. --- @@ -236,7 +238,7 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, ` - Bare `` for a section header inside settings. `` exists. - A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu. - The word "checkout" in UI strings or identifiers. The term is "workspace". -- New color tokens or hardcoded hex outside the palette. Status pill rgba backgrounds are the documented pattern (§12), not a license. +- New color tokens or hardcoded hex outside the palette. Status pill rgba backgrounds and the identity color table are the documented exceptions (§13), not a license. - Placeholder text dimmed beyond `foregroundMuted`. No extra opacity, no italics, no ghost-text. - `onPointerEnter` and `onPointerLeave`. They do not fire on native iOS. Hover uses Pressable's `onHoverIn`/`onHoverOut` gated with `isHovered || isCompact || isNative`. - Raw DOM APIs without an `isWeb` guard. diff --git a/docs/glossary.md b/docs/glossary.md index 4bee02256..bb917b8a5 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -10,6 +10,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run". - **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`). - **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host). +- **Host badge** — The sidebar pill naming which host a workspace lives on. Only drawn once the visible sidebar spans more than one host. Its name, color, and display mode (**Name** / **Icon only** / **Hidden**) are per-host, per-device, and live client-side on `HostProfile.appearance`; nothing about it reaches the daemon. UI: the "Appearance" group on a host's settings page. Code: `HostBadge` (`packages/app/src/components/sidebar/host-badge.tsx`), `selectHostBadges` (`packages/app/src/hosts/appearance.ts`). Don't confuse with **Status pill**, which reports state rather than identity. - **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym. - **Placement** — One workspace's stable foreign-key relationship to its project plus its git checkout snapshot. Internal. An explicit creation `projectId` is authoritative when active. - **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/protocol/src/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`). diff --git a/packages/app/e2e/browser/agent-consecutive-turns.spec.ts b/packages/app/e2e/browser/agent-consecutive-turns.spec.ts index 26ee605ea..8032da793 100644 --- a/packages/app/e2e/browser/agent-consecutive-turns.spec.ts +++ b/packages/app/e2e/browser/agent-consecutive-turns.spec.ts @@ -660,6 +660,19 @@ function expectAtomicIdleToRunningTransition(frames: TurnFrame[]): void { ).toEqual([]); } +function expectSubmittedMessageHeightStable(frames: TurnFrame[]): void { + const first = frames.findIndex((frame) => frame.userRow.visible); + const baseline = frames[first]?.userRow.rect?.height; + const changes = frames + .slice(first) + .flatMap((frame, offset) => + geometryChanged(frame.userRow.rect?.height, baseline) + ? [`frame ${first + offset}: submitted row height changed`] + : [], + ); + expect(changes, `submitted message geometry changed:\n${changes.join("\n")}`).toEqual([]); +} + function expectAtomicFirstPromptTransition(frames: TurnFrame[]): void { expectRowContinuity(frames); const first = frames.findIndex((frame) => hasPaintedLayout(frame.userRow)); @@ -828,6 +841,33 @@ test("keeps follow-up activity continuous when turn starts before response", asy expectAtomicIdleToRunningTransition(frames); }); +test("keeps a submitted message height stable when its canonical echo arrives", async ({ + page, + streamingAgent, +}) => { + const gate = await installDaemonWebSocketGate(page); + await openAgentRoute(page, { + workspaceId: streamingAgent.workspaceId, + agentId: streamingAgent.agentId, + }); + await expectAgentIdle(page); + + const prompt = "Canonical echo must not resize this message."; + gate.holdNextAgentStreamItem("user_message"); + await recordTurnFrames(page, prompt); + await submitMessage(page, prompt); + await gate.waitForHeldAgentStreamItem("user_message"); + const submittedRow = page.getByTestId("user-message").filter({ hasText: prompt }); + await expect(submittedRow).toBeVisible(); + await recordPaintsFor(page, 80); + + gate.releaseHeldAgentStreamItem("user_message"); + await expect(submittedRow.getByTestId("rewind-menu-trigger")).toBeVisible(); + await recordPaintsFor(page, 80); + + expectSubmittedMessageHeightStable(await stopTurnFrameRecording(page)); +}); + test("keeps follow-up activity continuous when response arrives before running state", async ({ page, streamingAgent, diff --git a/packages/app/e2e/browser/host-appearance.spec.ts b/packages/app/e2e/browser/host-appearance.spec.ts new file mode 100644 index 000000000..64575ecb9 --- /dev/null +++ b/packages/app/e2e/browser/host-appearance.spec.ts @@ -0,0 +1,212 @@ +import { expect, test as base } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { + addConnectedHostAndReload, + chooseHostBadgeDisplay, + chooseHostColor, + expectHostAppearancePreview, + expectHostBadgeIconOnly, + expectHostBadgeName, + expectHostBadgeTinted, + expectNoHostBadge, + leaveHostAppearanceSettings, + openHostAppearanceSettings, + reloadPreservingHostRegistry, + renameHostFromSettings, + waitForConnectedHost, +} from "../support/helpers/hosts"; +import { + type IsolatedHostDaemon, + startIsolatedHostDaemon, +} from "../support/helpers/isolated-host-daemon"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; + +const PRIMARY_HOST_LABEL = "Primary Host"; +const SECONDARY_HOST_LABEL = "Secondary Host"; + +interface TwoHostSidebar { + primaryServerId: string; + primaryWorkspaceId: string; + secondaryServerId: string; + secondaryWorkspaceId: string; +} + +// The host badge only appears once the visible sidebar spans more than one host, and that count +// runs over visible projects — so an offline host with no workspaces will not raise it. Two real +// daemons, each with a seeded workspace, is the minimum shape that exercises the feature. +const test = base.extend<{ twoHostSidebar: TwoHostSidebar }>({ + twoHostSidebar: async ({ page }, provide) => { + const secondaryHost: IsolatedHostDaemon = await startIsolatedHostDaemon( + "host-appearance-secondary", + ); + let primary: SeededWorkspace | null = null; + let secondary: SeededWorkspace | null = null; + + try { + primary = await seedWorkspace({ + repoPrefix: "host-appearance-primary-", + title: "Primary workspace", + }); + secondary = await seedWorkspace({ + repoPrefix: "host-appearance-secondary-", + title: "Secondary workspace", + port: secondaryHost.port, + }); + + await gotoAppShell(page); + await addConnectedHostAndReload(page, { + serverId: secondaryHost.serverId, + label: SECONDARY_HOST_LABEL, + port: secondaryHost.port, + primaryLabel: PRIMARY_HOST_LABEL, + }); + await waitForConnectedHost(page, { + serverId: secondaryHost.serverId, + endpoint: `localhost:${secondaryHost.port}`, + }); + await waitForSidebarHydration(page); + + await provide({ + primaryServerId: getServerId(), + primaryWorkspaceId: primary.workspaceId, + secondaryServerId: secondaryHost.serverId, + secondaryWorkspaceId: secondary.workspaceId, + }); + } finally { + await primary?.cleanup().catch(() => undefined); + await secondary?.cleanup().catch(() => undefined); + await secondaryHost.close().catch(() => undefined); + } + }, +}); + +test.describe("Host appearance", () => { + test.describe.configure({ timeout: 180_000 }); + + test("renaming a host renames its sidebar badge", async ({ page, twoHostSidebar }) => { + const badge = { + serverId: twoHostSidebar.secondaryServerId, + workspaceId: twoHostSidebar.secondaryWorkspaceId, + }; + await expectHostBadgeName(page, { ...badge, hostName: SECONDARY_HOST_LABEL }); + + await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId); + await renameHostFromSettings(page, "Build Box"); + await leaveHostAppearanceSettings(page); + + await expectHostBadgeName(page, { ...badge, hostName: "Build Box" }); + }); + + test("long host names leave room for the workspace title and actions", async ({ + page, + twoHostSidebar, + }) => { + const hostName = "A very long remote build host name that must not take over the sidebar"; + const workspaceKey = `${twoHostSidebar.secondaryServerId}:${twoHostSidebar.secondaryWorkspaceId}`; + + await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId); + await renameHostFromSettings(page, hostName); + await leaveHostAppearanceSettings(page); + + const row = page.getByTestId(`sidebar-workspace-row-${workspaceKey}`); + const badge = row.getByTestId(`sidebar-host-badge-${twoHostSidebar.secondaryServerId}`); + await expect(badge).toHaveText(hostName); + await row.hover(); + + const title = row.getByText("Secondary workspace", { exact: true }); + const kebab = page.getByTestId(`sidebar-workspace-kebab-${workspaceKey}`); + await expect(kebab).toBeVisible(); + const [badgeBox, titleBox, rowBox, kebabBox] = await Promise.all([ + badge.boundingBox(), + title.boundingBox(), + row.boundingBox(), + kebab.boundingBox(), + ]); + + expect(badgeBox).not.toBeNull(); + expect(titleBox).not.toBeNull(); + expect(rowBox).not.toBeNull(); + expect(kebabBox).not.toBeNull(); + expect(titleBox!.width).toBeGreaterThan(0); + expect(kebabBox!.x + kebabBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width); + }); + + test("picking a color identifies the badge", async ({ page, twoHostSidebar }) => { + const badge = { + serverId: twoHostSidebar.secondaryServerId, + workspaceId: twoHostSidebar.secondaryWorkspaceId, + hostName: SECONDARY_HOST_LABEL, + }; + + await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId); + await chooseHostColor(page, "Teal"); + await leaveHostAppearanceSettings(page); + + await expectHostBadgeTinted(page, { ...badge, color: "teal" }); + }); + + test("icon only keeps the badge and drops the name", async ({ page, twoHostSidebar }) => { + const badge = { + serverId: twoHostSidebar.secondaryServerId, + workspaceId: twoHostSidebar.secondaryWorkspaceId, + hostName: SECONDARY_HOST_LABEL, + }; + + await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId); + await chooseHostBadgeDisplay(page, "Icon only"); + await leaveHostAppearanceSettings(page); + + await expectHostBadgeIconOnly(page, badge); + }); + + test("hiding one host leaves the other host's badge visible", async ({ + page, + twoHostSidebar, + }) => { + await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId); + await chooseHostBadgeDisplay(page, "Hidden"); + await leaveHostAppearanceSettings(page); + + await expectNoHostBadge(page, { + serverId: twoHostSidebar.secondaryServerId, + workspaceId: twoHostSidebar.secondaryWorkspaceId, + hostName: SECONDARY_HOST_LABEL, + }); + await expectHostBadgeName(page, { + serverId: twoHostSidebar.primaryServerId, + workspaceId: twoHostSidebar.primaryWorkspaceId, + hostName: PRIMARY_HOST_LABEL, + }); + }); + + test("name, color, and display survive a reload", async ({ page, twoHostSidebar }) => { + await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId); + await renameHostFromSettings(page, "Build Box"); + await chooseHostColor(page, "Amber"); + await chooseHostBadgeDisplay(page, "Icon only"); + await leaveHostAppearanceSettings(page); + + await reloadPreservingHostRegistry(page); + await waitForSidebarHydration(page); + + await expectHostBadgeIconOnly(page, { + serverId: twoHostSidebar.secondaryServerId, + workspaceId: twoHostSidebar.secondaryWorkspaceId, + hostName: "Build Box", + }); + }); + + test("the settings preview shows the badge as configured", async ({ page, twoHostSidebar }) => { + await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId); + await renameHostFromSettings(page, "Build Box"); + await chooseHostColor(page, "Emerald"); + + await expectHostAppearancePreview(page, { + serverId: twoHostSidebar.secondaryServerId, + hostName: "Build Box", + color: "emerald", + }); + }); +}); diff --git a/packages/app/e2e/browser/new-workspace.spec.ts b/packages/app/e2e/browser/new-workspace.spec.ts index 950916935..b50d79acf 100644 --- a/packages/app/e2e/browser/new-workspace.spec.ts +++ b/packages/app/e2e/browser/new-workspace.spec.ts @@ -116,7 +116,13 @@ async function startTrackingSidebarStatusGroups(page: import("@playwright/test") capture(); const observer = new MutationObserver(capture); - observer.observe(document.body, { childList: true, subtree: true, characterData: true }); + observer.observe(document.body, { + childList: true, + subtree: true, + characterData: true, + attributes: true, + attributeFilter: ["data-testid"], + }); win.__workspaceStatusGroupObserver = observer; }); } @@ -159,7 +165,6 @@ async function expectWorkspaceStatusGroupEvents(input: { rowTestId: string; includes: string; excludes: string; - includesIndicator?: string; excludesIndicator?: string; }) { await waitForWorkspaceStatusGroupEvent({ @@ -172,11 +177,6 @@ async function expectWorkspaceStatusGroupEvents(input: { ); expect(createdWorkspaceEvents.map((event) => event.bucket)).toContain(input.includes); expect(createdWorkspaceEvents.filter((event) => event.bucket === input.excludes)).toEqual([]); - if (input.includesIndicator) { - expect(createdWorkspaceEvents.map((event) => event.indicatorTestId)).toContain( - input.includesIndicator, - ); - } if (input.excludesIndicator) { expect( createdWorkspaceEvents.filter((event) => event.indicatorTestId === input.excludesIndicator), @@ -612,7 +612,6 @@ test.describe("New workspace flow", () => { rowTestId, includes: "running", excludes: "done", - includesIndicator: "workspace-status-indicator-running", }); } finally { await tempRepo.cleanup(); diff --git a/packages/app/e2e/browser/sidebar-context-menu.spec.ts b/packages/app/e2e/browser/sidebar-context-menu.spec.ts new file mode 100644 index 000000000..91745b491 --- /dev/null +++ b/packages/app/e2e/browser/sidebar-context-menu.spec.ts @@ -0,0 +1,40 @@ +import { test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { + closeProjectContextMenu, + closeWorkspaceContextMenu, + expectProjectContextMenuActions, + expectWorkspaceContextMenuActions, + expectWorkspaceContextMenuOwnsAttention, + expectWorkspaceRowHoverCleared, + openProjectContextMenu, + openWorkspaceContextMenu, + showWorkspaceHoverCard, +} from "../support/helpers/sidebar"; + +test.describe("Sidebar context menus", () => { + test("right-clicking workspace and project rows opens their actions at the pointer", async ({ + page, + }) => { + const workspace = await seedWorkspace({ repoPrefix: "sidebar-context-menu-" }); + + try { + await gotoAppShell(page); + + await showWorkspaceHoverCard(page, workspace.workspaceId); + await openWorkspaceContextMenu(page, workspace.workspaceId); + await expectWorkspaceContextMenuActions(page, workspace.workspaceId); + await expectWorkspaceContextMenuOwnsAttention(page); + + await closeWorkspaceContextMenu(page, workspace.workspaceId); + await expectWorkspaceRowHoverCleared(page, workspace.workspaceId); + await openProjectContextMenu(page, workspace.projectKey); + await expectProjectContextMenuActions(page, workspace.projectKey); + + await closeProjectContextMenu(page, workspace.projectKey); + } finally { + await workspace.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/browser/sidebar-model-b.spec.ts b/packages/app/e2e/browser/sidebar-model-b.spec.ts index 5cf6313ca..a11d2480b 100644 --- a/packages/app/e2e/browser/sidebar-model-b.spec.ts +++ b/packages/app/e2e/browser/sidebar-model-b.spec.ts @@ -149,6 +149,12 @@ test.describe("Model B sidebar shape", () => { await expect(workspaceRow(page, activeMock.workspaceId).first()).toBeVisible({ timeout: 60_000, }); + await expect(workspaceRow(page, idleProject.workspaceId).first()).toHaveAccessibleName( + `${idleProject.projectDisplayName}, ${idleProject.workspaceName}`, + ); + await expect(workspaceRow(page, activeMock.workspaceId).first()).toHaveAccessibleName( + /Working$/, + ); // Only workspace rows are shown — no tab/agent/terminal leaves leak into // the status view. diff --git a/packages/app/e2e/helpers/project-status-badge.ts b/packages/app/e2e/helpers/project-status-badge.ts index fbcb907a8..ca8a5fb3c 100644 --- a/packages/app/e2e/helpers/project-status-badge.ts +++ b/packages/app/e2e/helpers/project-status-badge.ts @@ -102,5 +102,11 @@ function projectRow(page: Page, project: StatusProject) { } function workspaceRow(page: Page, workspaceName: string) { - return page.getByRole("button", { name: workspaceName, exact: true }); + return page.getByRole("button", { + name: new RegExp(`^${escapeRegExp(workspaceName)}(?:,|$)`), + }); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } diff --git a/packages/app/e2e/support/helpers/daemon-websocket-gate.ts b/packages/app/e2e/support/helpers/daemon-websocket-gate.ts index 82067b47e..687ab6e06 100644 --- a/packages/app/e2e/support/helpers/daemon-websocket-gate.ts +++ b/packages/app/e2e/support/helpers/daemon-websocket-gate.ts @@ -176,6 +176,10 @@ function agentStreamEventKey(type: string): string { return `agent-stream-event:${type}`; } +function agentStreamItemKey(type: string): string { + return `agent-stream-item:${type}`; +} + function agentUpdateKey(agentId: string, status: string): string { return `agent-update:${agentId}:${status}`; } @@ -526,6 +530,12 @@ export async function installDaemonWebSocketGate(page: Page) { (message) => readAgentStreamEventType(message) === type, ); }, + holdNextAgentStreamItem(type: string): void { + pendingServerMessageHolds.set( + agentStreamItemKey(type), + (message) => readAgentStreamItemType(message) === type, + ); + }, async waitForHeldServerMessage(type?: string): Promise { const key = type ? serverMessageKey(type) : null; while (!heldServerMessages.some((message) => key === null || message.key === key)) { @@ -574,6 +584,22 @@ export async function installDaemonWebSocketGate(page: Page) { heldServerMessage.browser.send(follower); } }, + async waitForHeldAgentStreamItem(type: string): Promise { + const key = agentStreamItemKey(type); + while (!heldServerMessages.some((message) => message.key === key)) { + await new Promise((resolve) => heldServerMessageWaiters.add(resolve)); + } + }, + releaseHeldAgentStreamItem(type: string): void { + const key = agentStreamItemKey(type); + const index = heldServerMessages.findIndex((message) => message.key === key); + const [heldServerMessage] = index >= 0 ? heldServerMessages.splice(index, 1) : []; + if (!heldServerMessage) throw new Error("No held agent stream item to release"); + heldServerMessage.browser.send(heldServerMessage.message); + for (const follower of heldServerMessage.agentStreamFollowers) { + heldServerMessage.browser.send(follower); + } + }, requestTimelineTail(agentId: string): void { if (!latestServer) throw new Error("No daemon WebSocket is connected"); latestServer.send( diff --git a/packages/app/e2e/support/helpers/hosts.ts b/packages/app/e2e/support/helpers/hosts.ts index 0c003fed7..f5c7d2156 100644 --- a/packages/app/e2e/support/helpers/hosts.ts +++ b/packages/app/e2e/support/helpers/hosts.ts @@ -1,5 +1,8 @@ import { expect, type Page } from "@playwright/test"; +import { identityColor, type IdentityColorName } from "@/styles/identity-colors"; +import { openSettings } from "./app"; import { buildSeededHost } from "./daemon-registry"; +import { clickSettingsBackToWorkspace, openHostSection, selectSettingsHost } from "./settings"; const REGISTRY_KEY = "@paseo:daemon-registry"; const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce"; @@ -140,3 +143,113 @@ export async function toggleHostFilter(page: Page, serverId: string): Promise { await page.getByTestId("sidebar-host-filter-all").click(); } + +export async function openHostAppearanceSettings(page: Page, serverId: string): Promise { + await openSettings(page); + await selectSettingsHost(page, serverId); + await openHostSection(page, serverId, "host"); + await expect(page.getByTestId("host-appearance-preview")).toBeVisible({ timeout: 15_000 }); +} + +// Settings replaces the workspace sidebar, so badge assertions need the app shell back. Going +// through the in-app back control (rather than a fresh goto) also keeps the auto-seed fixture +// from rewriting the host registry, since init scripts only re-run on a real navigation. +export async function leaveHostAppearanceSettings(page: Page): Promise { + await clickSettingsBackToWorkspace(page); + await expect(page.getByTestId("host-appearance-preview")).toHaveCount(0); +} + +export async function renameHostFromSettings(page: Page, name: string): Promise { + await page.getByTestId("host-page-label-edit-button").click(); + const input = page.getByTestId("host-page-rename-modal-input"); + await expect(input).toBeVisible(); + await input.fill(name); + await page.getByTestId("host-page-rename-modal-submit").click(); + await expect(input).toHaveCount(0); +} + +export async function chooseHostColor(page: Page, colorName: string): Promise { + await page.getByRole("button", { name: /^Color, / }).click(); + await page.getByRole("button", { name: colorName, exact: true }).click(); + await expect(page.getByRole("button", { name: `Color, ${colorName}` })).toBeVisible(); +} + +export async function chooseHostBadgeDisplay( + page: Page, + option: "Name" | "Icon only" | "Hidden", +): Promise { + await page.getByTestId("host-appearance-badge-display").click(); + await page.getByRole("button", { name: option, exact: true }).click(); + await expect(page.getByRole("button", { name: `Sidebar badge, ${option}` })).toBeVisible(); +} + +// The badge is visual-only; the workspace row owns its accessible name. Test IDs keep visual +// assertions from coupling to that row-level accessibility contract. +function hostBadge(page: Page, input: HostBadgeTarget) { + return page + .getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`) + .getByTestId(`sidebar-host-badge-${input.serverId}`); +} + +interface HostBadgeTarget { + serverId: string; + workspaceId: string; + hostName: string; +} + +export async function expectHostBadgeName(page: Page, target: HostBadgeTarget): Promise { + const badge = hostBadge(page, target); + await expect(badge).toBeVisible({ timeout: 15_000 }); + await expect(badge).toHaveText(target.hostName); +} + +export async function expectHostBadgeIconOnly(page: Page, target: HostBadgeTarget): Promise { + const badge = hostBadge(page, target); + await expect(badge).toBeVisible({ timeout: 15_000 }); + await expect(badge).not.toHaveText(target.hostName); + await expect( + page.getByTestId(`sidebar-workspace-row-${target.serverId}:${target.workspaceId}`), + ).toHaveAccessibleName(new RegExp(target.hostName)); +} + +export async function expectNoHostBadge(page: Page, target: HostBadgeTarget): Promise { + await expect(hostBadge(page, target)).toHaveCount(0, { timeout: 15_000 }); +} + +export async function expectHostBadgeTinted( + page: Page, + target: HostBadgeTarget & { color: IdentityColorName }, +): Promise { + const badge = hostBadge(page, target); + await expect(badge).toBeVisible({ timeout: 15_000 }); + await expect(badge.getByText(target.hostName)).toHaveCSS("color", "rgb(113, 113, 122)"); + await expect(badge.locator("svg")).toHaveAttribute("stroke", identityColor(target.color)); +} + +export async function expectHostAppearancePreview( + page: Page, + input: { serverId: string; hostName: string; color: IdentityColorName }, +): Promise { + const preview = page.getByTestId("host-appearance-preview"); + await expect(preview).toBeVisible(); + const badge = preview.getByTestId(`sidebar-host-badge-${input.serverId}`); + await expect(badge).toHaveText(input.hostName); + await expect(badge.locator("svg")).toHaveAttribute("stroke", identityColor(input.color)); +} + +// The auto-seed fixture rewrites the host registry on every navigation. Setting the +// disable-once flag to the current nonce makes it skip exactly one reload, which is what proves +// the appearance survived rather than being reseeded. +export async function reloadPreservingHostRegistry(page: Page): Promise { + await page.evaluate( + (keys) => { + const nonce = localStorage.getItem(keys.nonce); + if (!nonce) { + throw new Error("Expected the e2e seed nonce before reloading."); + } + localStorage.setItem(keys.disableSeedOnce, nonce); + }, + { nonce: SEED_NONCE_KEY, disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY }, + ); + await page.reload(); +} diff --git a/packages/app/e2e/support/helpers/sidebar.ts b/packages/app/e2e/support/helpers/sidebar.ts index dc06f26cc..0884fc5a3 100644 --- a/packages/app/e2e/support/helpers/sidebar.ts +++ b/packages/app/e2e/support/helpers/sidebar.ts @@ -1,6 +1,110 @@ import { expect, type Page } from "@playwright/test"; import { getServerId } from "./server-id"; +interface ContextMenuAnchor { + x: number; + y: number; +} + +async function openContextMenuAtRowPoint( + page: Page, + rowTestID: string, + menuTestID: string, +): Promise { + const row = page.getByTestId(rowTestID); + await expect(row).toBeVisible({ timeout: 30_000 }); + const bounds = await row.boundingBox(); + if (!bounds) throw new Error(`Could not measure ${rowTestID}`); + + const anchor = { + x: bounds.x + Math.min(60, bounds.width / 2), + y: bounds.y + bounds.height / 2, + }; + await page.mouse.click(anchor.x, anchor.y, { button: "right" }); + await expect(page.getByTestId(menuTestID)).toBeVisible({ timeout: 10_000 }); + return anchor; +} + +async function expectContextMenuAtPointer( + page: Page, + menuTestID: string, + anchor: ContextMenuAnchor, +): Promise { + const bounds = await page.getByTestId(menuTestID).boundingBox(); + if (!bounds) throw new Error(`Could not measure ${menuTestID}`); + expect(Math.abs(bounds.x - anchor.x)).toBeLessThanOrEqual(2); + expect(Math.abs(bounds.y - (anchor.y + 4))).toBeLessThanOrEqual(2); +} + +export async function openWorkspaceContextMenu(page: Page, workspaceId: string): Promise { + const workspaceKey = `${getServerId()}:${workspaceId}`; + const menuTestID = `sidebar-workspace-context-menu-${workspaceKey}`; + const anchor = await openContextMenuAtRowPoint( + page, + `sidebar-workspace-row-${workspaceKey}`, + menuTestID, + ); + await expectContextMenuAtPointer(page, menuTestID, anchor); +} + +export async function showWorkspaceHoverCard(page: Page, workspaceId: string): Promise { + const workspaceKey = `${getServerId()}:${workspaceId}`; + await page.getByTestId(`sidebar-workspace-row-${workspaceKey}`).hover(); + await expect(page.getByTestId("workspace-hover-card")).toBeVisible(); +} + +export async function closeWorkspaceContextMenu(page: Page, workspaceId: string): Promise { + const workspaceKey = `${getServerId()}:${workspaceId}`; + await page.getByTestId(`sidebar-workspace-context-menu-${workspaceKey}-backdrop`).click(); +} + +export async function expectWorkspaceContextMenuOwnsAttention(page: Page): Promise { + await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0); +} + +export async function expectWorkspaceRowHoverCleared( + page: Page, + workspaceId: string, +): Promise { + const workspaceKey = `${getServerId()}:${workspaceId}`; + await expect(page.getByTestId(`sidebar-workspace-kebab-${workspaceKey}`)).toBeHidden(); + await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0); +} + +export async function expectWorkspaceContextMenuActions( + page: Page, + workspaceId: string, +): Promise { + const workspaceKey = `${getServerId()}:${workspaceId}`; + const menu = page.getByTestId(`sidebar-workspace-context-menu-${workspaceKey}`); + await expect(menu.getByRole("button", { name: "Copy path" })).toBeVisible(); + await expect(menu.getByRole("button", { name: "Rename workspace" })).toBeVisible(); + await expect(menu.getByRole("button", { name: "Archive" })).toBeVisible(); +} + +export async function openProjectContextMenu(page: Page, projectViewKey: string): Promise { + const menuTestID = `sidebar-project-context-menu-${projectViewKey}`; + const anchor = await openContextMenuAtRowPoint( + page, + `sidebar-project-row-${projectViewKey}`, + menuTestID, + ); + await expectContextMenuAtPointer(page, menuTestID, anchor); +} + +export async function closeProjectContextMenu(page: Page, projectViewKey: string): Promise { + await page.getByTestId(`sidebar-project-context-menu-${projectViewKey}-backdrop`).click(); +} + +export async function expectProjectContextMenuActions( + page: Page, + projectViewKey: string, +): Promise { + const menu = page.getByTestId(`sidebar-project-context-menu-${projectViewKey}`); + await expect(menu.getByRole("button", { name: "Open project settings" })).toBeVisible(); + await expect(menu.getByRole("button", { name: "Remove project" })).toBeVisible(); +} + export async function selectWorkspaceInSidebar(page: Page, workspaceId: string): Promise { const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`); await expect(row).toBeVisible({ timeout: 30_000 }); diff --git a/packages/app/src/agent-stream/turn-footer.test.tsx b/packages/app/src/agent-stream/turn-footer.test.tsx new file mode 100644 index 000000000..86984e555 --- /dev/null +++ b/packages/app/src/agent-stream/turn-footer.test.tsx @@ -0,0 +1,105 @@ +/** + * @vitest-environment jsdom + */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("react-native", () => ({ + Platform: { OS: "web" }, + View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) => ( +
{children}
+ ), +})); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: unknown) => + typeof factory === "function" + ? (factory as (theme: Record) => unknown)({ + spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24 }, + colors: { + foregroundMuted: "#aaa", + palette: { amber: { 500: "#f0b429", 700: "#b7791f" } }, + }, + }) + : factory, + }, + useUnistyles: () => ({ rt: { breakpoint: "md" } }), + withUnistyles: (component: T) => component, +})); + +vi.mock("@/components/message", () => ({ + AssistantTurnFooter: () => null, + LiveElapsed: () => , + STREAM_METADATA_FONT_SIZE: 11, +})); + +vi.mock("@/components/assistant-fork-menu", () => ({ + AssistantForkMenu: () =>