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) => (
)}
@@ -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")}
)