feat(ai): Agent 面板运行中消息排队

忙时可继续输入并排队;立即打断当前任务、编辑或撤销;
结束后自动续发。补充 zh/en 文案与 docs/02。
This commit is contained in:
Logan.Li committed 2026-09-19 00:17:08 +08:00
1 parent 96497092a8
commit 2ca32f3199
5 files changed
+145 -22

No files matched your search

+30
View File
@@ -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 长队列(首版单槽)
- 跨会话排队
+2
View File
@@ -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) — 忙时排队 / 立即 / 编辑 / 撤销
+97 -20
View File
@@ -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>
</>
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -22,7 +22,14 @@
"footerNote": "高风险先确认 · 破坏性命令直接拒绝 · 对话不保存",
"summarize": "总结输出",
"summarizeTitle": "用 AI 总结当前终端可见输出(优先使用选中文本),不执行任何命令",
"summarizeRequest": "总结当前终端输出"
"summarizeRequest": "总结当前终端输出",
"queueHint": "排队中(当前任务结束后自动发送)",
"queueSendNow": "立即",
"queueEdit": "编辑",
"queueCancel": "撤销",
"queue": "排队",
"placeholderQueue": "继续输入以排队…",
"hintQueue": "任务进行中:发送将进入排队"
},
"safety": {
"aria": "Agent 安全模式",