Skip to content
Closed
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
20 changes: 19 additions & 1 deletion apps/mobile/src/features/threads/PendingUserInputCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { RequestActionButton } from "./RequestActionButton";
import { QuestionAttachments } from "./QuestionAttachments";
import type { RuntimeRequestId } from "@t3tools/contracts";
import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests";
import { splitPlainTextLinks } from "@t3tools/shared/plainTextLinks";
import { useCallback, useRef } from "react";
import { Platform, Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native";
import Animated, {
Expand All @@ -21,6 +22,7 @@ import { SymbolView } from "../../components/AppSymbol";
import { AppText as Text } from "../../components/AppText";
import { ControlPill } from "../../components/ControlPill";
import { cn } from "../../lib/cn";
import { useOpenPullRequest } from "../../native/useOpenPullRequest";
import {
isPendingUserInputOptionSelected,
type PendingUserInput,
Expand Down Expand Up @@ -97,6 +99,8 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
const canRespond = props.pendingUserInput.responseCapability !== "not_resumable";
const isResponding = props.respondingUserInputId === props.pendingUserInput.requestId;
const responseDisabled = !canRespond || isResponding;
// The same path a link in the thread's messages takes.
const openLink = useOpenPullRequest();

const cardCoverage = props.cardCoverage;
const barHeightRef = useRef(0);
Expand Down Expand Up @@ -278,7 +282,21 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
{question.header}
</Text>
<Text className="font-sans text-base leading-snug text-foreground">
{question.question}
{splitPlainTextLinks(question.question).map((segment, index) =>
segment.kind === "url" ? (
<Text
// oxlint-disable-next-line react/no-array-index-key -- segments never reorder
key={index}
accessibilityRole="link"
className="text-md-link"
onPress={() => void openLink(segment.url)}
>
{segment.url}
</Text>
) : (
segment.text
),
)}
</Text>
<View className="gap-2">
{question.options.map((option) => {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6880,6 +6880,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
onToggleOption={onSelectActivePendingUserInputOption}
onAdvance={onAdvanceActivePendingUserInput}
onDismiss={onDismissActivePendingUserInput}
onOpenLink={openPrLink}
/>
) : !isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan ? (
<ComposerPlanFollowUpBanner
Expand All @@ -6901,6 +6902,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
onToggleOption={onSelectActivePendingUserInputOption}
onAdvance={onAdvanceActivePendingUserInput}
onDismiss={onDismissActivePendingUserInput}
onOpenLink={openPrLink}
/>
{!isChoiceOnlyPendingQuestion ||
activePendingProgress?.activeQuestion?.multiSelect ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ function Questions({ initialIndex = 0 }: { initialIndex?: number }) {
}));
}}
onDismiss={() => {}}
onOpenLink={() => {}}
/>
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ it("cancels an answer's pending auto-submit on revocation and resumes after a ne
onToggleOption={onToggleOption}
onAdvance={onAdvance}
onDismiss={onDismiss}
onOpenLink={() => {}}
/>
);
let renderer: ReactTestRenderer | undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ function renderPanel(pendingUserInput: PendingUserInput = prompt) {
onToggleOption={() => {}}
onAdvance={() => {}}
onDismiss={() => {}}
onOpenLink={() => {}}
/>,
);
}
Expand Down
28 changes: 26 additions & 2 deletions apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type RuntimeRequestId } from "@t3tools/contracts";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { splitPlainTextLinks } from "@t3tools/shared/plainTextLinks";
import { memo, useCallback, useEffect, useRef, useState, type MouseEvent } from "react";
import { type PendingUserInput } from "../../session-logic";
import {
derivePendingUserInputProgress,
Expand All @@ -19,6 +20,7 @@ interface PendingUserInputPanelProps {
onToggleOption: (questionId: string, optionValue: string) => void;
onAdvance: () => void;
onDismiss: (requestId: RuntimeRequestId) => void;
onOpenLink: (event: MouseEvent<HTMLElement>, url: string) => void;
}

export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({
Expand All @@ -30,6 +32,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn
onToggleOption,
onAdvance,
onDismiss,
onOpenLink,
}: PendingUserInputPanelProps) {
if (pendingUserInputs.length === 0) return null;
const activePrompt = pendingUserInputs[0];
Expand All @@ -46,6 +49,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn
onToggleOption={onToggleOption}
onAdvance={onAdvance}
onDismiss={onDismiss}
onOpenLink={onOpenLink}
/>
);
});
Expand All @@ -59,6 +63,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
onToggleOption,
onAdvance,
onDismiss,
onOpenLink,
}: {
prompt: PendingUserInput;
disabled: boolean;
Expand All @@ -68,6 +73,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
onToggleOption: (questionId: string, optionValue: string) => void;
onAdvance: () => void;
onDismiss: (requestId: RuntimeRequestId) => void;
onOpenLink: (event: MouseEvent<HTMLElement>, url: string) => void;
}) {
// Message-mode requests remain answerable after their provider turn ends.
const canRespond = prompt.responseCapability !== "not_resumable";
Expand Down Expand Up @@ -242,7 +248,25 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
<CollapsiblePanel>
<ComposerBanner.Scroll>
<ComposerBanner.Body className="pe-1 pb-1 wrap-anywhere">
<p className="text-sm text-foreground/85">{activeQuestion.question}</p>
<p className="text-sm text-foreground/85">
{splitPlainTextLinks(activeQuestion.question).map((segment, index) =>
segment.kind === "url" ? (
<a
// oxlint-disable-next-line react/no-array-index-key -- segments never reorder
key={index}
href={segment.url}
target="_blank"
rel="noopener noreferrer"
className="text-info-foreground hover:underline focus-visible:underline"
onClick={(event) => onOpenLink(event, segment.url)}
>
{segment.url}
</a>
) : (
segment.text
),
)}
</p>
{activeQuestion.multiSelect ? (
<p className="mt-1 text-secondary-label text-xs">Select one or more options.</p>
) : null}
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,10 @@
"types": "./src/promptLinks.ts",
"import": "./src/promptLinks.ts"
},
"./plainTextLinks": {
"types": "./src/plainTextLinks.ts",
"import": "./src/plainTextLinks.ts"
},
"./threadLinks": {
"types": "./src/threadLinks.ts",
"import": "./src/threadLinks.ts"
Expand Down
54 changes: 54 additions & 0 deletions packages/shared/src/plainTextLinks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vite-plus/test";

import { splitPlainTextLinks } from "./plainTextLinks.ts";

const urls = (text: string) =>
splitPlainTextLinks(text).flatMap((segment) => (segment.kind === "url" ? [segment.url] : []));

describe("splitPlainTextLinks", () => {
it("keeps the surrounding prose around a link", () => {
expect(
splitPlainTextLinks("Mocks: https://example.com/a.html (press 1-5). Which one?"),
).toEqual([
{ kind: "text", text: "Mocks: " },
{ kind: "url", url: "https://example.com/a.html" },
{ kind: "text", text: " (press 1-5). Which one?" },
]);
});

it("keeps the punctuation between two links as prose", () => {
expect(splitPlainTextLinks("A: https://a.dev/x, B: https://b.dev/y.")).toEqual([
{ kind: "text", text: "A: " },
{ kind: "url", url: "https://a.dev/x" },
{ kind: "text", text: ", B: " },
{ kind: "url", url: "https://b.dev/y" },
{ kind: "text", text: "." },
]);
});

it("stops at quotes and at the end of a markdown link label", () => {
expect(urls("Read “https://example.com/docs” first")).toEqual(["https://example.com/docs"]);
expect(urls("[https://example.com/a](https://example.com/a)")).toEqual([
"https://example.com/a",
]);
});

it("drops punctuation and brackets that close the sentence, not the URL", () => {
expect(urls("See https://example.com/a.")).toEqual(["https://example.com/a"]);
expect(urls("(see https://example.com/a)")).toEqual(["https://example.com/a"]);
expect(urls("[mock](https://example.com/a)")).toEqual(["https://example.com/a"]);
expect(urls("**https://example.com/a**")).toEqual(["https://example.com/a"]);
});

it("keeps brackets the URL opened itself", () => {
expect(urls("https://en.wikipedia.org/wiki/Fence_(disambiguation).")).toEqual([
"https://en.wikipedia.org/wiki/Fence_(disambiguation)",
]);
});

it("leaves text without links as one segment", () => {
expect(splitPlainTextLinks("No links, just http:// here")).toEqual([
{ kind: "text", text: "No links, just http:// here" },
]);
});
});
53 changes: 53 additions & 0 deletions packages/shared/src/plainTextLinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Splits plain text into prose and bare `http(s)` URLs, so surfaces that show
* agent text without a markdown renderer (such as a pending question) can
* still make the links in it tappable.
*/
export type PlainTextLinkSegment =
| { readonly kind: "text"; readonly text: string }
| { readonly kind: "url"; readonly url: string };

// Quotes, straight or curly, cannot appear unescaped in a URL, so they end one.
const URL_PATTERN = /https?:\/\/[^\s<>"'`“”‘’]+/giu;
// GitHub's autolink rule: sentence punctuation and markdown emphasis that
// trail a URL are not part of it.
const TRAILING_PUNCTUATION = /[.,;:!?*_~]$/u;
const CLOSING_BRACKETS: Record<string, string> = { ")": "(", "]": "[", "}": "{" };

const count = (text: string, character: string) => text.split(character).length - 1;

function trimUrl(candidate: string): string {
// `[https://a](https://a)` is a markdown link whose label is the URL.
let url = candidate.split("](")[0] ?? candidate;
for (;;) {
const last = url.at(-1);
if (last === undefined) return url;
if (TRAILING_PUNCTUATION.test(last)) {
url = url.slice(0, -1);
continue;
}
const opening = CLOSING_BRACKETS[last];
// Keep a closing bracket the URL itself opened, like a Wikipedia path.
if (opening !== undefined && count(url, opening) < count(url, last)) {
url = url.slice(0, -1);
continue;
}
return url;
}
}

export function splitPlainTextLinks(text: string): ReadonlyArray<PlainTextLinkSegment> {
const segments: PlainTextLinkSegment[] = [];
let cursor = 0;
for (const match of text.matchAll(URL_PATTERN)) {
const url = trimUrl(match[0]);
// A bare scheme with nothing after it is not a link.
if (!/^https?:\/\/[^/]/iu.test(url)) continue;
if (match.index > cursor)
segments.push({ kind: "text", text: text.slice(cursor, match.index) });
segments.push({ kind: "url", url });
cursor = match.index + url.length;
}
if (cursor < text.length) segments.push({ kind: "text", text: text.slice(cursor) });
return segments;
}
Loading