Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,17 @@ story("renders user image, file attachment, file reference, and agent reference"
expect(bodyBox).not.toBeNull()
expect(bodyBox!.y - (attachmentsBox!.y + attachmentsBox!.height)).toBe(8)
})

story("links URLs in user messages without swallowing punctuation or mentions", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "links" } })
const links = timeline.locator('[data-slot="user-message-text"] a[data-slot="user-message-link"]')
await expect(links).toHaveCount(2)
await expect(links.nth(0)).toHaveText("https://github.com/Hona/mesh")
await expect(links.nth(0)).toHaveAttribute("href", "https://github.com/Hona/mesh")
await expect(links.nth(0)).toHaveAttribute("target", "_blank")
await expect(links.nth(1)).toHaveText("https://opencode.ai/docs")
await expect(timeline.getByText("@src/a.ts", { exact: true })).toBeVisible()
await expect(timeline.locator('[data-slot="user-message-text"]')).toHaveText(
"set it up on this pc https://github.com/Hona/mesh, then compare @src/a.ts with (https://opencode.ai/docs).",
)
})
11 changes: 11 additions & 0 deletions packages/session-ui/src/components/message-part.css
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,17 @@
color: var(--v2-blue-500);
}

[data-slot="user-message-link"] {
color: inherit;
text-decoration: underline;
text-decoration-color: color-mix(in oklch, currentColor 40%, transparent);
text-underline-offset: 2px;

&:hover {
text-decoration-color: currentColor;
}
}

max-width: 100%;
}

Expand Down
46 changes: 37 additions & 9 deletions packages/session-ui/src/message/message-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -533,35 +533,63 @@ function CurrentHighlightedText(props: {
].sort((a, b) => a.start - b.start)

const result: HighlightSegment[] = []
// Plain text between references may contain URLs, which become links.
const pushText = (text: string) => {
let last = 0

for (const match of text.matchAll(urlPattern)) {
const href = match[0].replace(/[),.;:!?\]]+$/, "")

if (!URL.canParse(href)) continue

if (match.index > last) result.push({ text: text.slice(last, match.index) })
result.push({ text: href, type: "link" })
last = match.index + href.length
}

if (last < text.length) result.push({ text: text.slice(last) })
}

let last = 0
references.forEach((reference) => {
if (reference.start < last) return

if (reference.start > last) result.push({ text: props.text.slice(last, reference.start) })
if (reference.start > last) pushText(props.text.slice(last, reference.start))
result.push({ text: props.text.slice(reference.start, reference.end), type: reference.type })
last = reference.end
})

if (last < props.text.length) result.push({ text: props.text.slice(last) })
if (last < props.text.length) pushText(props.text.slice(last))

return result
})

return (
<For each={segments()}>
{(segment) => (
<span data-highlight={segment.type}>
<Show when={segment.type && segment.text.startsWith("@")} fallback={segment.text}>
<span data-slot="user-message-mention-prefix">@</span>
{segment.text.slice(1)}
</Show>
</span>
<Show
when={segment.type === "link"}
fallback={
<span data-highlight={segment.type}>
<Show when={segment.type && segment.text.startsWith("@")} fallback={segment.text}>
<span data-slot="user-message-mention-prefix">@</span>
{segment.text.slice(1)}
</Show>
</span>
}
>
<a data-slot="user-message-link" href={segment.text} target="_blank" rel="noopener noreferrer">
{segment.text}
</a>
</Show>
)}
</For>
)
}

type HighlightSegment = { text: string; type?: "file" | "agent" }
const urlPattern = /https?:\/\/[^\s<>"'`]+/g

type HighlightSegment = { text: string; type?: "file" | "agent" | "link" }

/** A compaction the server admitted but has not started, drawn as the divider a started one opens with. */
export function SessionCompactionQueued() {
Expand Down
38 changes: 37 additions & 1 deletion packages/session-ui/src/timeline/timeline-row.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,10 @@ function RetryAndRecoverStory() {
? {
attempt: 2,
at: 1_900_000_000_000,
error: { type: "ProviderRateLimitError" as const, message: "Rate limit reached. Retrying with backoff." },
error: {
type: "ProviderRateLimitError" as const,
message: "Rate limit reached. Retrying with backoff.",
},
}
: undefined,
time: { created: STORY_TIME, completed: finished ? STORY_TIME + 300 : undefined },
Expand Down Expand Up @@ -617,6 +620,38 @@ const RichUserAttachments = {
),
}

const UserMessageLinks = {
render: () => (
<CurrentSessionTimelineStory
title="Prompt with links"
description="URLs in a prompt open externally; trailing punctuation and mentions stay plain text."
document={{
...thinkingDocument,
status: { type: "idle" },
messages: [
{
id: "msg_story_user_links",
type: "user",
text: "set it up on this pc https://github.com/Hona/mesh, then compare @src/a.ts with (https://opencode.ai/docs).",
agents: [],
files: [
{
data: "",
mime: "text/plain",
name: "a.ts",
source: { type: "uri", uri: "src/a.ts" },
mention: { text: "@src/a.ts", start: 64, end: 73 },
},
],
time: { created: 1_735_689_633_000 },
},
],
}}
width="620px"
/>
),
}

const conversationScenarios = {
reasoning: AgentReasoning,
hidden: WorkingWithoutReasoningDetails,
Expand All @@ -627,6 +662,7 @@ const conversationScenarios = {
interruption: InterruptedTurn,
models: AliasedModelNotices,
attachments: RichUserAttachments,
links: UserMessageLinks,
}

export const Conversation = {
Expand Down
Loading