mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 18:22:38 +00:00
Make agent tab tooltips compact and informative (#4214)
Join and truncate prompt-derived titles for the visual tooltip, add agent ID and live human activity metadata on a second row, and keep activity subscriptions local to the open tooltip. Preserve the full normalized title as the accessible tab name and cover the behavior through Playwright.
This commit is contained in:
@@ -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<void> {
|
||||
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<Locator> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<View style={styles.tooltipAgentContent}>
|
||||
<Text style={styles.agentTooltipTitle} numberOfLines={1} ellipsizeMode="tail">
|
||||
{title}
|
||||
</Text>
|
||||
<View style={styles.tooltipAgentMetadata}>
|
||||
<Text style={styles.tooltipAgentId}>{agentId.slice(0, 7)}</Text>
|
||||
{activity ? (
|
||||
<>
|
||||
<Text style={styles.tooltipAgentSeparator}>·</Text>
|
||||
<Text style={styles.tooltipAgentActivity}>{activity}</Text>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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<SetStateAction<string | null>>;
|
||||
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" ? (
|
||||
<View style={styles.tooltipAgentRow}>
|
||||
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
|
||||
<Text style={styles.tooltipAgentId}>{tab.target.agentId.slice(0, 7)}</Text>
|
||||
</View>
|
||||
<AgentTabTooltipBody
|
||||
serverId={serverId}
|
||||
agentId={tab.target.agentId}
|
||||
title={tooltipLabel}
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
|
||||
)}
|
||||
@@ -1191,6 +1249,7 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
return (
|
||||
<ResolvedDesktopTabChip
|
||||
key={`${item.tab.key}:${item.tab.kind}`}
|
||||
serverId={normalizedServerId}
|
||||
item={item}
|
||||
isFocused={isFocused}
|
||||
isDragging={isActive}
|
||||
@@ -1223,6 +1282,7 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
isFocused,
|
||||
layout.closeButtonPolicy,
|
||||
layout.items,
|
||||
normalizedServerId,
|
||||
onCloseOtherTabs,
|
||||
onCloseTab,
|
||||
onCloseTabsToLeft,
|
||||
@@ -1336,6 +1396,7 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
return <RenderProfile id="WorkspaceDesktopTabsRow">{row}</RenderProfile>;
|
||||
}
|
||||
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 (
|
||||
<View style={styles.tabSlot}>
|
||||
@@ -1435,6 +1505,7 @@ function ResolvedDesktopTabChip({
|
||||
<View style={[styles.tabDropIndicator, styles.tabDropIndicatorBefore]} />
|
||||
) : null}
|
||||
<TabChip
|
||||
serverId={serverId}
|
||||
tab={item.tab}
|
||||
isActive={item.isActive}
|
||||
isDragging={isDragging}
|
||||
@@ -1446,6 +1517,7 @@ function ResolvedDesktopTabChip({
|
||||
isClosingTab={item.isClosingTab}
|
||||
presentation={presentation}
|
||||
tooltipLabel={tooltipLabel}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
resolvedTab={resolvedTab}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onNavigateTab={onNavigateTab}
|
||||
@@ -1641,15 +1713,31 @@ const styles = StyleSheet.create((theme) => ({
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user