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";...@@ -21,6 +21,9 @@ import { SymbolView } from "../../components/AppSymbol";
21import { AppText as Text } from "../../components/AppText";21import { AppText as Text } from "../../components/AppText";
22import { ControlPill } from "../../components/ControlPill";22import { ControlPill } from "../../components/ControlPill";
23import { cn } from "../../lib/cn";23import { cn } from "../../lib/cn";
24import { SelectableMarkdownText } from "../../native/SelectableMarkdownText";
25import { useOpenPullRequest } from "../../native/useOpenPullRequest";
26import { useMarkdownPreviewStyles } from "../files/FileMarkdownPreview";
24import {27import {
25 isPendingUserInputOptionSelected,28 isPendingUserInputOptionSelected,
26 type PendingUserInput,29 type PendingUserInput,
...@@ -91,6 +94,19 @@ const EXPANDED_CARD_IS_OVERLAY = Platform.OS === "ios";...@@ -91,6 +94,19 @@ const EXPANDED_CARD_IS_OVERLAY = Platform.OS === "ios";
9194
92const CARD_LAYOUT_TRANSITION = LinearTransition.duration(200);95const 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
94export function PendingUserInputCard(props: PendingUserInputCardProps) {110export function PendingUserInputCard(props: PendingUserInputCardProps) {
95 const questionCount = props.pendingUserInput.questions.length;111 const questionCount = props.pendingUserInput.questions.length;
96 // Message responses start a new run and remain available after the provider exits.112 // Message responses start a new run and remain available after the provider exits.
...@@ -277,9 +293,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {...@@ -277,9 +293,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
277 <Text className="font-t3-bold text-xs uppercase tracking-[1px] text-foreground-muted">293 <Text className="font-t3-bold text-xs uppercase tracking-[1px] text-foreground-muted">
278 {question.header}294 {question.header}
279 </Text>295 </Text>
280 <Text className="font-sans text-base leading-snug text-foreground">296 <QuestionText markdown={question.question} />
281 {question.question}
282 </Text>
283 <View className="gap-2">297 <View className="gap-2">
284 {question.options.map((option) => {298 {question.options.map((option) => {
285 const optionValue = option.value ?? option.label.trim();299 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)...@@ -6869,6 +6869,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
6869 ) : !isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (6869 ) : !isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
6870 <ComposerPendingUserInputPanel6870 <ComposerPendingUserInputPanel
6871 pendingUserInputs={pendingUserInputs}6871 pendingUserInputs={pendingUserInputs}
6872 cwd={gitCwd ?? undefined}
6873 threadRef={routeThreadRef}
6872 disabled={!canOperateThread}6874 disabled={!canOperateThread}
6873 respondingRequestIds={6875 respondingRequestIds={
6874 activePendingIsResponding && activePendingUserInput6876 activePendingIsResponding && activePendingUserInput
...@@ -6890,6 +6892,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -6890,6 +6892,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
6890 <div data-chat-composer-collapsed-controls="true">6892 <div data-chat-composer-collapsed-controls="true">
6891 <ComposerPendingUserInputPanel6893 <ComposerPendingUserInputPanel
6892 pendingUserInputs={pendingUserInputs}6894 pendingUserInputs={pendingUserInputs}
6895 cwd={gitCwd ?? undefined}
6896 threadRef={routeThreadRef}
6893 disabled={!canOperateThread}6897 disabled={!canOperateThread}
6894 respondingRequestIds={6898 respondingRequestIds={
6895 activePendingIsResponding && activePendingUserInput6899 activePendingIsResponding && activePendingUserInput
apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.test.tsx+7-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { RuntimeRequestId } from "@t3tools/contracts";1import { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts";
2import { act, useState, type ReactNode } from "react";2import { act, useState, type ReactNode } from "react";
3import { create, type ReactTestRenderer } from "react-test-renderer";3import { create, type ReactTestRenderer } from "react-test-renderer";
4import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";4import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
...@@ -17,8 +17,12 @@ vi.mock("../ui/collapsible", () => {...@@ -17,8 +17,12 @@ vi.mock("../ui/collapsible", () => {
17 };17 };
18});18});
1919
20vi.mock("../ChatMarkdown", () => ({ default: ({ text }: { text: string }) => <p>{text}</p> }));
21
20import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";22import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
2123
24const threadRef = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") };
25
22const prompt = {26const prompt = {
23 requestId: RuntimeRequestId.make("question-navigation"),27 requestId: RuntimeRequestId.make("question-navigation"),
24 createdAt: "2026-10-09T00:00:00.000Z",28 createdAt: "2026-10-09T00:00:00.000Z",
...@@ -44,6 +48,8 @@ function Questions({ initialIndex = 0 }: { initialIndex?: number }) {...@@ -44,6 +48,8 @@ function Questions({ initialIndex = 0 }: { initialIndex?: number }) {
44 <button onClick={() => setQuestionIndex(questionIndex - 1)}>Previous</button>48 <button onClick={() => setQuestionIndex(questionIndex - 1)}>Previous</button>
45 <ComposerPendingUserInputPanel49 <ComposerPendingUserInputPanel
46 pendingUserInputs={[prompt]}50 pendingUserInputs={[prompt]}
51 cwd="/repo"
52 threadRef={threadRef}
47 disabled={false}53 disabled={false}
48 respondingRequestIds={[]}54 respondingRequestIds={[]}
49 answers={answers}55 answers={answers}
apps/web/src/components/chat/ComposerPendingUserInputPanel.permissions.test.tsx+7-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { RuntimeRequestId } from "@t3tools/contracts";1import { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts";
2import { act, type ReactNode } from "react";2import { act, type ReactNode } from "react";
3import { create, type ReactTestRenderer } from "react-test-renderer";3import { create, type ReactTestRenderer } from "react-test-renderer";
4import { expect, it, vi } from "vite-plus/test";4import { expect, it, vi } from "vite-plus/test";
...@@ -12,8 +12,12 @@ vi.mock("../ui/collapsible", () => {...@@ -12,8 +12,12 @@ vi.mock("../ui/collapsible", () => {
12 };12 };
13});13});
1414
15vi.mock("../ChatMarkdown", () => ({ default: ({ text }: { text: string }) => <p>{text}</p> }));
16
15import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";17import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
1618
19const threadRef = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") };
20
17it("cancels an answer's pending auto-submit on revocation and resumes after a new grant", async () => {21it("cancels an answer's pending auto-submit on revocation and resumes after a new grant", async () => {
18 vi.useFakeTimers();22 vi.useFakeTimers();
19 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);23 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...@@ -43,6 +47,8 @@ it("cancels an answer's pending auto-submit on revocation and resumes after a ne
43 const panel = (disabled: boolean) => (47 const panel = (disabled: boolean) => (
44 <ComposerPendingUserInputPanel48 <ComposerPendingUserInputPanel
45 pendingUserInputs={[prompt]}49 pendingUserInputs={[prompt]}
50 cwd="/repo"
51 threadRef={threadRef}
46 disabled={disabled}52 disabled={disabled}
47 respondingRequestIds={[]}53 respondingRequestIds={[]}
48 answers={{}}54 answers={{}}
apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx+16-1
...@@ -1,8 +1,10 @@...@@ -1,8 +1,10 @@
1import { RuntimeRequestId } from "@t3tools/contracts";1import { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts";
2import { renderToStaticMarkup } from "react-dom/server";2import { renderToStaticMarkup } from "react-dom/server";
3import { describe, expect, it } from "vite-plus/test";3import { describe, expect, it } from "vite-plus/test";
44
5import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";5import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
6
7const threadRef = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") };
6import type { PendingUserInput } from "../../session-logic";8import type { PendingUserInput } from "../../session-logic";
79
8const prompt: PendingUserInput = {10const prompt: PendingUserInput = {
...@@ -28,6 +30,8 @@ function renderPanel(pendingUserInput: PendingUserInput = prompt) {...@@ -28,6 +30,8 @@ function renderPanel(pendingUserInput: PendingUserInput = prompt) {
28 return renderToStaticMarkup(30 return renderToStaticMarkup(
29 <ComposerPendingUserInputPanel31 <ComposerPendingUserInputPanel
30 pendingUserInputs={[pendingUserInput]}32 pendingUserInputs={[pendingUserInput]}
33 cwd="/repo"
34 threadRef={threadRef}
31 respondingRequestIds={[]}35 respondingRequestIds={[]}
32 answers={{}}36 answers={{}}
33 questionIndex={0}37 questionIndex={0}
...@@ -68,4 +72,15 @@ describe("ComposerPendingUserInputPanel", () => {...@@ -68,4 +72,15 @@ describe("ComposerPendingUserInputPanel", () => {
68 expect(markup).toContain("Incremental");72 expect(markup).toContain("Incremental");
69 expect(markup).toContain("Big bang");73 expect(markup).toContain("Big bang");
70 });74 });
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 });
71});86});
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+19-2
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { type RuntimeRequestId } from "@t3tools/contracts";1import { type RuntimeRequestId, type ScopedThreadRef } from "@t3tools/contracts";
2import { memo, useCallback, useEffect, useRef, useState } from "react";2import { memo, useCallback, useEffect, useRef, useState } from "react";
3import { type PendingUserInput } from "../../session-logic";3import { type PendingUserInput } from "../../session-logic";
4import {4import {
...@@ -9,9 +9,13 @@ import { CheckIcon } from "lucide-react";...@@ -9,9 +9,13 @@ import { CheckIcon } from "lucide-react";
9import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";9import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
10import { cn } from "~/lib/utils";10import { cn } from "~/lib/utils";
11import { ComposerBanner } from "./ComposerBanner";11import { ComposerBanner } from "./ComposerBanner";
12import ChatMarkdown from "../ChatMarkdown";
1213
13interface PendingUserInputPanelProps {14interface PendingUserInputPanelProps {
14 pendingUserInputs: PendingUserInput[];15 pendingUserInputs: PendingUserInput[];
16 /** Resolves file links in the question text. */
17 cwd: string | undefined;
18 threadRef: ScopedThreadRef;
15 disabled?: boolean;19 disabled?: boolean;
16 respondingRequestIds: RuntimeRequestId[];20 respondingRequestIds: RuntimeRequestId[];
17 answers: Record<string, PendingUserInputDraftAnswer>;21 answers: Record<string, PendingUserInputDraftAnswer>;
...@@ -23,6 +27,8 @@ interface PendingUserInputPanelProps {...@@ -23,6 +27,8 @@ interface PendingUserInputPanelProps {
2327
24export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({28export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({
25 pendingUserInputs,29 pendingUserInputs,
30 cwd,
31 threadRef,
26 disabled = false,32 disabled = false,
27 respondingRequestIds,33 respondingRequestIds,
28 answers,34 answers,
...@@ -39,6 +45,8 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn...@@ -39,6 +45,8 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn
39 <ComposerPendingUserInputCard45 <ComposerPendingUserInputCard
40 key={activePrompt.requestId}46 key={activePrompt.requestId}
41 prompt={activePrompt}47 prompt={activePrompt}
48 cwd={cwd}
49 threadRef={threadRef}
42 disabled={disabled}50 disabled={disabled}
43 isResponding={respondingRequestIds.includes(activePrompt.requestId)}51 isResponding={respondingRequestIds.includes(activePrompt.requestId)}
44 answers={answers}52 answers={answers}
...@@ -52,6 +60,8 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn...@@ -52,6 +60,8 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn
5260
53const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard({61const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard({
54 prompt,62 prompt,
63 cwd,
64 threadRef,
55 disabled,65 disabled,
56 isResponding,66 isResponding,
57 answers,67 answers,
...@@ -61,6 +71,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -61,6 +71,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
61 onDismiss,71 onDismiss,
62}: {72}: {
63 prompt: PendingUserInput;73 prompt: PendingUserInput;
74 cwd: string | undefined;
75 threadRef: ScopedThreadRef;
64 disabled: boolean;76 disabled: boolean;
65 isResponding: boolean;77 isResponding: boolean;
66 answers: Record<string, PendingUserInputDraftAnswer>;78 answers: Record<string, PendingUserInputDraftAnswer>;
...@@ -242,7 +254,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -242,7 +254,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
242 <CollapsiblePanel>254 <CollapsiblePanel>
243 <ComposerBanner.Scroll>255 <ComposerBanner.Scroll>
244 <ComposerBanner.Body className="pe-1 pb-1 wrap-anywhere">256 <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 />
246 {activeQuestion.multiSelect ? (263 {activeQuestion.multiSelect ? (
247 <p className="mt-1 text-secondary-label text-xs">Select one or more options.</p>264 <p className="mt-1 text-secondary-label text-xs">Select one or more options.</p>
248 ) : null}265 ) : null}