fix(app): make URLs in questions, MCP prompts and errors clickable (#61514)

This commit is contained in:
Luke Parker
2026-11-03 17:35:23 -06:00
committed by GitHub
Unverified
parent 4b370b4020
commit f73e2847ba
12 changed files with 136 additions and 12 deletions
@@ -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 }) => {
@@ -103,6 +103,34 @@ export const QuestionSeries = {
),
}
// A question whose text includes a URL to open before answering.
export const LinkPrompt = {
render: () => (
<SessionPreview
title="Harden package publishing"
description="opencode · release"
document={questionPendingDocument}
request={{
type: "question",
value: {
...activeQuestionRequest,
fields: [
{
key: "approval",
type: "string",
title: "Approval",
description:
'One more approval (it turns on "require 2FA, disallow tokens" for all 3 packages): https://www.npmjs.com/auth/cli/2ff15723-dfa2-4eec-addd-53cb3ff7597f',
options: [{ value: "done", label: "Done", description: "Approved." }],
custom: true,
},
],
},
}}
/>
),
}
// A pick-any question with more answers than the dock has room for, then a short one.
export const LongList = {
render: () => (
@@ -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) => (
<div data-slot="question-details">
<div data-slot="question-text">
<bdi>{text()}</bdi>
<bdi>
<LinkedText text={text()} />
</bdi>
</div>
</div>
)}
@@ -276,7 +279,11 @@ function Label(props: { field: Field }) {
<span data-slot="elicitation-title">{props.field.title}</span>
</Show>
<Show when={props.field.description}>
<span data-slot="elicitation-description">{props.field.description}</span>
{(description) => (
<span data-slot="elicitation-description">
<LinkedText text={description()} />
</span>
)}
</Show>
</div>
</Show>
@@ -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,
}}
>
<bdi>{question()?.question}</bdi>
<bdi>
<LinkedText text={question()?.question ?? ""} />
</bdi>
</div>
<Show when={!store.minimized}>
<div data-slot="question-hint">
@@ -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,
}}
>
<bdi>{question()?.question}</bdi>
<bdi>
<LinkedText text={question()?.question ?? ""} />
</bdi>
</div>
<Show when={!store.minimized && multi()}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
@@ -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 },
],
},
@@ -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" } })
+1
View File
@@ -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",
@@ -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 (
<>
<Show when={content().title}>{(title) => <strong class="font-[530]">{title()}</strong>}</Show>
{content().detail}
<Show when={props.links} fallback={content().detail}>
<LinkedText text={content().detail} />
</Show>
</>
)
}
@@ -28,7 +33,7 @@ export function SessionError(props: { message: string }) {
<div class="flex w-full min-w-0 items-start gap-2">
<Icon name="outline-hexagonal-warning" class="shrink-0 text-v2-state-fg-danger" />
<div class="min-w-0">
<SessionErrorMessage message={props.message} />
<SessionErrorMessage message={props.message} links />
</div>
</div>
</Card>
@@ -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
</Show>
<Show when={error()}>
<Card variant="error" class="error-card">
{error()}
<LinkedText text={error()} />
</Card>
</Show>
</div>
@@ -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: () => (
<CurrentSessionTimelineStory
title="Linked error"
description="A provider error whose message includes a URL."
document={linkDocument}
width="480px"
/>
),
}
@@ -2677,7 +2677,9 @@ ToolRegistry.register({
return (
<div data-slot="question-answer-item">
<div data-slot="question-text">{q.question}</div>
<div data-slot="question-text">
<LinkedText text={q.question} />
</div>
<div data-slot="answer-text">{answer().join(", ") || i18n.t("ui.question.answer.none")}</div>
</div>
)