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