From 9d029410e4ff740f0fc5514d74f92cb29575ef91 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 8 Apr 2026 14:06:57 -1000 Subject: [PATCH 1/6] feat(desktop): render GitHub PR links as inline smart chips GitHub pull request URLs are now displayed as compact inline chips with a pull request icon and owner/repo#number label instead of raw URLs, matching the style of @mention and #channel-link chips. Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/src/shared/ui/markdown.tsx | 44 ++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 2997f394de..ad980a6b44 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -1,3 +1,4 @@ +import { GitPullRequest } from "lucide-react"; import * as React from "react"; import ReactMarkdown, { type Components } from "react-markdown"; import remarkBreaks from "remark-breaks"; @@ -11,6 +12,9 @@ import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import remarkChannelLinks from "@/shared/lib/remarkChannelLinks"; import remarkMentions from "@/shared/lib/remarkMentions"; +const GITHUB_PR_RE = + /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/; + type MarkdownProps = { channelNames?: string[]; className?: string; @@ -41,17 +45,35 @@ function createMarkdownComponents( : "space-y-1 pl-6 marker:text-muted-foreground"; return { - a: ({ children, href, ...props }) => ( - - {children} - - ), + a: ({ children, href, ...props }) => { + const prMatch = href ? GITHUB_PR_RE.exec(href) : null; + if (prMatch) { + const [, owner, repo, number] = prMatch; + return ( + + + {owner}/{repo}#{number} + + ); + } + return ( + + {children} + + ); + }, blockquote: ({ children }) => (
{children} From d4043920a4149884f9fa1ca1ec1df2db181c35c9 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 8 Apr 2026 14:10:45 -1000 Subject: [PATCH 2/6] test: add e2e tests for GitHub PR smart link rendering Verifies PR URLs render as inline chips with icon and owner/repo#number, open in new tabs, and that non-PR GitHub links remain regular links. Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/playwright.config.ts | 1 + desktop/tests/e2e/smart-links.spec.ts | 68 +++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 desktop/tests/e2e/smart-links.spec.ts 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/tests/e2e/smart-links.spec.ts b/desktop/tests/e2e/smart-links.spec.ts new file mode 100644 index 0000000000..e047c604a8 --- /dev/null +++ b/desktop/tests/e2e/smart-links.spec.ts @@ -0,0 +1,68 @@ +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("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(); +}); From a3784fc00a579297d5b16227bfdeba17d2aed470 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 8 Apr 2026 14:14:34 -1000 Subject: [PATCH 3/6] feat(desktop): make PR smart chips copy the full URL on text selection The visible chip label (icon + owner/repo#number) is marked user-select:none while a hidden zero-width span holds the full URL for clipboard copy. Adds an e2e test verifying the DOM structure. Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/src/shared/ui/markdown.tsx | 9 ++++++--- desktop/tests/e2e/smart-links.spec.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index ad980a6b44..84fcf0b9bf 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -52,13 +52,16 @@ function createMarkdownComponents( return ( - - {owner}/{repo}#{number} + + {href} ); } diff --git a/desktop/tests/e2e/smart-links.spec.ts b/desktop/tests/e2e/smart-links.spec.ts index e047c604a8..8030492f6e 100644 --- a/desktop/tests/e2e/smart-links.spec.ts +++ b/desktop/tests/e2e/smart-links.spec.ts @@ -46,6 +46,32 @@ test("GitHub PR chip links open in new tab", async ({ page }) => { 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("non-PR GitHub links render as regular links", async ({ page }) => { const repoUrl = "https://github.com/block/sprout"; const message = `Check out ${repoUrl}`; From 8d624903e9ca4fb682f8853d62ae25caf82edf87 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 8 Apr 2026 15:18:27 -1000 Subject: [PATCH 4/6] feat(desktop): add smart chips for GitHub issue and commit URLs Extend smart link rendering to convert GitHub issue URLs (CircleDot icon) and commit URLs (GitCommitHorizontal icon, 7-char short SHA) into inline chips, matching the existing PR chip pattern. Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/src/shared/ui/markdown.tsx | 69 +++++++++++++++++++-------- desktop/tests/e2e/smart-links.spec.ts | 44 +++++++++++++++++ 2 files changed, 94 insertions(+), 19 deletions(-) diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 84fcf0b9bf..8dd10b3d58 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -1,4 +1,4 @@ -import { GitPullRequest } from "lucide-react"; +import { CircleDot, GitCommitHorizontal, GitPullRequest } from "lucide-react"; import * as React from "react"; import ReactMarkdown, { type Components } from "react-markdown"; import remarkBreaks from "remark-breaks"; @@ -15,6 +15,12 @@ import remarkMentions from "@/shared/lib/remarkMentions"; const GITHUB_PR_RE = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/; +const GITHUB_ISSUE_RE = + /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)\/?$/; + +const GITHUB_COMMIT_RE = + /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/commit\/([0-9a-f]{7,40})\/?$/; + type MarkdownProps = { channelNames?: string[]; className?: string; @@ -46,25 +52,50 @@ function createMarkdownComponents( return { a: ({ children, href, ...props }) => { - const prMatch = href ? GITHUB_PR_RE.exec(href) : null; - if (prMatch) { - const [, owner, repo, number] = prMatch; - return ( - - - {href} - - ); + if (href) { + let Icon: React.ComponentType<{ className?: string }> | null = null; + let label: string | null = null; + + const prMatch = GITHUB_PR_RE.exec(href); + if (prMatch) { + const [, owner, repo, number] = prMatch; + Icon = GitPullRequest; + label = `${owner}/${repo}#${number}`; + } + + const issueMatch = !Icon ? GITHUB_ISSUE_RE.exec(href) : null; + if (issueMatch) { + const [, owner, repo, number] = issueMatch; + Icon = CircleDot; + label = `${owner}/${repo}#${number}`; + } + + const commitMatch = !Icon ? GITHUB_COMMIT_RE.exec(href) : null; + if (commitMatch) { + const [, owner, repo, sha] = commitMatch; + Icon = GitCommitHorizontal; + label = `${owner}/${repo}@${sha.slice(0, 7)}`; + } + + if (Icon && label) { + return ( + + + {href} + + ); + } } + return ( { + 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}`; From 95e4919a6893e78ff87c862705aa00adf4701aa6 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 9 Apr 2026 07:53:14 -1000 Subject: [PATCH 5/6] feat(desktop): add agent runtime popover to typing indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Click the typing indicator when a bot is typing to see: - Agent name, command, and model - Live-ticking elapsed runtime from lastStartedAt - Destructive 'Interrupt' button that calls stopManagedAgent Human typers are unchanged — no popover shown. Hooks (useManagedAgentsQuery, useStopManagedAgentMutation) live inside TypingIndicatorRow — zero prop changes to ChannelPane. --- .../messages/ui/TypingIndicatorRow.tsx | 127 +++++++++++++++++- 1 file changed, 120 insertions(+), 7 deletions(-) diff --git a/desktop/src/features/messages/ui/TypingIndicatorRow.tsx b/desktop/src/features/messages/ui/TypingIndicatorRow.tsx index d7a2f88d67..aaaaf9afc2 100644 --- a/desktop/src/features/messages/ui/TypingIndicatorRow.tsx +++ b/desktop/src/features/messages/ui/TypingIndicatorRow.tsx @@ -1,11 +1,16 @@ import * as React from "react"; +import { + 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 +51,91 @@ 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`; +} + +type BotTypingPopoverContentProps = { + botAgents: ManagedAgent[]; +}; + +function BotTypingPopoverContent({ botAgents }: BotTypingPopoverContentProps) { + const [, setTick] = React.useState(0); + const mutation = useStopManagedAgentMutation(); + + React.useEffect(() => { + const interval = setInterval(() => { + setTick((prev) => prev + 1); + }, 1000); + return () => clearInterval(interval); + }, []); + + function handleInterrupt() { + for (const agent of botAgents) { + mutation.mutate(agent.pubkey); + } + } + + return ( +
+ {botAgents.map((agent) => ( +
+
{agent.name}
+ {agent.model && ( +
{agent.model}
+ )} +
+ {agent.lastStartedAt ? formatElapsed(agent.lastStartedAt) : "—"} +
+
+ ))} + +
+ ); +} + export function TypingIndicatorRow({ channel, currentPubkey, profiles, typingPubkeys, }: TypingIndicatorRowProps) { + const { data: managedAgents } = useManagedAgentsQuery(); + + const managedAgentMap = React.useMemo(() => { + const map = new Map(); + if (managedAgents) { + for (const agent of managedAgents) { + map.set(agent.pubkey.toLowerCase(), agent); + } + } + return map; + }, [managedAgents]); + const labels = React.useMemo( () => typingPubkeys.map((pubkey) => @@ -66,10 +150,22 @@ export function TypingIndicatorRow({ [channel, currentPubkey, profiles, typingPubkeys], ); + const botAgents = React.useMemo( + () => + typingPubkeys + .map((pubkey) => managedAgentMap.get(pubkey.toLowerCase())) + .filter((agent): agent is ManagedAgent => agent !== undefined), + [typingPubkeys, managedAgentMap], + ); + + const hasBotTypers = botAgents.length > 0; + if (labels.length === 0) { return null; } + const typingText = formatTypingLabel(labels); + return (
-

- {formatTypingLabel(labels)} -

+ {hasBotTypers ? ( + + + + + + + + + ) : ( +

+ {typingText} +

+ )}
); From 7fa0af4152901a8b724cb20f2d7458d788d5b16e Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 9 Apr 2026 08:24:52 -1000 Subject: [PATCH 6/6] feat(desktop): add ACP log preview to typing indicator popover Show last 10 lines of agent harness log in the typing indicator popover, dark terminal style with 5s auto-refresh. Gives observability into what the agent is doing before interrupting. --- .../messages/ui/TypingIndicatorRow.tsx | 45 ++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/desktop/src/features/messages/ui/TypingIndicatorRow.tsx b/desktop/src/features/messages/ui/TypingIndicatorRow.tsx index aaaaf9afc2..ab1b875dd8 100644 --- a/desktop/src/features/messages/ui/TypingIndicatorRow.tsx +++ b/desktop/src/features/messages/ui/TypingIndicatorRow.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { + useManagedAgentLogQuery, useManagedAgentsQuery, useStopManagedAgentMutation, } from "@/features/agents/hooks"; @@ -71,6 +72,35 @@ function formatElapsed(startIso: string): string { 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 ( +
+ Loading log… +
+ ); + } + + const trimmed = logData?.content?.trim(); + if (!trimmed) { + return null; + } + + return ( +
+
+        {trimmed}
+      
+
+ ); +} + type BotTypingPopoverContentProps = { botAgents: ManagedAgent[]; }; @@ -95,14 +125,17 @@ function BotTypingPopoverContent({ botAgents }: BotTypingPopoverContentProps) { return (
{botAgents.map((agent) => ( -
-
{agent.name}
+
+
+
{agent.name}
+
+ {agent.lastStartedAt ? formatElapsed(agent.lastStartedAt) : "—"} +
+
{agent.model && (
{agent.model}
)} -
- {agent.lastStartedAt ? formatElapsed(agent.lastStartedAt) : "—"} -
+
))}