初步支持AskUserQuestion交互

This commit is contained in:
xintaofei
2026-03-11 19:43:24 +08:00
parent 56100c6759
commit 79a22c8a03
16 changed files with 273 additions and 2 deletions

View File

@@ -7,9 +7,13 @@ import type {
SessionModeInfo,
AvailableCommandInfo,
} from "@/lib/types"
import type { PendingPermission } from "@/contexts/acp-connections-context"
import type {
PendingPermission,
PendingQuestion,
} from "@/contexts/acp-connections-context"
import { ChatInput } from "@/components/chat/chat-input"
import { PermissionDialog } from "@/components/chat/permission-dialog"
import { QuestionDialog } from "@/components/chat/question-dialog"
interface ConversationShellProps {
status: ConnectionStatus | null
@@ -17,10 +21,12 @@ interface ConversationShellProps {
defaultPath?: string
error: string | null
pendingPermission: PendingPermission | null
pendingQuestion: PendingQuestion | null
onFocus: () => void
onSend: (draft: PromptDraft, modeId?: string | null) => void
onCancel: () => void
onRespondPermission: (requestId: string, optionId: string) => void
onAnswerQuestion: (answer: string) => void
children: ReactNode
modes?: SessionModeInfo[]
configOptions?: SessionConfigOptionInfo[]
@@ -42,10 +48,12 @@ export function ConversationShell({
defaultPath,
error,
pendingPermission,
pendingQuestion,
onFocus,
onSend,
onCancel,
onRespondPermission,
onAnswerQuestion,
children,
modes,
configOptions,
@@ -69,6 +77,8 @@ export function ConversationShell({
onRespond={onRespondPermission}
/>
<QuestionDialog question={pendingQuestion} onAnswer={onAnswerQuestion} />
{!hideInput && (
<ChatInput
status={status}

View File

@@ -0,0 +1,86 @@
"use client"
import { useState, useRef, useEffect, useCallback } from "react"
import { useTranslations } from "next-intl"
import { MessageCircleQuestion, SendHorizonal } from "lucide-react"
import { Button } from "@/components/ui/button"
import type { PendingQuestion } from "@/contexts/acp-connections-context"
interface QuestionDialogProps {
question: PendingQuestion | null
onAnswer: (answer: string) => void
}
export function QuestionDialog({ question, onAnswer }: QuestionDialogProps) {
const t = useTranslations("Folder.chat.questionDialog")
const [answer, setAnswer] = useState("")
const textareaRef = useRef<HTMLTextAreaElement>(null)
const prevQuestionIdRef = useRef<string | null>(null)
const questionId = question?.tool_call_id ?? null
if (questionId !== prevQuestionIdRef.current) {
prevQuestionIdRef.current = questionId
if (questionId && answer !== "") {
setAnswer("")
}
}
useEffect(() => {
if (question) {
textareaRef.current?.focus()
}
}, [question])
const handleSubmit = useCallback(() => {
const trimmed = answer.trim()
if (!trimmed) return
onAnswer(trimmed)
setAnswer("")
}, [answer, onAnswer])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
},
[handleSubmit]
)
if (!question) return null
return (
<div className="mx-4 mb-3 rounded-xl border border-blue-500/30 bg-card/95 p-3 shadow-sm">
<div className="flex items-center gap-1.5 text-sm font-medium">
<MessageCircleQuestion className="h-4 w-4 shrink-0 text-blue-500" />
<span>{t("title")}</span>
</div>
<p className="mt-2 text-sm text-foreground/90 whitespace-pre-wrap">
{question.question}
</p>
<div className="mt-3 flex gap-2">
<textarea
ref={textareaRef}
value={answer}
onChange={(e) => setAnswer(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("placeholder")}
rows={2}
className="flex-1 resize-none rounded-md border border-border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button
size="sm"
disabled={!answer.trim()}
onClick={handleSubmit}
className="self-end"
>
<SendHorizonal className="mr-1.5 h-3.5 w-3.5" />
{t("send")}
</Button>
</div>
</div>
)
}

View File

@@ -572,6 +572,36 @@ const ConversationTabView = memo(function ConversationTabView({
[connConnect, connDisconnect, workingDirForConnection]
)
const handleAnswerQuestion = useCallback(
(answer: string) => {
if (connStatus !== "connected") return
const optimisticTurn: MessageTurn = {
id: `optimistic-${crypto.randomUUID()}`,
role: "user",
blocks: [{ type: "text", text: answer }],
timestamp: new Date().toISOString(),
}
appendOptimisticTurn(
effectiveConversationId,
optimisticTurn,
optimisticTurn.id
)
setSendSignal((prev) => prev + 1)
setSyncState(effectiveConversationId, "awaiting_persist")
lifecycleSend(
{ blocks: [{ type: "text", text: answer }], displayText: answer },
null
)
},
[
appendOptimisticTurn,
connStatus,
effectiveConversationId,
lifecycleSend,
setSyncState,
]
)
const showDraftHeader = !hasPersistedConversation
const isWelcomeMode = showDraftHeader && !hasSentMessage
@@ -596,10 +626,12 @@ const ConversationTabView = memo(function ConversationTabView({
defaultPath={workingDirForConnection}
error={conn.error}
pendingPermission={conn.pendingPermission}
pendingQuestion={conn.pendingQuestion}
onFocus={handleFocus}
onSend={handleSend}
onCancel={handleCancel}
onRespondPermission={handleRespondPermission}
onAnswerQuestion={handleAnswerQuestion}
modes={connectionModes}
configOptions={connectionConfigOptions}
modeLoading={modeLoading}

View File

@@ -12,6 +12,7 @@ import {
import { useTranslations } from "next-intl"
import { listen, type UnlistenFn } from "@tauri-apps/api/event"
import { disposeTauriListener } from "@/lib/tauri-listener"
import { inferLiveToolName } from "@/lib/tool-call-normalization"
import {
acpConnect,
acpListAgents,
@@ -63,6 +64,11 @@ export interface PendingPermission {
options: PermissionOptionInfo[]
}
export interface PendingQuestion {
tool_call_id: string
question: string
}
export type LiveContentBlock =
| { type: "text"; text: string }
| { type: "thinking"; text: string }
@@ -92,6 +98,7 @@ export interface ConnectionState {
usage: SessionUsageUpdateInfo | null
liveMessage: LiveMessage | null
pendingPermission: PendingPermission | null
pendingQuestion: PendingQuestion | null
error: string | null
}
@@ -147,6 +154,12 @@ type Action =
options: PermissionOptionInfo[]
}
| { type: "PERMISSION_CLEARED"; contextKey: string }
| {
type: "SET_PENDING_QUESTION"
contextKey: string
pendingQuestion: PendingQuestion
}
| { type: "CLEAR_PENDING_QUESTION"; contextKey: string }
| { type: "SESSION_STARTED"; contextKey: string; sessionId: string }
| {
type: "SESSION_MODES"
@@ -259,6 +272,23 @@ function extractPermissionToolKind(toolCall: unknown): string | null {
return null
}
function extractQuestionText(rawInput: string | null): string | null {
if (!rawInput) return null
try {
const parsed = JSON.parse(rawInput)
if (
parsed &&
typeof parsed === "object" &&
typeof parsed.question === "string"
) {
return parsed.question
}
} catch {
// not JSON, try using rawInput as-is if it looks like a question
}
return null
}
function sameModes(
a: SessionModeStateInfo | null,
b: SessionModeStateInfo
@@ -451,6 +481,7 @@ function connectionsReducer(
usage: null,
liveMessage: null,
pendingPermission: null,
pendingQuestion: null,
error: null,
})
return next
@@ -477,6 +508,7 @@ function connectionsReducer(
content: [],
startedAt: Date.now(),
}
updated.pendingQuestion = null
updated.error = null
}
next.set(action.contextKey, updated)
@@ -741,6 +773,28 @@ function connectionsReducer(
return next
}
case "SET_PENDING_QUESTION": {
const conn = state.get(action.contextKey)
if (!conn) return state
const next = new Map(state)
next.set(action.contextKey, {
...conn,
pendingQuestion: action.pendingQuestion,
})
return next
}
case "CLEAR_PENDING_QUESTION": {
const conn = state.get(action.contextKey)
if (!conn) return state
const next = new Map(state)
next.set(action.contextKey, {
...conn,
pendingQuestion: null,
})
return next
}
case "SESSION_STARTED": {
const conn = state.get(action.contextKey)
if (!conn) return state
@@ -1398,14 +1452,43 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
entries: e.entries,
})
break
case "turn_complete":
case "turn_complete": {
flushStreamingQueue()
dispatch({
type: "STATUS_CHANGED",
contextKey,
status: "connected",
})
// Detect pending question from tool calls in the completed turn
const turnConn = storeRef.current.connections.get(contextKey)
if (turnConn?.liveMessage) {
const blocks = turnConn.liveMessage.content
for (let i = blocks.length - 1; i >= 0; i--) {
const block = blocks[i]
if (block.type !== "tool_call") continue
const normalized = inferLiveToolName({
title: block.info.title,
kind: block.info.kind,
rawInput: block.info.raw_input,
})
if (normalized === "question") {
const questionText = extractQuestionText(block.info.raw_input)
if (questionText) {
dispatch({
type: "SET_PENDING_QUESTION",
contextKey,
pendingQuestion: {
tool_call_id: block.info.tool_call_id,
question: questionText,
},
})
}
break
}
}
}
break
}
case "error":
flushStreamingQueue()
dispatch({ type: "ERROR", contextKey, message: e.message })

View File

@@ -8,6 +8,7 @@ import {
type ConnectOptions,
type LiveMessage,
type PendingPermission,
type PendingQuestion,
} from "@/contexts/acp-connections-context"
import type {
AgentType,
@@ -36,6 +37,7 @@ export interface UseConnectionReturn {
availableCommands: AvailableCommandInfo[] | null
liveMessage: LiveMessage | null
pendingPermission: PendingPermission | null
pendingQuestion: PendingQuestion | null
error: string | null
connect: (
agentType: AgentType,
@@ -81,6 +83,7 @@ export function useConnection(contextKey: string): UseConnectionReturn {
const availableCommands = connection?.availableCommands ?? null
const liveMessage = connection?.liveMessage ?? null
const pendingPermission = connection?.pendingPermission ?? null
const pendingQuestion = connection?.pendingQuestion ?? null
const error = connection?.error ?? null
const connect = useCallback(
@@ -137,6 +140,7 @@ export function useConnection(contextKey: string): UseConnectionReturn {
availableCommands,
liveMessage,
pendingPermission,
pendingQuestion,
error,
connect,
disconnect,
@@ -157,6 +161,7 @@ export function useConnection(contextKey: string): UseConnectionReturn {
availableCommands,
liveMessage,
pendingPermission,
pendingQuestion,
error,
connect,
disconnect,

View File

@@ -1175,6 +1175,11 @@
"plan": "الخطة",
"targetMode": "وضع الهدف: {mode}"
},
"questionDialog": {
"title": "الوكيل يطرح سؤالاً",
"placeholder": "اكتب إجابتك...",
"send": "إرسال"
},
"messageBranch": {
"previousBranchAria": "الفرع السابق",
"nextBranchAria": "الفرع التالي",

View File

@@ -1175,6 +1175,11 @@
"plan": "Arbeitsplan",
"targetMode": "Zielmodus: {mode}"
},
"questionDialog": {
"title": "Agent stellt eine Frage",
"placeholder": "Antwort eingeben...",
"send": "Senden"
},
"messageBranch": {
"previousBranchAria": "Vorheriger Branch",
"nextBranchAria": "Nächster Branch",

View File

@@ -1175,6 +1175,11 @@
"plan": "Plan",
"targetMode": "Target mode: {mode}"
},
"questionDialog": {
"title": "Agent is asking a question",
"placeholder": "Type your answer...",
"send": "Send"
},
"messageBranch": {
"previousBranchAria": "Previous branch",
"nextBranchAria": "Next branch",

View File

@@ -1175,6 +1175,11 @@
"plan": "Plan de trabajo",
"targetMode": "Modo objetivo: {mode}"
},
"questionDialog": {
"title": "El agente está haciendo una pregunta",
"placeholder": "Escribe tu respuesta...",
"send": "Enviar"
},
"messageBranch": {
"previousBranchAria": "Rama anterior",
"nextBranchAria": "Rama siguiente",

View File

@@ -1175,6 +1175,11 @@
"plan": "Plan de travail",
"targetMode": "Mode cible : {mode}"
},
"questionDialog": {
"title": "L'agent pose une question",
"placeholder": "Tapez votre réponse...",
"send": "Envoyer"
},
"messageBranch": {
"previousBranchAria": "Branche précédente",
"nextBranchAria": "Branche suivante",

View File

@@ -1175,6 +1175,11 @@
"plan": "計画",
"targetMode": "対象モード: {mode}"
},
"questionDialog": {
"title": "エージェントが質問しています",
"placeholder": "回答を入力...",
"send": "送信"
},
"messageBranch": {
"previousBranchAria": "前のブランチ",
"nextBranchAria": "次のブランチ",

View File

@@ -1175,6 +1175,11 @@
"plan": "계획",
"targetMode": "대상 모드: {mode}"
},
"questionDialog": {
"title": "에이전트가 질문하고 있습니다",
"placeholder": "답변을 입력하세요...",
"send": "전송"
},
"messageBranch": {
"previousBranchAria": "이전 브랜치",
"nextBranchAria": "다음 브랜치",

View File

@@ -1175,6 +1175,11 @@
"plan": "Plano",
"targetMode": "Modo de destino: {mode}"
},
"questionDialog": {
"title": "O agente está fazendo uma pergunta",
"placeholder": "Digite sua resposta...",
"send": "Enviar"
},
"messageBranch": {
"previousBranchAria": "Branch anterior",
"nextBranchAria": "Próxima branch",

View File

@@ -1175,6 +1175,11 @@
"plan": "计划",
"targetMode": "目标模式:{mode}"
},
"questionDialog": {
"title": "代理正在提问",
"placeholder": "输入你的回答...",
"send": "发送"
},
"messageBranch": {
"previousBranchAria": "上一分支",
"nextBranchAria": "下一分支",

View File

@@ -1175,6 +1175,11 @@
"plan": "計畫",
"targetMode": "目標模式:{mode}"
},
"questionDialog": {
"title": "代理正在提問",
"placeholder": "輸入你的回答...",
"send": "傳送"
},
"messageBranch": {
"previousBranchAria": "上一個分支",
"nextBranchAria": "下一個分支",

View File

@@ -51,6 +51,8 @@ const EXACT_TOOL_NAME_ALIASES: Record<string, string> = {
background_output: "task",
slashcommand: "skill",
question: "question",
ask_user_question: "question",
askuserquestion: "question",
lsp_diagnostics: "lsp",
lsp_document_symbols: "lsp",
lsp_goto_definition: "lsp",
@@ -97,6 +99,7 @@ function inferFromFreeformName(input: string): string | null {
if (/^taskcreate(?:\b|[_\s:-])/.test(normalized)) return "taskcreate"
if (/^tasklist(?:\b|[_\s:-])/.test(normalized)) return "tasklist"
if (/^task(?:\b|[_\s:-])/.test(normalized)) return "task"
if (/\bask\s*(?:user)?\s*question\b/.test(normalized)) return "question"
return null
}
@@ -181,6 +184,8 @@ function inferFromInput(
if (hasPattern) return hasGlob ? "glob" : "grep"
if (hasGlob) return "glob"
if (hasAnyKey(parsed, ["question"])) return "question"
if (hasAnyKey(parsed, ["subagent_type", "taskId", "task_id", "subject"])) {
return "task"
}