authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-13 17:59:02-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-13 17:59:02-07:00
log85272eeeae53c5baa1cba1a36d84bbec81a3a0b5
treeb9072c9f5c0da1198f76725b6fdd60db9a271e28
parenta74ce48f9630e3e672f687c1cb3b38f2187ecf2d
signature Signed by PGP key B5690EEEBB952194

fix(chat): make links in agent questions clickable (#19197)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

6 files changed, 70 insertions(+), 8 deletions(-)

apps/mobile/src/features/threads/PendingUserInputCard.tsx+17-3
......@@ -21,6 +21,9 @@ import { SymbolView } from "../../components/AppSymbol";
2121import { AppText as Text } from "../../components/AppText";
2222import { ControlPill } from "../../components/ControlPill";
2323import { cn } from "../../lib/cn";
24import { SelectableMarkdownText } from "../../native/SelectableMarkdownText";
25import { useOpenPullRequest } from "../../native/useOpenPullRequest";
26import { useMarkdownPreviewStyles } from "../files/FileMarkdownPreview";
2427import {
2528 isPendingUserInputOptionSelected,
2629 type PendingUserInput,
......@@ -91,6 +94,19 @@ const EXPANDED_CARD_IS_OVERLAY = Platform.OS === "ios";
9194
9295const CARD_LAYOUT_TRANSITION = LinearTransition.duration(200);
9396
97/** Renders question text as markdown so agent-provided links open on tap. */
98function QuestionText(props: { readonly markdown: string }) {
99 const styles = useMarkdownPreviewStyles();
100 const openLink = useOpenPullRequest();
101 return (
102 <SelectableMarkdownText
103 markdown={props.markdown}
104 textStyle={styles.nativeTextStyle}
105 onLinkPress={(href) => void openLink(href)}
106 />
107 );
108}
109
94110export function PendingUserInputCard(props: PendingUserInputCardProps) {
95111 const questionCount = props.pendingUserInput.questions.length;
96112 // Message responses start a new run and remain available after the provider exits.
......@@ -277,9 +293,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
277293 <Text className="font-t3-bold text-xs uppercase tracking-[1px] text-foreground-muted">
278294 {question.header}
279295 </Text>
280 <Text className="font-sans text-base leading-snug text-foreground">
281 {question.question}
282 </Text>
296 <QuestionText markdown={question.question} />
283297 <View className="gap-2">
284298 {question.options.map((option) => {
285299 const optionValue = option.value ?? option.label.trim();
apps/web/src/components/chat/ChatComposer.tsx+4
......@@ -6869,6 +6869,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
68696869 ) : !isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
68706870 <ComposerPendingUserInputPanel
68716871 pendingUserInputs={pendingUserInputs}
6872 cwd={gitCwd ?? undefined}
6873 threadRef={routeThreadRef}
68726874 disabled={!canOperateThread}
68736875 respondingRequestIds={
68746876 activePendingIsResponding && activePendingUserInput
......@@ -6890,6 +6892,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
68906892 <div data-chat-composer-collapsed-controls="true">
68916893 <ComposerPendingUserInputPanel
68926894 pendingUserInputs={pendingUserInputs}
6895 cwd={gitCwd ?? undefined}
6896 threadRef={routeThreadRef}
68936897 disabled={!canOperateThread}
68946898 respondingRequestIds={
68956899 activePendingIsResponding && activePendingUserInput
apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.test.tsx+7-1
......@@ -1,4 +1,4 @@
1import { RuntimeRequestId } from "@t3tools/contracts";
1import { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts";
22import { act, useState, type ReactNode } from "react";
33import { create, type ReactTestRenderer } from "react-test-renderer";
44import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
......@@ -17,8 +17,12 @@ vi.mock("../ui/collapsible", () => {
1717 };
1818});
1919
20vi.mock("../ChatMarkdown", () => ({ default: ({ text }: { text: string }) => <p>{text}</p> }));
21
2022import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
2123
24const threadRef = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") };
25
2226const prompt = {
2327 requestId: RuntimeRequestId.make("question-navigation"),
2428 createdAt: "2026-10-09T00:00:00.000Z",
......@@ -44,6 +48,8 @@ function Questions({ initialIndex = 0 }: { initialIndex?: number }) {
4448 <button onClick={() => setQuestionIndex(questionIndex - 1)}>Previous</button>
4549 <ComposerPendingUserInputPanel
4650 pendingUserInputs={[prompt]}
51 cwd="/repo"
52 threadRef={threadRef}
4753 disabled={false}
4854 respondingRequestIds={[]}
4955 answers={answers}
apps/web/src/components/chat/ComposerPendingUserInputPanel.permissions.test.tsx+7-1
......@@ -1,4 +1,4 @@
1import { RuntimeRequestId } from "@t3tools/contracts";
1import { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts";
22import { act, type ReactNode } from "react";
33import { create, type ReactTestRenderer } from "react-test-renderer";
44import { expect, it, vi } from "vite-plus/test";
......@@ -12,8 +12,12 @@ vi.mock("../ui/collapsible", () => {
1212 };
1313});
1414
15vi.mock("../ChatMarkdown", () => ({ default: ({ text }: { text: string }) => <p>{text}</p> }));
16
1517import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
1618
19const threadRef = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") };
20
1721it("cancels an answer's pending auto-submit on revocation and resumes after a new grant", async () => {
1822 vi.useFakeTimers();
1923 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
......@@ -43,6 +47,8 @@ it("cancels an answer's pending auto-submit on revocation and resumes after a ne
4347 const panel = (disabled: boolean) => (
4448 <ComposerPendingUserInputPanel
4549 pendingUserInputs={[prompt]}
50 cwd="/repo"
51 threadRef={threadRef}
4652 disabled={disabled}
4753 respondingRequestIds={[]}
4854 answers={{}}
apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx+16-1
......@@ -1,8 +1,10 @@
1import { RuntimeRequestId } from "@t3tools/contracts";
1import { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts";
22import { renderToStaticMarkup } from "react-dom/server";
33import { describe, expect, it } from "vite-plus/test";
44
55import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
6
7const threadRef = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") };
68import type { PendingUserInput } from "../../session-logic";
79
810const prompt: PendingUserInput = {
......@@ -28,6 +30,8 @@ function renderPanel(pendingUserInput: PendingUserInput = prompt) {
2830 return renderToStaticMarkup(
2931 <ComposerPendingUserInputPanel
3032 pendingUserInputs={[pendingUserInput]}
33 cwd="/repo"
34 threadRef={threadRef}
3135 respondingRequestIds={[]}
3236 answers={{}}
3337 questionIndex={0}
......@@ -68,4 +72,15 @@ describe("ComposerPendingUserInputPanel", () => {
6872 expect(markup).toContain("Incremental");
6973 expect(markup).toContain("Big bang");
7074 });
75
76 it("links URLs in the question text", () => {
77 const url = "https://accounts.t3.codes/device?user_code=RTJS-TQGS";
78 const question = prompt.questions[0]!;
79 const markup = renderPanel({
80 ...prompt,
81 questions: [{ ...question, question: `Authorize BeeBox at ${url} and confirm.` }],
82 });
83
84 expect(markup).toMatch(new RegExp(`<a[^>]*href="${url.replace(/[?.]/g, "\\$&")}"`));
85 });
7186});
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+19-2
......@@ -1,4 +1,4 @@
1import { type RuntimeRequestId } from "@t3tools/contracts";
1import { type RuntimeRequestId, type ScopedThreadRef } from "@t3tools/contracts";
22import { memo, useCallback, useEffect, useRef, useState } from "react";
33import { type PendingUserInput } from "../../session-logic";
44import {
......@@ -9,9 +9,13 @@ import { CheckIcon } from "lucide-react";
99import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
1010import { cn } from "~/lib/utils";
1111import { ComposerBanner } from "./ComposerBanner";
12import ChatMarkdown from "../ChatMarkdown";
1213
1314interface PendingUserInputPanelProps {
1415 pendingUserInputs: PendingUserInput[];
16 /** Resolves file links in the question text. */
17 cwd: string | undefined;
18 threadRef: ScopedThreadRef;
1519 disabled?: boolean;
1620 respondingRequestIds: RuntimeRequestId[];
1721 answers: Record<string, PendingUserInputDraftAnswer>;
......@@ -23,6 +27,8 @@ interface PendingUserInputPanelProps {
2327
2428export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({
2529 pendingUserInputs,
30 cwd,
31 threadRef,
2632 disabled = false,
2733 respondingRequestIds,
2834 answers,
......@@ -39,6 +45,8 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn
3945 <ComposerPendingUserInputCard
4046 key={activePrompt.requestId}
4147 prompt={activePrompt}
48 cwd={cwd}
49 threadRef={threadRef}
4250 disabled={disabled}
4351 isResponding={respondingRequestIds.includes(activePrompt.requestId)}
4452 answers={answers}
......@@ -52,6 +60,8 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn
5260
5361const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard({
5462 prompt,
63 cwd,
64 threadRef,
5565 disabled,
5666 isResponding,
5767 answers,
......@@ -61,6 +71,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
6171 onDismiss,
6272}: {
6373 prompt: PendingUserInput;
74 cwd: string | undefined;
75 threadRef: ScopedThreadRef;
6476 disabled: boolean;
6577 isResponding: boolean;
6678 answers: Record<string, PendingUserInputDraftAnswer>;
......@@ -242,7 +254,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
242254 <CollapsiblePanel>
243255 <ComposerBanner.Scroll>
244256 <ComposerBanner.Body className="pe-1 pb-1 wrap-anywhere">
245 <p className="text-sm text-foreground/85">{activeQuestion.question}</p>
257 <ChatMarkdown
258 text={activeQuestion.question}
259 cwd={cwd}
260 threadRef={threadRef}
261 className="text-foreground/85"
262 />
246263 {activeQuestion.multiSelect ? (
247264 <p className="mt-1 text-secondary-label text-xs">Select one or more options.</p>
248265 ) : null}