feat(sidebar): agent 终端等待 banner(needle + 跳过等待按钮)

This commit is contained in:
HuanLinOTO
2026-09-09 13:37:08 +08:00
parent 1fa777293f
commit c4c856a450
25 changed files with 189 additions and 0 deletions
+28
View File
@@ -48,6 +48,7 @@ import {
shouldActivateTerminalLink,
openTerminalUrl,
} from './terminal-links.ts'
import { TerminalWaitBanner } from './TerminalWaitBanner.tsx'
import css from './sidebar.module.css'
/** How many consecutive unreasoned failures before showing the error banner. */
@@ -123,6 +124,27 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
const [fatal, setFatal] = useState<string | null>(null)
const [depsFatal, setDepsFatal] = useState<TerminalDepsInfo | null>(null)
const [lastUrl, setLastUrl] = useState<string | null>(null)
// Agent terminals only: the model's active terminal_wait_for (mirrored
// from the host's agent-terminals push into the store) drives the wait
// banner. Read + subscribe like the font prefs above; the banner vanishes
// when the host's push drops the waiting field (skip / exit / abort all
// converge through the same push). getSnapshot() is {sessionId, state?,
// prefs} — the state may be briefly undefined around session switches.
const agentUuid = isAgentTabId(tabId) ? agentUuidOf(tabId) : null
const [waiting, setWaiting] = useState<{ needle: string; since: number } | undefined>(undefined)
useEffect(() => {
if (agentUuid === null) return
const read = (): void => {
const next = store.getSnapshot().state?.agentWaits?.[agentUuid]
setWaiting(prev => {
const nextValue = next === undefined ? undefined : { needle: next.needle, since: next.since }
if (prev?.needle === nextValue?.needle && prev?.since === nextValue?.since) return prev
return nextValue
})
}
read()
return store.subscribe(read)
}, [agentUuid, store])
const connectRef = useRef<(() => void) | null>(null)
useEffect(() => {
@@ -386,6 +408,12 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
return (
<div className={css.terminalWrap}>
{agentUuid !== null && waiting !== undefined && (
<TerminalWaitBanner
needle={waiting.needle}
onSkip={() => { void api.agentSkipWait(agentUuid).catch(() => { /* 跳过失败时 banner 留存,可重试 */ }) }}
/>
)}
{depsFatal !== null && (
<TerminalDepsBanner deps={depsFatal} onRetry={() => { setDepsFatal(null); connectRef.current?.() }} />
)}
+32
View File
@@ -0,0 +1,32 @@
/**
* The "Agent 正在等待 {needle}" wait banner: rendered at the top of an
* agent-owned terminal's view while the model blocks in terminal_wait_for.
* The skip button asks the host to abort every active wait on the terminal
* (`agent-pty.skip-wait`); the banner disappears when the host's next
* agent-terminals push drops the waiting field — no optimistic UI. Kept in
* its own module (no xterm imports) so jsdom tests can render it directly.
*/
import { t } from './locales.ts'
import css from './sidebar.module.css'
/** Cap the needle shown inline; the full text rides the title tooltip. */
const NEEDLE_DISPLAY_CAP = 80
/** Truncate one needle for inline display (title attr carries the full text). */
export function truncateNeedle(needle: string): string {
return needle.length > NEEDLE_DISPLAY_CAP ? `${needle.slice(0, NEEDLE_DISPLAY_CAP - 1)}…` : needle
}
export function TerminalWaitBanner(props: { needle: string; onSkip: () => void }) {
const { needle, onSkip } = props
return (
<div className={css.terminalWaitBanner}>
<span className={css.terminalWaitNeedle} title={needle}>
{t('terminalWaitBanner', { needle: truncateNeedle(needle) })}
</span>
<button type="button" className={css.terminalRetry} onClick={onSkip}>
{t('terminalSkipWait')}
</button>
</div>
)
}
+4
View File
@@ -346,6 +346,10 @@ export const api = {
/** Release an agent terminal by uuid (tab closed while WS was down). */
agentPtyClose: (uuid: string) =>
call<{ ok: true }>('agent-pty.close', { uuid }),
/** Skip every active terminal_wait_for on one agent terminal (the wait
* banner's skip button). Idempotent: {skipped:0} when none is active. */
agentSkipWait: (uuid: string) =>
call<{ ok: true; skipped: number }>('agent-pty.skip-wait', { uuid }),
/** Terminal dependency status (issue #140): after a WS close 1011 with
* reason `pty-deps-missing` the view fetches the full repair details here
* (the close reason itself is capped at 123 bytes). */
+2
View File
@@ -104,6 +104,8 @@ export const ar: Record<string, string> = {
terminalError: 'فشل اتصال الطرفية',
terminalConnectFailed: 'فشل اتصال الطرفية عدة مرات',
terminalRetry: 'إعادة المحاولة',
terminalWaitBanner: 'الوكيل ينتظر {needle}',
terminalSkipWait: 'تخطي الانتظار',
terminalDepsFailed: 'فشل تحميل تبعية الطرفية node-pty',
terminalDepsHint: 'شغّل الأمر التالي في طرفية أو cmd على جهاز DSH للإصلاح، ثم أعد المحاولة (يبقى node-pty متزامناً مع إصدار نواة DSH):',
terminalDepsProfile: ' (الملف الشخصي المكتشف: {profile})',
+2
View File
@@ -89,6 +89,8 @@ export const de: Record<string, string> = {
terminalError: 'Terminalverbindung fehlgeschlagen',
terminalConnectFailed: 'Terminal konnte wiederholt keine Verbindung herstellen',
terminalRetry: 'Erneut versuchen',
terminalWaitBanner: 'Agent wartet auf {needle}',
terminalSkipWait: 'Warten überspringen',
terminalDepsFailed: 'Die Terminal-Abhängigkeit node-pty konnte nicht geladen werden',
terminalDepsHint: 'Führen Sie den folgenden Befehl in einem Terminal oder in cmd auf dem DSH-System aus, um dies zu beheben, und klicken Sie dann auf „Erneut versuchen“ (node-pty bleibt mit der DSH-Core-Version synchron):',
terminalDepsProfile: ' (erkanntes Profil: {profile})',
+2
View File
@@ -96,6 +96,8 @@ export const fr: Record<string, string> = {
terminalError: 'Échec de la connexion au terminal',
terminalConnectFailed: 'Le terminal a échoué à se connecter à plusieurs reprises',
terminalRetry: 'Réessayer',
terminalWaitBanner: 'L’agent attend {needle}',
terminalSkipWait: 'Ignorer l’attente',
terminalDepsFailed: 'Échec du chargement de la dépendance terminal node-pty',
terminalDepsHint: 'Exécutez la commande suivante dans un terminal ou cmd de l’environnement DSH pour réparer, puis cliquez sur Réessayer (node-pty reste à la même version que le cœur DSH) :',
terminalDepsProfile: ' (profil détecté : {profile})',
+2
View File
@@ -103,6 +103,8 @@ export const hi: Record<string, string> = {
terminalError: 'टर्मिनल कनेक्शन विफल',
terminalConnectFailed: 'टर्मिनल बार-बार कनेक्ट विफल',
terminalRetry: 'पुनः प्रयास',
terminalWaitBanner: 'एजेंट {needle} की प्रतीक्षा कर रहा है',
terminalSkipWait: 'प्रतीक्षा छोड़ें',
terminalDepsFailed: 'टर्मिनल निर्भरता node-pty लोड विफल',
terminalDepsHint: 'DSH मशीन पर एक टर्मिनल या cmd में नीचे दिया गया कमांड चलाएँ, फिर पुनः प्रयास करें (node-pty DSH कोर संस्करण के साथ सिंक रहता है):',
terminalDepsProfile: ' (पहचाना गया प्रोफ़ाइल: {profile})',
+2
View File
@@ -101,6 +101,8 @@ export const id: Record<string, string> = {
terminalError: 'Koneksi terminal gagal',
terminalConnectFailed: 'Terminal gagal terhubung berulang kali',
terminalRetry: 'Coba lagi',
terminalWaitBanner: 'Agen menunggu {needle}',
terminalSkipWait: 'Lewati penungguan',
terminalDepsFailed: 'Dependensi terminal node-pty gagal dimuat',
terminalDepsHint: 'Jalankan perintah di bawah ini di terminal atau cmd pada mesin DSH untuk memperbaikinya, lalu coba lagi (node-pty tetap sinkron dengan versi inti DSH):',
terminalDepsProfile: ' (profil terdeteksi: {profile})',
+2
View File
@@ -94,6 +94,8 @@ export const it: Record<string, string> = {
terminalError: 'Connessione del terminale non riuscita',
terminalConnectFailed: 'Il terminale non è riuscito a connettersi più volte',
terminalRetry: 'Riprova',
terminalWaitBanner: 'L’agente è in attesa di {needle}',
terminalSkipWait: 'Salta attesa',
terminalDepsFailed: 'Caricamento della dipendenza del terminale node-pty non riuscito',
terminalDepsHint: 'Esegua il comando seguente in un terminale o cmd sul computer DSH per ripristinarlo, poi riprovi (node-pty resta sincronizzato con la versione del core DSH):',
terminalDepsProfile: ' (rilevato profilo: {profile})',
+2
View File
@@ -103,6 +103,8 @@ export const ja: Record<string, string> = {
terminalError: 'ターミナル接続に失敗',
terminalConnectFailed: 'ターミナル接続が繰り返し失敗しました',
terminalRetry: '再試行',
terminalWaitBanner: 'エージェントが {needle} を待機中',
terminalSkipWait: '待機をスキップ',
terminalDepsFailed: 'ターミナル依存関係 node-pty の読み込みに失敗',
terminalDepsHint: 'DSH 環境のターミナルまたは cmd で以下のコマンドを実行して修復し、再試行してください(node-pty は DSH コアと同じバージョンを維持):',
terminalDepsProfile: '(検出された profile:{profile})',
+2
View File
@@ -95,6 +95,8 @@ export const ko: Record<string, string> = {
terminalError: '터미널 연결 실패',
terminalConnectFailed: '터미널 연결이 여러 번 실패했습니다',
terminalRetry: '다시 시도',
terminalWaitBanner: '에이전트가 {needle} 대기 중',
terminalSkipWait: '대기 건너뛰기',
terminalDepsFailed: '터미널 의존성 node-pty 로드 실패',
terminalDepsHint: 'DSH가 설치된 환경의 터미널 또는 cmd에서 아래 명령을 실행하여 복구한 후 다시 시도를 클릭하세요(node-pty는 DSH 코어와 동일한 버전을 유지합니다):',
terminalDepsProfile: ' (profile 감지됨: {profile})',
+2
View File
@@ -101,6 +101,8 @@ export const nl: Record<string, string> = {
terminalError: 'Terminalverbinding mislukt',
terminalConnectFailed: 'Terminalverbinding herhaaldelijk mislukt',
terminalRetry: 'Opnieuw proberen',
terminalWaitBanner: 'Agent wacht op {needle}',
terminalSkipWait: 'Wachten overslaan',
terminalDepsFailed: 'Terminalafhankelijkheid node-pty kon niet worden geladen',
terminalDepsHint: 'Voer het onderstaande commando uit in een terminal of cmd op de DSH-machine om het te herstellen en probeer het opnieuw (node-pty blijft in sync met de DSH-coreversie):',
terminalDepsProfile: ' (gedetecteerde profile: {profile})',
+2
View File
@@ -105,6 +105,8 @@ export const pl: Record<string, string> = {
terminalError: 'Połączenie z terminalem nie powiodło się',
terminalConnectFailed: 'Terminal nie mógł połączyć się wielokrotnie',
terminalRetry: 'Ponów',
terminalWaitBanner: 'Agent czeka na {needle}',
terminalSkipWait: 'Pomiń oczekiwanie',
terminalDepsFailed: 'Zależność terminala node-pty nie mogła się załadować',
terminalDepsHint: 'Uruchom poniższe polecenie w terminalu lub cmd na maszynie DSH, aby to naprawić, a następnie ponów (node-pty pozostaje w synchronizacji z wersją rdzenia DSH):',
terminalDepsProfile: ' (wykryty profil: {profile})',
+2
View File
@@ -86,6 +86,8 @@ export const pt: Record<string, string> = {
terminalError: 'Falha na conexão do terminal',
terminalConnectFailed: 'O terminal falhou ao conectar repetidamente',
terminalRetry: 'Tentar novamente',
terminalWaitBanner: 'O agente está aguardando {needle}',
terminalSkipWait: 'Pular espera',
terminalDepsFailed: 'A dependência do terminal node-pty falhou ao carregar',
terminalDepsHint: 'Execute o comando abaixo em um terminal ou cmd na máquina do DSH para reparar e tente novamente (node-pty permanece em sincronia com a versão do núcleo do DSH):',
terminalDepsProfile: ' (perfil detectado: {profile})',
+2
View File
@@ -99,6 +99,8 @@ export const ru: Record<string, string> = {
terminalError: 'Сбой подключения терминала',
terminalConnectFailed: 'Терминал не смог подключиться после нескольких попыток',
terminalRetry: 'Повторить',
terminalWaitBanner: 'Агент ожидает {needle}',
terminalSkipWait: 'Пропустить ожидание',
terminalDepsFailed: 'Не удалось загрузить зависимость терминала node-pty',
terminalDepsHint: 'Выполните приведённую ниже команду в терминале или cmd на машине DSH, затем нажмите «Повторить» (node-pty остаётся синхронизированным с версией ядра DSH):',
terminalDepsProfile: ' (обнаружен profile: {profile})',
+2
View File
@@ -86,6 +86,8 @@ export const sv: Record<string, string> = {
terminalError: 'Terminalanslutning misslyckades',
terminalConnectFailed: 'Terminal misslyckades att ansluta upprepade gånger',
terminalRetry: 'Försök igen',
terminalWaitBanner: 'Agenten väntar på {needle}',
terminalSkipWait: 'Hoppa över väntan',
terminalDepsFailed: 'Terminalberoendet node-pty kunde inte laddas',
terminalDepsHint: 'Kör kommandot nedan i en terminal eller cmd på DSH-maskinen för att reparera det, försök sedan igen (node-pty hålls synkad med DSH-kärnversionen):',
terminalDepsProfile: ' (upptäckt profil: {profile})',
+2
View File
@@ -103,6 +103,8 @@ export const th: Record<string, string> = {
terminalError: 'การเชื่อมต่อเทอร์มินัลล้มเหลว',
terminalConnectFailed: 'เทอร์มินัลเชื่อมต่อล้มเหลวหลายครั้ง',
terminalRetry: 'ลองอีกครั้ง',
terminalWaitBanner: 'เอเจนต์กำลังรอ {needle}',
terminalSkipWait: 'ข้ามการรอ',
terminalDepsFailed: 'การอ้างอิงเทอร์มินัล node-pty โหลดล้มเหลว',
terminalDepsHint: 'เรียกใช้คำสั่งด้านล่างในเทอร์มินัลหรือ cmd บนเครื่อง DSH เพื่อซ่อมแซม แล้วกดลองอีกครั้ง (node-pty ต้องซิงค์กับเวอร์ชันหลักของ DSH):',
terminalDepsProfile: ' (ตรวจพบ profile: {profile})',
+2
View File
@@ -103,6 +103,8 @@ export const tr: Record<string, string> = {
terminalError: 'Terminal bağlantısı başarısız',
terminalConnectFailed: 'Terminal art arda bağlanamadı',
terminalRetry: 'Yeniden dene',
terminalWaitBanner: 'Ajan {needle} bekliyor',
terminalSkipWait: 'Beklemeyi atla',
terminalDepsFailed: 'Terminal bağımlılığı node-pty yüklenemedi',
terminalDepsHint: 'Onarmak için DSH makinesindeki bir terminalde veya cmd’de aşağıdaki komutu çalıştırın, sonra yeniden deneyin (node-pty, DSH çekirdek sürümüyle senkron kalır):',
terminalDepsProfile: ' (algılanan profil: {profile})',
+2
View File
@@ -103,6 +103,8 @@ export const vi: Record<string, string> = {
terminalError: 'Kết nối terminal thất bại',
terminalConnectFailed: 'Terminal kết nối thất bại nhiều lần',
terminalRetry: 'Thử lại',
terminalWaitBanner: 'Agent đang chờ {needle}',
terminalSkipWait: 'Bỏ qua thời gian chờ',
terminalDepsFailed: 'Dependency terminal node-pty tải thất bại',
terminalDepsHint: 'Chạy lệnh sau trong terminal hoặc cmd trên máy DSH để sửa, rồi nhấn thử lại (node-pty đồng bộ phiên bản với lõi DSH):',
terminalDepsProfile: ' (phát hiện profile: {profile})',
+2
View File
@@ -118,6 +118,8 @@ export const zhHK: Record<string, string> = {
terminalError: '終端連線失敗',
terminalConnectFailed: '終端多次連線失敗',
terminalRetry: '重試',
terminalWaitBanner: 'Agent 正在等待 {needle}',
terminalSkipWait: '跳過等待',
terminalDepsFailed: '終端依賴 node-pty 載入失敗',
terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):',
terminalDepsProfile: '(偵測到 profile:{profile})',
+2
View File
@@ -118,6 +118,8 @@ export const zhMO: Record<string, string> = {
terminalError: '終端連線失敗',
terminalConnectFailed: '終端多次連線失敗',
terminalRetry: '重試',
terminalWaitBanner: 'Agent 正在等待 {needle}',
terminalSkipWait: '跳過等待',
terminalDepsFailed: '終端依賴 node-pty 載入失敗',
terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):',
terminalDepsProfile: '(偵測到 profile:{profile})',
+2
View File
@@ -118,6 +118,8 @@ export const zhTW: Record<string, string> = {
terminalError: '終端連線失敗',
terminalConnectFailed: '終端多次連線失敗',
terminalRetry: '重試',
terminalWaitBanner: 'Agent 正在等待 {needle}',
terminalSkipWait: '跳過等待',
terminalDepsFailed: '終端依賴 node-pty 載入失敗',
terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):',
terminalDepsProfile: '(偵測到 profile:{profile})',
+4
View File
@@ -115,6 +115,8 @@ export const zh = {
terminalError: '终端连接失败',
terminalConnectFailed: '终端多次连接失败',
terminalRetry: '重试',
terminalWaitBanner: 'Agent 正在等待 {needle}',
terminalSkipWait: '跳过等待',
terminalDepsFailed: '终端依赖 node-pty 加载失败',
terminalDepsHint: '在 DSH 所在环境的终端或 cmd 中执行以下命令修复,然后点重试(node-pty 与 DSH 核心保持同一版本):',
terminalDepsProfile: '(检测到 profile:{profile})',
@@ -565,6 +567,8 @@ export const en: Record<keyof typeof zh, string> = {
terminalError: 'Terminal connection failed',
terminalConnectFailed: 'Terminal failed to connect repeatedly',
terminalRetry: 'Retry',
terminalWaitBanner: 'Agent is waiting for {needle}',
terminalSkipWait: 'Skip wait',
terminalDepsFailed: 'Terminal dependency node-pty failed to load',
terminalDepsHint: 'Run the command below in a terminal or cmd on the DSH machine to repair it, then retry (node-pty stays in sync with the DSH core version):',
terminalDepsProfile: ' (detected profile: {profile})',
+23
View File
@@ -2201,6 +2201,29 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
opacity: 0.85;
}
/* The "Agent 正在等待 {needle}" wait banner: a warn-toned strip above the
terminal surface while the model blocks in terminal_wait_for; the needle
ellipsizes inline and the full text rides the title tooltip. */
.terminalWaitBanner {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 3px 10px;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-state-warn-label);
background: var(--dsw-alias-state-warn-tertiary);
}
.terminalWaitNeedle {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.boundaryError {
position: fixed;
right: 0;
+60
View File
@@ -0,0 +1,60 @@
/**
* TerminalWaitBanner unit tests: localized text with the needle, the skip
* button wiring, and the long-needle display cap (full text stays on the
* title attribute). The component imports no xterm — renderable in plain
* jsdom (the repo's jsdom pattern, see bottom-auto-terminal.spec.tsx head).
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
import { TerminalWaitBanner } from '../src/client/TerminalWaitBanner.tsx'
describe('TerminalWaitBanner', () => {
const mounted: Array<{ root: Root; container: HTMLDivElement }> = []
function mount(needle: string, onSkip: () => void): HTMLDivElement {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
act(() => { root.render(createElement(TerminalWaitBanner, { needle, onSkip })) })
mounted.push({ root, container })
return container
}
afterEach(() => {
for (const { root, container } of mounted.splice(0)) {
act(() => { root.unmount() })
container.remove()
}
})
it('shows the localized waiting text with the needle and a working skip button', () => {
let skipped = 0
const container = mount('BUILD_OK', () => { skipped += 1 })
// jsdom navigator.language is en-US → the en dictionary applies.
expect(container.textContent).toContain('Agent is waiting for')
expect(container.textContent).toContain('BUILD_OK')
expect(container.textContent).toContain('Skip wait')
const button = container.querySelector('button')
expect(button).not.toBeNull()
act(() => { button!.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
expect(skipped).toBe(1)
})
it('truncates a long needle for display but keeps the full text on the title', () => {
const long = 'X'.repeat(200)
const container = mount(long, () => {})
const span = container.querySelector('span[title]')
expect(span).not.toBeNull()
expect(span!.getAttribute('title')).toBe(long)
// The needle's inline contribution is capped (NEEDLE_DISPLAY_CAP): at
// most 80 X's are rendered — the full 200-char run only rides the title
// tooltip. The localized sentence around the needle is not measured;
// its length varies per language, so counting the needle's own chars is
// the language-agnostic form of the display-cap assertion.
expect((span!.textContent?.match(/X/g) ?? []).length).toBeLessThanOrEqual(80)
})
})