mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-09 22:25:02 +03:00
fix(app): make URLs in questions, MCP prompts and errors clickable (#61514)
This commit is contained in:
committed by
GitHub
Unverified
parent
4b370b4020
commit
f73e2847ba
@@ -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" } })
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user