diff --git a/packages/app/e2e/browser/workspace-agent-tab-tooltip.spec.ts b/packages/app/e2e/browser/workspace-agent-tab-tooltip.spec.ts new file mode 100644 index 0000000000..c5870d0134 --- /dev/null +++ b/packages/app/e2e/browser/workspace-agent-tab-tooltip.spec.ts @@ -0,0 +1,77 @@ +import type { Locator } from "@playwright/test"; +import { expect, test, type Page } from "../support/fixtures"; +import { createIdleAgent } from "../support/helpers/archive-tab"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; +import { buildHostAgentDetailRoute } from "@/utils/host-routes"; + +const TITLE_MAX_LENGTH = 80; + +function normalizedTitle(title: string): string { + return title.replace(/\s+/g, " ").trim(); +} + +function expectedTooltipTitle(title: string): string { + const singleLineTitle = normalizedTitle(title); + return singleLineTitle.length <= TITLE_MAX_LENGTH + ? singleLineTitle + : `${singleLineTitle.slice(0, TITLE_MAX_LENGTH - 1).trimEnd()}…`; +} + +async function openAgent(page: Page, agent: { id: string; workspaceId: string }): Promise { + await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, agent.workspaceId)); + await page.waitForURL( + (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), + { timeout: 60_000 }, + ); + await waitForWorkspaceTabsVisible(page); +} + +async function openAgentTabTooltip(page: Page, agentId: string, title: string): Promise { + await page.getByRole("button", { name: normalizedTitle(title), exact: true }).hover(); + const tooltip = page.getByTestId(`workspace-tab-tooltip-agent_${agentId}`); + await expect(tooltip).toBeVisible({ timeout: 10_000 }); + return tooltip; +} + +async function expectTwoRowAgentSummary( + tooltip: Locator, + input: { title: string; agentId: string }, +): Promise { + const title = tooltip.getByText(expectedTooltipTitle(input.title), { exact: true }); + const id = tooltip.getByText(input.agentId.slice(0, 7), { exact: true }); + const activity = tooltip.getByText(/^(just now|\d+[mhd] ago|[A-Z][a-z]{2} \d{1,2})$/); + + await expect(title).toBeVisible(); + await expect(id).toBeVisible(); + await expect(tooltip.getByText("·", { exact: true })).toBeVisible(); + await expect(activity).toBeVisible(); + expect((await title.boundingBox())?.y).toBeLessThan((await id.boundingBox())?.y ?? 0); +} + +test.describe("Workspace agent tab tooltip", () => { + test("summarizes a multiline agent title above its ID and last activity", async ({ page }) => { + test.setTimeout(120_000); + const workspace = await seedWorkspace({ repoPrefix: "workspace-agent-tooltip-" }); + + try { + const title = [ + "Short first line", + "followed by a much longer continuation that should be joined before the complete title is truncated to a useful tooltip length", + ].join("\n"); + const agent = await createIdleAgent(workspace.client, { + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title, + }); + + await openAgent(page, agent); + const tooltip = await openAgentTabTooltip(page, agent.id, title); + + await expectTwoRowAgentSummary(tooltip, { title, agentId: agent.id }); + } finally { + await workspace.cleanup(); + } + }); +}); diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index d7284a22c1..b462cf0cd5 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -68,6 +68,7 @@ import type { Theme } from "@/styles/theme"; import { RenderProfile } from "@/utils/render-profiler"; import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; +import { useCompactTimeAgo } from "@/hooks/use-compact-time-ago"; import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import { WorkspaceNewTabMenuContent } from "@/screens/workspace/workspace-new-tab-menu"; @@ -81,6 +82,7 @@ import { HorizontalScrollBoundaryShades, useHorizontalScrollBoundary, } from "@/components/ui/horizontal-scroll-boundary"; +import { useSessionStore } from "@/stores/session-store"; const DROPDOWN_WIDTH = 220; const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36; @@ -108,6 +110,7 @@ const TAB_MIN_WIDTH = 96; const TAB_MAX_WIDTH = 160; const TAB_CLOSE_BUTTON_RESERVED_WIDTH = 0; const TAB_LABEL_LAYOUT_ALLOWANCE = 4; +const AGENT_TOOLTIP_TITLE_MAX_LENGTH = 80; const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedX = withUnistyles(X); @@ -136,6 +139,56 @@ function updateMeasuredWidth( setWidth((current) => retainWorkspaceTabMeasuredWidth(current, nextWidth)); } +function normalizeAgentTooltipTitle(title: string): string { + return title.replace(/\s+/g, " ").trim(); +} + +function formatAgentTooltipTitle(singleLineTitle: string): string { + if (singleLineTitle.length <= AGENT_TOOLTIP_TITLE_MAX_LENGTH) return singleLineTitle; + return `${singleLineTitle.slice(0, AGENT_TOOLTIP_TITLE_MAX_LENGTH - 1).trimEnd()}…`; +} + +function formatAgentTooltipActivity(compactActivity: string): string { + if (compactActivity === "now") return "just now"; + if (/^\d/.test(compactActivity)) return `${compactActivity} ago`; + return compactActivity; +} + +function AgentTabTooltipBody({ + serverId, + agentId, + title, +}: { + serverId: string; + agentId: string; + title: string; +}) { + const lastActivityAt = useSessionStore((state) => { + const session = state.sessions[serverId]; + const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null; + return state.agentLastActivity.get(agentId) ?? agent?.lastActivityAt ?? null; + }); + const compactActivity = useCompactTimeAgo(lastActivityAt); + const activity = formatAgentTooltipActivity(compactActivity); + + return ( + + + {title} + + + {agentId.slice(0, 7)} + {activity ? ( + <> + · + {activity} + + ) : null} + + + ); +} + function TabLabelMeasurement({ tabKey, label, @@ -648,6 +701,7 @@ function TabHandleContent({ } function TabChip({ + serverId, tab, isActive, isDragging, @@ -659,12 +713,14 @@ function TabChip({ isClosingTab, presentation, tooltipLabel, + accessibilityLabel, resolvedTab, setHoveredCloseTabKey, onNavigateTab, onCloseTab, dragHandleProps, }: { + serverId: string; tab: WorkspaceTabDescriptor; isActive: boolean; isDragging: boolean; @@ -676,6 +732,7 @@ function TabChip({ isClosingTab: boolean; presentation: WorkspaceTabPresentation; tooltipLabel: string; + accessibilityLabel: string; resolvedTab: WorkspaceDesktopTabActions; setHoveredCloseTabKey: Dispatch>; onNavigateTab: (tabId: string) => void; @@ -786,7 +843,7 @@ function TabChip({ onPressIn={handleNavigateTab} onPress={handleNavigateTab} accessibilityRole="button" - accessibilityLabel={tooltipLabel} + accessibilityLabel={accessibilityLabel} accessibilityState={tabAccessibilityState} aria-selected={isActive} > @@ -809,10 +866,11 @@ function TabChip({ testID={`workspace-tab-tooltip-${testIdentity}`} > {tab.target.kind === "agent" ? ( - - {tooltipLabel} - {tab.target.agentId.slice(0, 7)} - + ) : ( {tooltipLabel} )} @@ -1191,6 +1249,7 @@ function ResolvedWorkspaceDesktopTabsRow({ return ( {row}; } function ResolvedDesktopTabChip({ + serverId, item, isFocused, isDragging, @@ -1361,6 +1422,7 @@ function ResolvedDesktopTabChip({ showDropIndicatorBefore, showDropIndicatorAfter, }: { + serverId: string; item: ResolvedWorkspaceDesktopTabRowItem; isFocused: boolean; isDragging: boolean; @@ -1424,10 +1486,18 @@ function ResolvedDesktopTabChip({ ], ); - const tooltipLabel = + const rawTooltipLabel = presentation.titleState === "loading" ? t("workspace.tabs.loadingAgentTitle") : presentation.tooltip; + const accessibilityLabel = + item.tab.target.kind === "agent" + ? normalizeAgentTooltipTitle(rawTooltipLabel) + : rawTooltipLabel; + const tooltipLabel = + item.tab.target.kind === "agent" + ? formatAgentTooltipTitle(accessibilityLabel) + : rawTooltipLabel; return ( @@ -1435,6 +1505,7 @@ function ResolvedDesktopTabChip({ ) : null} ({ color: theme.colors.foreground, fontSize: theme.fontSize.base, }, - tooltipAgentRow: { + tooltipAgentContent: { + gap: theme.spacing[0.5], + maxWidth: 420, + }, + agentTooltipTitle: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + }, + tooltipAgentMetadata: { flexDirection: "row", alignItems: "center", - gap: theme.spacing[2], + gap: theme.spacing[1], }, tooltipAgentId: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, + tooltipAgentSeparator: { + color: theme.colors.foregroundExtraMuted, + fontSize: theme.fontSize.sm, + }, + tooltipAgentActivity: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, menuItemHint: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm,