From f73e2847baf403ac402773db662e2cbee83a8cb5 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:35:23 -0600 Subject: [PATCH] fix(app): make URLs in questions, MCP prompts and errors clickable (#61514) --- .../session-question-dock.spec.ts | 23 +++++++++++++ .../src/session/question-switch.stories.tsx | 28 ++++++++++++++++ .../requests/session-elicitation-dock.tsx | 11 +++++-- .../session-question-dock-classic.tsx | 5 ++- .../session-question-dock-experimental.tsx | 5 ++- .../src/session/session-screen.stories.tsx | 14 ++++++-- .../component-tests/session-timeline.spec.ts | 9 ++++++ packages/session-ui/package.json | 1 + .../src/components/session-error.tsx | 13 +++++--- .../src/message/message-content.tsx | 3 +- .../src/timeline/error-card.stories.tsx | 32 +++++++++++++++++++ .../session-ui/src/tools/tool-renderer.tsx | 4 ++- 12 files changed, 136 insertions(+), 12 deletions(-) diff --git a/packages/app/component-tests/session-question-dock.spec.ts b/packages/app/component-tests/session-question-dock.spec.ts index a9bd0538125..6f6d469e9d0 100644 --- a/packages/app/component-tests/session-question-dock.spec.ts +++ b/packages/app/component-tests/session-question-dock.spec.ts @@ -64,8 +64,31 @@ for (const variant of ["classic", "experimental"] as const) { await files.click({ position: { x: 4, y: 4 } }) await expect(files).not.toBeChecked() }) + + story(`${variant} question text links URLs`, async ({ mount, page }) => { + await page.addInitScript( + (enabled) => localStorage.setItem("settings.v3", JSON.stringify({ general: { newQuestionUI: enabled } })), + variant === "experimental", + ) + + const component = await mount("session-question-switch--link-prompt") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + const url = "https://www.npmjs.com/auth/cli/2ff15723-dfa2-4eec-addd-53cb3ff7597f" + const link = question.locator('[data-slot="question-text"]').getByRole("link", { name: url, exact: true }) + + await expect(link).toHaveAttribute("href", url) + await expect(link).toHaveAttribute("target", "_blank") + }) } +story("MCP elicitation message and field descriptions link URLs", async ({ mount }) => { + const component = await mount("app-current-session-surface--mcp-elicitation-request") + const dock = component.locator('[data-component="dock-prompt"][data-kind="question"]') + + for (const url of ["https://example.com/releases/v2.4.0", "https://example.com/docs/release-notes"]) + await expect(dock.getByRole("link", { name: url, exact: true })).toHaveAttribute("href", url) +}) + story( "uses the original question cards by default and submits selected and custom answers", async ({ mount, page }) => { diff --git a/packages/app/src/session/question-switch.stories.tsx b/packages/app/src/session/question-switch.stories.tsx index 4fd21095177..aaaead6a806 100644 --- a/packages/app/src/session/question-switch.stories.tsx +++ b/packages/app/src/session/question-switch.stories.tsx @@ -103,6 +103,34 @@ export const QuestionSeries = { ), } +// A question whose text includes a URL to open before answering. +export const LinkPrompt = { + render: () => ( + + ), +} + // A pick-any question with more answers than the dock has room for, then a short one. export const LongList = { render: () => ( diff --git a/packages/app/src/session/requests/session-elicitation-dock.tsx b/packages/app/src/session/requests/session-elicitation-dock.tsx index 5b47798e5a4..fcc69ffcf7b 100644 --- a/packages/app/src/session/requests/session-elicitation-dock.tsx +++ b/packages/app/src/session/requests/session-elicitation-dock.tsx @@ -9,6 +9,7 @@ import { Select } from "@opencode/ui/select" import { Switch } from "@opencode/ui/switch" import { TextInput } from "@opencode/ui/text-input" import { DockPrompt } from "@opencode/session-ui/dock-prompt" +import { LinkedText } from "@opencode/session-ui/linked-text" import { showToast } from "@/shell/notifications/toast" import { useLanguage } from "@/runtime/i18n/language" import { usePlatform } from "@/runtime/platform/platform" @@ -136,7 +137,9 @@ export function SessionElicitationDock(props: { {(text) => (
- {text()} + + +
)} @@ -276,7 +279,11 @@ function Label(props: { field: Field }) { {props.field.title} - {props.field.description} + {(description) => ( + + + + )} diff --git a/packages/app/src/session/requests/session-question-dock-classic.tsx b/packages/app/src/session/requests/session-question-dock-classic.tsx index 4d90c980c5e..1008113e77d 100644 --- a/packages/app/src/session/requests/session-question-dock-classic.tsx +++ b/packages/app/src/session/requests/session-question-dock-classic.tsx @@ -5,6 +5,7 @@ import { Button } from "@opencode/ui/button" import { IconButton } from "@opencode/ui/icon-button" import { Tooltip } from "@opencode/ui/tooltip" import { DockPrompt } from "@opencode/session-ui/dock-prompt" +import { LinkedText } from "@opencode/session-ui/linked-text" import { Icon } from "@opencode/ui/icon" import { Keybind } from "@opencode/ui/keybind" import { TextInput } from "@opencode/ui/text-input" @@ -678,7 +679,9 @@ export const SessionQuestionDockClassic: Component<{ request: FormInfo; onSubmit overflow: store.minimized ? "hidden" : undefined, }} > - {question()?.question} + + +
diff --git a/packages/app/src/session/requests/session-question-dock-experimental.tsx b/packages/app/src/session/requests/session-question-dock-experimental.tsx index 7949c6873a9..41d7af0dc3f 100644 --- a/packages/app/src/session/requests/session-question-dock-experimental.tsx +++ b/packages/app/src/session/requests/session-question-dock-experimental.tsx @@ -6,6 +6,7 @@ import { IconButton } from "@opencode/ui/icon-button" import { RollingNumber } from "@opencode/ui/rolling-number" import { Tooltip } from "@opencode/ui/tooltip" import { DockPrompt } from "@opencode/session-ui/dock-prompt" +import { LinkedText } from "@opencode/session-ui/linked-text" import { Icon } from "@opencode/ui/icon" import { TextInput } from "@opencode/ui/text-input" import { frameLoop, Spring, type DurationOptions } from "@opencode/ui/motion" @@ -1209,7 +1210,9 @@ export const SessionQuestionDockExperimental: Component<{ request: FormInfo; onS overflow: store.minimized ? "hidden" : undefined, }} > - {question()?.question} + + +
{language.t("ui.question.multiHint")}
diff --git a/packages/app/src/session/session-screen.stories.tsx b/packages/app/src/session/session-screen.stories.tsx index 797ec7509c2..4386d68f68b 100644 --- a/packages/app/src/session/session-screen.stories.tsx +++ b/packages/app/src/session/session-screen.stories.tsx @@ -188,7 +188,11 @@ export const McpElicitationRequest = { id: "frm_elicitation_preview", sessionID: "global", title: "release is requesting input", - metadata: { kind: "mcp-elicitation", server: "release", message: "Publish v2.4.0 to the selected channel?" }, + metadata: { + kind: "mcp-elicitation", + server: "release", + message: "Publish v2.4.0 to the selected channel? Changelog: https://example.com/releases/v2.4.0", + }, fields: [ { key: "approve", type: "boolean", title: "Approve publish", required: true }, { @@ -200,7 +204,13 @@ export const McpElicitationRequest = { { value: "latest", label: "Latest" }, ], }, - { key: "notes", type: "string", title: "Release notes", placeholder: "Optional summary" }, + { + key: "notes", + type: "string", + title: "Release notes", + description: "Follow https://example.com/docs/release-notes", + placeholder: "Optional summary", + }, { key: "retries", type: "integer", title: "Retries", minimum: 0, default: 2 }, ], }, diff --git a/packages/session-ui/component-tests/session-timeline.spec.ts b/packages/session-ui/component-tests/session-timeline.spec.ts index 962b7a1ae24..6fcb51270ce 100644 --- a/packages/session-ui/component-tests/session-timeline.spec.ts +++ b/packages/session-ui/component-tests/session-timeline.spec.ts @@ -84,6 +84,15 @@ story("centers the error icon on the first line of short and wrapped errors", as geometry.forEach((card) => expect(card.offset).toBeCloseTo(0, 0)) }) +story("links URLs in an error without splitting them at a colon", async ({ mount }) => { + const timeline = await mount("current-session-error-card--linked-error") + const card = timeline.locator('[data-kind="session-error-card"]') + const url = "http://localhost:11434/api" + + await expect(card.getByRole("link", { name: url, exact: true })).toHaveAttribute("href", url) + await expect(card.locator("strong")).toHaveCount(0) +}) + // Moved from packages/app/e2e/regression/session-timeline-context-state.spec.ts story("preserves a collapsed context group through count and status updates", async ({ mount }) => { const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "exploration" } }) diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 530ecefb2f9..71ef5ee3439 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -17,6 +17,7 @@ "./file": "./src/components/file.tsx", "./file-media": "./src/components/file-media.tsx", "./file-search": "./src/components/file-search.tsx", + "./linked-text": "./src/components/linked-text.tsx", "./line-comment": "./src/components/line-comment.tsx", "./line-comment-annotations": "./src/components/line-comment-annotations.tsx", "./markdown": "./src/components/markdown.tsx", diff --git a/packages/session-ui/src/components/session-error.tsx b/packages/session-ui/src/components/session-error.tsx index 12608a14af5..9037a82e643 100644 --- a/packages/session-ui/src/components/session-error.tsx +++ b/packages/session-ui/src/components/session-error.tsx @@ -1,10 +1,13 @@ import { createMemo, Show } from "solid-js" import { Card } from "@opencode/ui/card" import { Icon } from "@opencode/ui/icon" +import { LinkedText } from "./linked-text" -export function SessionErrorMessage(props: { message: string }) { +export function SessionErrorMessage(props: { message: string; links?: boolean }) { const content = createMemo(() => { - const separator = props.message.indexOf(":") + // Only a colon before the first URL ends a title, so a scheme or port never splits a URL. + const url = props.message.search(/https?:\/\//) + const separator = props.message.slice(0, url === -1 ? undefined : url).indexOf(":") if (separator === -1) return { detail: props.message } @@ -17,7 +20,9 @@ export function SessionErrorMessage(props: { message: string }) { return ( <> {(title) => {title()}} - {content().detail} + + + ) } @@ -28,7 +33,7 @@ export function SessionError(props: { message: string }) {
- +
diff --git a/packages/session-ui/src/message/message-content.tsx b/packages/session-ui/src/message/message-content.tsx index f45ca6a3aa8..6156ba2afb8 100644 --- a/packages/session-ui/src/message/message-content.tsx +++ b/packages/session-ui/src/message/message-content.tsx @@ -19,6 +19,7 @@ import { useDialog } from "@opencode/ui/context/dialog" import { useI18n } from "@opencode/ui/context/i18n" import { Markdown } from "../components/markdown" import { linkSegments } from "../components/link-segments" +import { LinkedText } from "../components/linked-text" import { sessionReferenceText, setupSessionLinks } from "../components/markdown-session-links" import { useMarkdown } from "../context/markdown" import { useSources } from "../context/sources" @@ -1034,7 +1035,7 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact
- {error()} + diff --git a/packages/session-ui/src/timeline/error-card.stories.tsx b/packages/session-ui/src/timeline/error-card.stories.tsx index c785b1d1c0c..0be1499ba5b 100644 --- a/packages/session-ui/src/timeline/error-card.stories.tsx +++ b/packages/session-ui/src/timeline/error-card.stories.tsx @@ -98,3 +98,35 @@ export const ExecutionErrors = { /> ), } + +const linkDocument = { + sessionID: CURRENT_SESSION_ID, + messages: [ + ...thinkingDocument.messages, + { + id: "msg_story_error_link", + type: "assistant", + agent: "build", + model: STORY_MODEL, + content: [], + error: { + type: "provider.transport", + message: "Could not connect to http://localhost:11434/api. Check that Ollama is running.", + }, + time: { created: STORY_TIME + 33_000, completed: STORY_TIME + 34_000 }, + }, + ], + status: { type: "idle" }, + diffs: [], +} satisfies SessionDocument + +export const LinkedError = { + render: () => ( + + ), +} diff --git a/packages/session-ui/src/tools/tool-renderer.tsx b/packages/session-ui/src/tools/tool-renderer.tsx index 9799f8d43d8..028e873ba51 100644 --- a/packages/session-ui/src/tools/tool-renderer.tsx +++ b/packages/session-ui/src/tools/tool-renderer.tsx @@ -2677,7 +2677,9 @@ ToolRegistry.register({ return (
-
{q.question}
+
+ +
{answer().join(", ") || i18n.t("ui.question.answer.none")}
)