diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 3ed95fd510..da5e244bc1 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -24,6 +24,7 @@ export default defineConfig({ "**/channel-browser.spec.ts", "**/messaging.spec.ts", "**/mentions.spec.ts", + "**/smart-links.spec.ts", "**/workflows.spec.ts", ], use: { diff --git a/desktop/src/features/messages/ui/TypingIndicatorRow.tsx b/desktop/src/features/messages/ui/TypingIndicatorRow.tsx index d7a2f88d67..ab1b875dd8 100644 --- a/desktop/src/features/messages/ui/TypingIndicatorRow.tsx +++ b/desktop/src/features/messages/ui/TypingIndicatorRow.tsx @@ -1,11 +1,17 @@ import * as React from "react"; +import { + useManagedAgentLogQuery, + useManagedAgentsQuery, + useStopManagedAgentMutation, +} from "@/features/agents/hooks"; import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; -import type { Channel } from "@/shared/api/types"; +import type { Channel, ManagedAgent } from "@/shared/api/types"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; type TypingIndicatorRowProps = { channel: Channel | null; @@ -46,12 +52,123 @@ function formatTypingLabel(names: string[]) { return `${names[0]}, ${names[1]}, and ${names.length - 2} others are typing...`; } +function formatElapsed(startIso: string): string { + const startMs = new Date(startIso).getTime(); + const nowMs = Date.now(); + const totalSeconds = Math.max(0, Math.floor((nowMs - startMs) / 1000)); + + if (totalSeconds >= 3600) { + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + return `${hours}h ${minutes}m`; + } + + if (totalSeconds >= 60) { + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}m ${seconds}s`; + } + + return `${totalSeconds}s`; +} + +/** Compact ACP log preview — dark terminal block, last N lines. */ +function AgentLogPreview({ pubkey }: { pubkey: string }) { + const { data: logData, isLoading } = useManagedAgentLogQuery(pubkey, 10); + + if (isLoading) { + return ( +
+ {trimmed}
+
+ - {formatTypingLabel(labels)} -
+ {hasBotTypers ? ( ++ {typingText} +
+ )}{children} diff --git a/desktop/tests/e2e/smart-links.spec.ts b/desktop/tests/e2e/smart-links.spec.ts new file mode 100644 index 0000000000..f0f44e4410 --- /dev/null +++ b/desktop/tests/e2e/smart-links.spec.ts @@ -0,0 +1,138 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +test.beforeEach(async ({ page }) => { + await installMockBridge(page); +}); + +test("GitHub PR URL renders as an inline smart chip", async ({ page }) => { + const prUrl = "https://github.com/block/goose2/pull/125"; + const message = `Check out ${prUrl} for the fix`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill(message); + await page.getByTestId("send-message").click(); + + const lastRow = page.getByTestId("message-row").last(); + + // The PR link should render as a styled chip with the repo and PR number + const prChip = lastRow.locator("a", { hasText: "block/goose2#125" }); + await expect(prChip).toBeVisible(); + await expect(prChip).toHaveAttribute("href", prUrl); + + // Should contain the GitPullRequest icon (rendered as an SVG) + await expect(prChip.locator("svg")).toBeVisible(); +}); + +test("GitHub PR chip links open in new tab", async ({ page }) => { + const prUrl = "https://github.com/block/sprout/pull/42"; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill(prUrl); + await page.getByTestId("send-message").click(); + + const lastRow = page.getByTestId("message-row").last(); + const prChip = lastRow.locator("a", { hasText: "block/sprout#42" }); + await expect(prChip).toHaveAttribute("target", "_blank"); + await expect(prChip).toHaveAttribute("rel", "noreferrer"); +}); + +test("selecting a PR chip copies the full URL, not the chip label", async ({ + page, +}) => { + const prUrl = "https://github.com/block/goose2/pull/125"; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill(prUrl); + await page.getByTestId("send-message").click(); + + const lastRow = page.getByTestId("message-row").last(); + const prChip = lastRow.locator("a", { hasText: "block/goose2#125" }); + await expect(prChip).toBeVisible(); + + // The hidden span should contain the full URL for selection/copy + const hiddenUrl = prChip.locator("span.overflow-hidden"); + await expect(hiddenUrl).toHaveText(prUrl); + + // The visible label should not be selectable + const visibleLabel = prChip.locator("span.select-none"); + await expect(visibleLabel).toBeVisible(); +}); + +test("GitHub issue URL renders as an inline smart chip", async ({ page }) => { + const issueUrl = "https://github.com/block/sprout/issues/99"; + const message = `See ${issueUrl} for context`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill(message); + await page.getByTestId("send-message").click(); + + const lastRow = page.getByTestId("message-row").last(); + + const issueChip = lastRow.locator("a", { hasText: "block/sprout#99" }); + await expect(issueChip).toBeVisible(); + await expect(issueChip).toHaveAttribute("href", issueUrl); + await expect(issueChip.locator("svg")).toBeVisible(); +}); + +test("GitHub commit URL renders as an inline smart chip", async ({ page }) => { + const commitUrl = + "https://github.com/block/sprout/commit/abc1234def5678901234567890abcdef12345678"; + const message = `Reverted in ${commitUrl}`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill(message); + await page.getByTestId("send-message").click(); + + const lastRow = page.getByTestId("message-row").last(); + + // Should show short SHA + const commitChip = lastRow.locator("a", { + hasText: "block/sprout@abc1234", + }); + await expect(commitChip).toBeVisible(); + await expect(commitChip).toHaveAttribute("href", commitUrl); + await expect(commitChip.locator("svg")).toBeVisible(); +}); + +test("non-PR GitHub links render as regular links", async ({ page }) => { + const repoUrl = "https://github.com/block/sprout"; + const message = `Check out ${repoUrl}`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill(message); + await page.getByTestId("send-message").click(); + + const lastRow = page.getByTestId("message-row").last(); + + // Should render as a normal underlined link, not a chip + const link = lastRow.locator("a", { hasText: repoUrl }); + await expect(link).toBeVisible(); + // Regular links have underline styling, not the chip background + await expect(link.locator("svg")).not.toBeVisible(); +});