/** * Floating queue window for the Codex true-gateway, seated in the official * `shell.overlay` layer. Anchored just above the composer, it lists pending * Codex queue submissions in FIFO order and offers the standard operations: * promote to front, insert (steer) into the active turn, delete, and edit. * It appears automatically whenever the queue is non-empty and hides when it * drains. * * @module dsh-subagent-codex-plus/client/queue-panel */ import { useCallback, useEffect, useState } from 'react' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import { runGatewayAction, useGatewayView } from './gateway-store.ts' import { PANEL_HOVER_CSS, THEME } from './theme.ts' /** Locate the chat composer textarea to anchor the window above it. */ function chatInputRect(): { left: number; width: number; top: number } | null { const candidates = [...document.querySelectorAll('textarea')] .map((el) => ({ el, r: el.getBoundingClientRect() })) .filter((x) => x.r.width > 400 && x.r.top > window.innerHeight * 0.4) .sort((a, b) => b.r.width - a.r.width) const best = candidates[0] if (best === undefined) return null return { left: best.r.left, width: best.r.width, top: best.r.top } } const PANEL_STYLE = { position: 'fixed', zIndex: 9998, boxSizing: 'border-box' as const, pointerEvents: 'auto' as const, borderRadius: 10, border: `1px solid ${THEME.borderStrong}`, background: THEME.surface, color: THEME.textPrimary, boxShadow: '0 8px 28px rgba(0,0,0,0.35)', fontFamily: 'var(--dsh-font-sans, -apple-system, "PingFang SC", sans-serif)', fontSize: 12, overflow: 'hidden', } as const const HEADER = { display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', borderBottom: `1px solid ${THEME.borderSubtle}`, color: THEME.textSecondary, fontSize: 11, fontWeight: 600, } as const const ROW = { display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px', borderTop: `1px solid ${THEME.borderSubtle}`, } as const const TEXT = { flex: '1 1 auto', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' as const, fontSize: 12, lineHeight: '18px', minWidth: 0, } as const const ICON_BUTTON = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 22, borderRadius: 5, border: `1px solid ${THEME.borderStrong}`, background: THEME.buttonBg, color: THEME.textPrimary, fontSize: 11, lineHeight: 1, cursor: 'pointer', flex: '0 0 auto', } as const const TEXT_BUTTON = { ...ICON_BUTTON, width: 'auto', padding: '0 8px', fontSize: 11, } as const const DANGER_TEXT = { ...TEXT_BUTTON, borderColor: THEME.danger, color: THEME.danger, } as const const FOOTER = { display: 'flex', alignItems: 'center', gap: 8, padding: '6px 12px', borderTop: `1px solid ${THEME.borderSubtle}`, } as const const EDIT_INPUT = { boxSizing: 'border-box' as const, flex: '1 1 auto', minWidth: 0, height: 24, padding: '0 6px', borderRadius: 5, border: `1px solid ${THEME.borderStrong}`, background: THEME.surfaceInput, color: THEME.textPrimary, fontSize: 12, } /** Floating queue window anchored above the composer (shell.overlay entry). */ export function QueuePanel(props: PropsRuntime<'shell.overlay'>) { const sessionId: string | undefined = props.useSessions((state) => state.current) const { view } = useGatewayView(sessionId) const [anchor, setAnchor] = useState<{ left: number; width: number; top: number } | null>(null) const [editingId, setEditingId] = useState(null) const [editText, setEditText] = useState('') const [actionError, setActionError] = useState(undefined) const [notice, setNotice] = useState(null) const refreshAnchor = useCallback(() => setAnchor(chatInputRect()), []) useEffect(() => { refreshAnchor() window.addEventListener('resize', refreshAnchor) window.addEventListener('scroll', refreshAnchor, true) return () => { window.removeEventListener('resize', refreshAnchor) window.removeEventListener('scroll', refreshAnchor, true) } }, [refreshAnchor]) const run = useCallback(async ( action: (api: import('./api.ts').GatewayApi) => Promise<{ ok: boolean; error?: string }>, ): Promise => { if (sessionId === undefined) return false const error = await runGatewayAction(sessionId, action) setActionError(error) return error === undefined }, [sessionId]) const queue = view?.attached === true ? view.queue : [] if (queue.length === 0 || anchor === null) return null const promote = async (index: number): Promise => { if (index === 0 || sessionId === undefined) return const ids = queue.map((item) => item.id) const [moved] = ids.splice(index, 1) if (moved === undefined) return ids.unshift(moved) await run((api) => api.queueReorder(sessionId, ids)) } const insertNow = async (item: { id: string; text: string }): Promise => { if (sessionId === undefined) return setNotice(null) const ok = await run((api) => api.steer(sessionId, item.text)) if (ok) { // Steer is injected at the model's next iteration; when Codex is // executing a long tool the insert visibly "does nothing" until the // tool returns, so acknowledge it explicitly instead of looking lost. setNotice('已插入当前回合:当前工具/步骤结束后生效') setTimeout(() => setNotice(null), 8000) await run((api) => api.queueDelete(sessionId, item.id)) } } const saveEdit = async (item: { id: string }): Promise => { const text = editText.trim() if (text === '' || sessionId === undefined) return const ok = await run((api) => api.queueUpdate(sessionId, item.id, text)) if (ok) { setEditingId(null) setEditText('') } } const bottom = Math.max(8, window.innerHeight - anchor.top + 8) const maxHeight = Math.max(120, anchor.top - 24) return (
⏳ 排队消息({queue.length}) 置顶 · 插入 · 编辑 · 删除
{queue.map((item, index) => (
{index + 1} {editingId === item.id ? ( <> setEditText(event.currentTarget.value)} onKeyDown={(event) => { if (event.key === 'Enter') { void saveEdit(item) } if (event.key === 'Escape') { setEditingId(null); setEditText('') } }} autoFocus /> ) : ( <> {item.text} )}
))}
{view?.attached === true && view.running && (
排队消息在 Codex 忙时自动入队
)} {notice !== null && (
{notice}
)} {actionError !== undefined && (
{actionError}
)}
) }