feat(ai): Agent 面板运行中消息排队
忙时可继续输入并排队;立即打断当前任务、编辑或撤销; 结束后自动续发。补充 zh/en 文案与 docs/02。
This commit is contained in:
1 parent
96497092a8
commit
2ca32f3199
5 files changed
+145
-22
No files matched your search
@@ -0,0 +1,30 @@
|
||||
# 运行中消息排队(三产品统一)
|
||||
|
||||
> // Logan.Li
|
||||
> 更新:2026-09-19
|
||||
> 对标常见 Agent UX(Zcode / Cursor):当前任务仍在跑时,允许预写下一条。
|
||||
|
||||
## 语义
|
||||
|
||||
| 动作 | 含义 |
|
||||
| ------------ | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| **排队** | 运行中提交的新输入进入 `pending`(默认一条;再提交则覆盖或需先撤销) |
|
||||
| **自动续发** | 当前任务 `completed` / `incomplete` / `failed` / `cancelled` 结束后,若仍有 pending,自动开始该条(continue/追问语义) |
|
||||
| **立即** | 中断当前任务,立刻用 pending 内容开跑 |
|
||||
| **编辑** | 把 pending 拉回输入框,清空排队槽 |
|
||||
| **撤销** | 丢弃 pending,不发送 |
|
||||
|
||||
与 [运行结果状态与可续跑契约]((见 opsxcli/aiops docs/13-运行结果状态与可续跑契约.md)) 的关系:排队是**同会话并发输入**;incomplete 续跑是**上一趟未完成再继续**。两者可叠加。
|
||||
|
||||
## 产品落点
|
||||
|
||||
| 产品 | UI | 中断手段 |
|
||||
| ----------- | ------------------------------------- | ------------------------------------------ |
|
||||
| opsxshell | Agent 面板排队条 | `stopAgent`(bump runId)后立刻 `runAgent` |
|
||||
| aiops Web | AgentCLI 输入区排队条 | WS `abort` 后再 `continue`/`start` |
|
||||
| opsxcli TUI | 忙时 Enter 入队;快捷键立即/编辑/撤销 | cancel 当前 RunStream ctx |
|
||||
|
||||
## 不做
|
||||
|
||||
- 多条 FIFO 长队列(首版单槽)
|
||||
- 跨会话排队
|
||||
@@ -5,3 +5,5 @@
|
||||
| [01-AI-Agent步数预算与incomplete](01-AI-Agent步数预算与incomplete.md) | MAX_AGENT_STEPS=12 与 incomplete/续跑目标 |
|
||||
|
||||
跨产品契约以 aiops/opsxcli 的 `13-运行结果状态与可续跑契约.md` 为准。
|
||||
|
||||
- [02-运行中消息排队](02-运行中消息排队.md) — 忙时排队 / 立即 / 编辑 / 撤销
|
||||
@@ -5,10 +5,12 @@ import {
|
||||
CheckCircle2,
|
||||
Clipboard,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
Send,
|
||||
Sparkles,
|
||||
Square,
|
||||
ArrowUp,
|
||||
TerminalSquare,
|
||||
TextSelect,
|
||||
X,
|
||||
@@ -182,6 +184,12 @@ export default function AiTerminalPanel({
|
||||
const confirmAllCommands = useSettingsStore((state) => state.settings.agentConfirmAllCommands)
|
||||
const setSetting = useSettingsStore((state) => state.setSetting)
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [queuedPrompt, setQueuedPrompt] = useState<string | null>(null)
|
||||
const queuedRef = useRef<string | null>(null)
|
||||
const setQueued = (v: string | null) => {
|
||||
queuedRef.current = v
|
||||
setQueuedPrompt(v)
|
||||
}
|
||||
const [messages, setMessages] = useState<ThreadMessage[]>([])
|
||||
const [liveSteps, setLiveSteps] = useState<AgentStep[]>([])
|
||||
const [error, setError] = useState('')
|
||||
@@ -220,12 +228,13 @@ export default function AiTerminalPanel({
|
||||
setLiveSteps([])
|
||||
setError('')
|
||||
setPrompt('')
|
||||
setQueued(null)
|
||||
setCopiedId('')
|
||||
sessionAllowRef.current.clear()
|
||||
initialSelectionRef.current = ''
|
||||
}
|
||||
|
||||
const runAgent = async () => {
|
||||
const runAgent = async (taskOverride?: string) => {
|
||||
setError('')
|
||||
if (!provider || !model) {
|
||||
setError(t('error.noProvider'))
|
||||
@@ -235,7 +244,7 @@ export default function AiTerminalPanel({
|
||||
setError(t('error.noApiKey', { provider: provider.name }))
|
||||
return
|
||||
}
|
||||
const task = prompt.trim()
|
||||
const task = (taskOverride ?? prompt).trim()
|
||||
if (!task) {
|
||||
setError(t('error.emptyTask'))
|
||||
return
|
||||
@@ -431,10 +440,54 @@ export default function AiTerminalPanel({
|
||||
if (runIdRef.current === runId) {
|
||||
runConnectionIdRef.current = null
|
||||
setRunning(false)
|
||||
// 自动续发排队消息(契约 14) // Logan.Li
|
||||
const pending = queuedRef.current
|
||||
if (pending && pending.trim()) {
|
||||
const next = pending.trim()
|
||||
setQueued(null)
|
||||
queueMicrotask(() => {
|
||||
void runAgent(next)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const enqueueOrSend = () => {
|
||||
const task = prompt.trim()
|
||||
if (!task) {
|
||||
setError(t('error.emptyTask'))
|
||||
return
|
||||
}
|
||||
if (running) {
|
||||
setQueued(task)
|
||||
setPrompt('')
|
||||
setError('')
|
||||
return
|
||||
}
|
||||
void runAgent(task)
|
||||
}
|
||||
|
||||
const sendQueuedNow = () => {
|
||||
const task = queuedPrompt?.trim()
|
||||
if (!task) return
|
||||
setQueued(null)
|
||||
stopAgent()
|
||||
queueMicrotask(() => {
|
||||
void runAgent(task)
|
||||
})
|
||||
}
|
||||
|
||||
const editQueued = () => {
|
||||
if (!queuedPrompt) return
|
||||
setPrompt(queuedPrompt)
|
||||
setQueued(null)
|
||||
}
|
||||
|
||||
const cancelQueued = () => {
|
||||
setQueued(null)
|
||||
}
|
||||
|
||||
const copyMessage = async (id: string, content: string) => {
|
||||
await writeClipboardText(content)
|
||||
setCopiedId(id)
|
||||
@@ -642,32 +695,61 @@ export default function AiTerminalPanel({
|
||||
</div>
|
||||
|
||||
<div className="border-dark-700/50 flex-shrink-0 border-t px-3 py-2.5">
|
||||
{queuedPrompt ? (
|
||||
<div className="mb-2 rounded-lg border border-accent-blue/30 bg-accent-blue/10 px-2.5 py-2">
|
||||
<div className="mb-1 text-[10px] text-txt-faint">{t('panel.queueHint')}</div>
|
||||
<div className="mb-2 line-clamp-2 whitespace-pre-wrap text-xs text-content-secondary">{queuedPrompt}</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button variant="primary" size="sm" icon={<ArrowUp className="h-3.5 w-3.5" />} onClick={sendQueuedNow}>
|
||||
{t('panel.queueSendNow')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" icon={<Pencil className="h-3.5 w-3.5" />} onClick={editQueued}>
|
||||
{t('panel.queueEdit')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" icon={<X className="h-3.5 w-3.5" />} onClick={cancelQueued}>
|
||||
{t('panel.queueCancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault()
|
||||
if (!running) void runAgent()
|
||||
enqueueOrSend()
|
||||
}
|
||||
}}
|
||||
rows={3}
|
||||
disabled={running}
|
||||
placeholder={t('panel.placeholder')}
|
||||
className="ui-input-sm min-h-[72px] resize-none leading-relaxed disabled:opacity-70"
|
||||
placeholder={running ? t('panel.placeholderQueue') : t('panel.placeholder')}
|
||||
className="ui-input-sm min-h-[72px] resize-none leading-relaxed"
|
||||
/>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-[10px] text-txt-faint">
|
||||
{messages.length > 0
|
||||
? t('panel.hintFollowUp')
|
||||
: capture().selection.trim()
|
||||
? t('panel.hintSelection', { lines: terminalContextLineCount(capture().selection) })
|
||||
: t('panel.hintSteps', { max: MAX_AGENT_STEPS })}
|
||||
{running
|
||||
? t('panel.hintQueue')
|
||||
: messages.length > 0
|
||||
? t('panel.hintFollowUp')
|
||||
: capture().selection.trim()
|
||||
? t('panel.hintSelection', { lines: terminalContextLineCount(capture().selection) })
|
||||
: t('panel.hintSteps', { max: MAX_AGENT_STEPS })}
|
||||
</span>
|
||||
{running ? (
|
||||
<Button variant="secondary" size="sm" icon={<Square className="h-3.5 w-3.5" />} onClick={stopAgent}>
|
||||
{t('panel.stop')}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Send className="h-3.5 w-3.5" />}
|
||||
onClick={enqueueOrSend}
|
||||
disabled={!prompt.trim()}
|
||||
>
|
||||
{t('panel.queue')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" icon={<Square className="h-3.5 w-3.5" />} onClick={stopAgent}>
|
||||
{t('panel.stop')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
@@ -679,12 +761,7 @@ export default function AiTerminalPanel({
|
||||
>
|
||||
{t('panel.summarize')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Send className="h-3.5 w-3.5" />}
|
||||
onClick={() => void runAgent()}
|
||||
>
|
||||
<Button variant="primary" size="sm" icon={<Send className="h-3.5 w-3.5" />} onClick={enqueueOrSend}>
|
||||
{t('panel.send')}
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -22,7 +22,14 @@
|
||||
"footerNote": "High-risk commands need confirmation · destructive ones are blocked · chat is not saved",
|
||||
"summarize": "Summarize",
|
||||
"summarizeTitle": "Summarize the visible terminal output with AI (selection first); never runs commands",
|
||||
"summarizeRequest": "Summarize the current terminal output"
|
||||
"summarizeRequest": "Summarize the current terminal output",
|
||||
"queueHint": "Queued (sends when the current run finishes)",
|
||||
"queueSendNow": "Send now",
|
||||
"queueEdit": "Edit",
|
||||
"queueCancel": "Dismiss",
|
||||
"queue": "Queue",
|
||||
"placeholderQueue": "Keep typing to queue…",
|
||||
"hintQueue": "Busy: send will queue your message"
|
||||
},
|
||||
"safety": {
|
||||
"aria": "Agent safety mode",
|
||||
|
||||
@@ -22,7 +22,14 @@
|
||||
"footerNote": "高风险先确认 · 破坏性命令直接拒绝 · 对话不保存",
|
||||
"summarize": "总结输出",
|
||||
"summarizeTitle": "用 AI 总结当前终端可见输出(优先使用选中文本),不执行任何命令",
|
||||
"summarizeRequest": "总结当前终端输出"
|
||||
"summarizeRequest": "总结当前终端输出",
|
||||
"queueHint": "排队中(当前任务结束后自动发送)",
|
||||
"queueSendNow": "立即",
|
||||
"queueEdit": "编辑",
|
||||
"queueCancel": "撤销",
|
||||
"queue": "排队",
|
||||
"placeholderQueue": "继续输入以排队…",
|
||||
"hintQueue": "任务进行中:发送将进入排队"
|
||||
},
|
||||
"safety": {
|
||||
"aria": "Agent 安全模式",
|
||||
|
||||
Reference in new issue
Block a user