| 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,6 +21,9 @@ import { SymbolView } from "../../components/AppSymbol"; |
| 21 | import { AppText as Text } from "../../components/AppText"; | 21 | import { AppText as Text } from "../../components/AppText"; |
| 22 | import { ControlPill } from "../../components/ControlPill"; | 22 | import { ControlPill } from "../../components/ControlPill"; |
| 23 | import { cn } from "../../lib/cn"; | 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 | import { | 27 | import { |
| 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"; |
| 91 | 94 | ||
| 92 | const CARD_LAYOUT_TRANSITION = LinearTransition.duration(200); | 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 | export function PendingUserInputCard(props: PendingUserInputCardProps) { | 110 | export 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 | <ComposerPendingUserInputPanel | 6870 | <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 && activePendingUserInput | 6876 | 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 | <ComposerPendingUserInputPanel | 6893 | <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 && activePendingUserInput | 6899 | activePendingIsResponding && activePendingUserInput |
apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.test.tsx+7-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { RuntimeRequestId } from "@t3tools/contracts"; | 1 | import { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { act, useState, type ReactNode } from "react"; | 2 | import { act, useState, type ReactNode } from "react"; |
| 3 | import { create, type ReactTestRenderer } from "react-test-renderer"; | 3 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 4 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | 4 | import { 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 | }); |
| 19 | 19 | ||
| 20 | vi.mock("../ChatMarkdown", () => ({ default: ({ text }: { text: string }) => <p>{text}</p> })); | ||
| 21 | |||
| 20 | import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; | 22 | import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; |
| 21 | 23 | ||
| 24 | const threadRef = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") }; | ||
| 25 | |||
| 22 | const prompt = { | 26 | const 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 | <ComposerPendingUserInputPanel | 49 | <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 @@ |
| 1 | import { RuntimeRequestId } from "@t3tools/contracts"; | 1 | import { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { act, type ReactNode } from "react"; | 2 | import { act, type ReactNode } from "react"; |
| 3 | import { create, type ReactTestRenderer } from "react-test-renderer"; | 3 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 4 | import { expect, it, vi } from "vite-plus/test"; | 4 | import { 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 | }); |
| 14 | 14 | ||
| 15 | vi.mock("../ChatMarkdown", () => ({ default: ({ text }: { text: string }) => <p>{text}</p> })); | ||
| 16 | |||
| 15 | import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; | 17 | import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; |
| 16 | 18 | ||
| 19 | const threadRef = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") }; | ||
| 20 | |||
| 17 | it("cancels an answer's pending auto-submit on revocation and resumes after a new grant", async () => { | 21 | it("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 | <ComposerPendingUserInputPanel | 48 | <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 @@ |
| 1 | import { RuntimeRequestId } from "@t3tools/contracts"; | 1 | import { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | 2 | import { renderToStaticMarkup } from "react-dom/server"; |
| 3 | import { describe, expect, it } from "vite-plus/test"; | 3 | import { describe, expect, it } from "vite-plus/test"; |
| 4 | 4 | ||
| 5 | import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; | 5 | import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; |
| 6 | |||
| 7 | const threadRef = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") }; | ||
| 6 | import type { PendingUserInput } from "../../session-logic"; | 8 | import type { PendingUserInput } from "../../session-logic"; |
| 7 | 9 | ||
| 8 | const prompt: PendingUserInput = { | 10 | const 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 | <ComposerPendingUserInputPanel | 31 | <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 @@ |
| 1 | import { type RuntimeRequestId } from "@t3tools/contracts"; | 1 | import { type RuntimeRequestId, type ScopedThreadRef } from "@t3tools/contracts"; |
| 2 | import { memo, useCallback, useEffect, useRef, useState } from "react"; | 2 | import { memo, useCallback, useEffect, useRef, useState } from "react"; |
| 3 | import { type PendingUserInput } from "../../session-logic"; | 3 | import { type PendingUserInput } from "../../session-logic"; |
| 4 | import { | 4 | import { |
| ... | @@ -9,9 +9,13 @@ import { CheckIcon } from "lucide-react"; | ... | @@ -9,9 +9,13 @@ import { CheckIcon } from "lucide-react"; |
| 9 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; | 9 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; |
| 10 | import { cn } from "~/lib/utils"; | 10 | import { cn } from "~/lib/utils"; |
| 11 | import { ComposerBanner } from "./ComposerBanner"; | 11 | import { ComposerBanner } from "./ComposerBanner"; |
| 12 | import ChatMarkdown from "../ChatMarkdown"; | ||
| 12 | 13 | ||
| 13 | interface PendingUserInputPanelProps { | 14 | interface 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 { |
| 23 | 27 | ||
| 24 | export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ | 28 | export 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 | <ComposerPendingUserInputCard | 45 | <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 |
| 52 | 60 | ||
| 53 | const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard({ | 61 | const 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} |