初步支持AskUserQuestion交互
This commit is contained in:
@@ -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}
|
||||
|
||||
86
src/components/chat/question-dialog.tsx
Normal file
86
src/components/chat/question-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1175,6 +1175,11 @@
|
||||
"plan": "الخطة",
|
||||
"targetMode": "وضع الهدف: {mode}"
|
||||
},
|
||||
"questionDialog": {
|
||||
"title": "الوكيل يطرح سؤالاً",
|
||||
"placeholder": "اكتب إجابتك...",
|
||||
"send": "إرسال"
|
||||
},
|
||||
"messageBranch": {
|
||||
"previousBranchAria": "الفرع السابق",
|
||||
"nextBranchAria": "الفرع التالي",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1175,6 +1175,11 @@
|
||||
"plan": "計画",
|
||||
"targetMode": "対象モード: {mode}"
|
||||
},
|
||||
"questionDialog": {
|
||||
"title": "エージェントが質問しています",
|
||||
"placeholder": "回答を入力...",
|
||||
"send": "送信"
|
||||
},
|
||||
"messageBranch": {
|
||||
"previousBranchAria": "前のブランチ",
|
||||
"nextBranchAria": "次のブランチ",
|
||||
|
||||
@@ -1175,6 +1175,11 @@
|
||||
"plan": "계획",
|
||||
"targetMode": "대상 모드: {mode}"
|
||||
},
|
||||
"questionDialog": {
|
||||
"title": "에이전트가 질문하고 있습니다",
|
||||
"placeholder": "답변을 입력하세요...",
|
||||
"send": "전송"
|
||||
},
|
||||
"messageBranch": {
|
||||
"previousBranchAria": "이전 브랜치",
|
||||
"nextBranchAria": "다음 브랜치",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1175,6 +1175,11 @@
|
||||
"plan": "计划",
|
||||
"targetMode": "目标模式:{mode}"
|
||||
},
|
||||
"questionDialog": {
|
||||
"title": "代理正在提问",
|
||||
"placeholder": "输入你的回答...",
|
||||
"send": "发送"
|
||||
},
|
||||
"messageBranch": {
|
||||
"previousBranchAria": "上一分支",
|
||||
"nextBranchAria": "下一分支",
|
||||
|
||||
@@ -1175,6 +1175,11 @@
|
||||
"plan": "計畫",
|
||||
"targetMode": "目標模式:{mode}"
|
||||
},
|
||||
"questionDialog": {
|
||||
"title": "代理正在提問",
|
||||
"placeholder": "輸入你的回答...",
|
||||
"send": "傳送"
|
||||
},
|
||||
"messageBranch": {
|
||||
"previousBranchAria": "上一個分支",
|
||||
"nextBranchAria": "下一個分支",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user