feat(ui): queue prompts, in-conversation approval cards, docked plan

Prompts submitted while a turn runs queue above the composer (edit,
remove, auto-send on completion; interruption or a failure pauses the
queue with an explicit resume). Permission requests and elicitations
route from the ACP client in main to renderer cards with the request
options — pending cards replay when the renderer reloads, replacing
the blocking OS dialogs. The task plan docks above the composer instead
of scrolling inside the thread, multi-line user bubbles trim their
padding lines, and a turn that ends abnormally shows an inline error
line in the transcript.
This commit is contained in:
NI0317
2026-07-20 10:46:00 +08:00
parent 91554b05f1
commit d3ac437616
7 changed files with 589 additions and 51 deletions
+220 -16
View File
@@ -81,6 +81,22 @@ interface LiveTurn {
errorText?: string
}
interface InteractionOption {
readonly optionId: string
readonly name: string
readonly kind: string
}
/** A permission or elicitation request forwarded from the ACP client in main. */
interface InteractionRequest {
readonly id: string
readonly kind: 'permission' | 'elicitation'
readonly sessionId?: string
readonly title: string
readonly detail: unknown
readonly options: readonly InteractionOption[]
}
interface PlanItem {
readonly content: string
readonly status: string
@@ -170,6 +186,9 @@ const state = {
traceLoadRevision: 0,
liveTurnCounter: 0,
liveToolMeta: new Map<string, LiveToolMeta>(),
interactions: [] as InteractionRequest[],
queues: new Map<string, string[]>(),
pausedQueues: new Set<string>(),
traceCatchupTimer: undefined as number | undefined,
traceCatchupAttempts: 0,
}
@@ -201,6 +220,9 @@ interface ShellRefs {
waterfall: HTMLElement
devCanvas: HTMLElement
sessionCanvas: HTMLElement
interactionDock: HTMLElement
queueDock: HTMLElement
planDock: HTMLElement
composerForm: HTMLFormElement
composerInput: HTMLTextAreaElement
composerHint: HTMLElement
@@ -287,6 +309,9 @@ function buildShell(): void {
</section>
</section>
<section class="module-canvas develop-canvas" id="devCanvas" hidden></section>
<div class="composer-dock" id="interactionDock" hidden></div>
<div class="composer-dock" id="queueDock" hidden></div>
<div class="composer-dock" id="planDock" hidden></div>
<form class="composer" id="composerForm">
<textarea id="composerInput" name="prompt" rows="1"></textarea>
<div class="composer-meta">
@@ -337,6 +362,9 @@ function buildShell(): void {
waterfall: pick('waterfall'),
devCanvas: pick('devCanvas'),
sessionCanvas: pick('sessionCanvas'),
interactionDock: pick('interactionDock'),
queueDock: pick('queueDock'),
planDock: pick('planDock'),
composerForm: pick('composerForm') as HTMLFormElement,
composerInput: pick('composerInput') as HTMLTextAreaElement,
composerHint: pick('composerHint'),
@@ -415,6 +443,11 @@ async function boot(): Promise<void> {
window.dshDesktop.sessions.onUpdate((payload) => {
handleSessionUpdate(asSessionUpdate(payload))
})
if (typeof window.dshDesktop.interaction?.onRequest === 'function') {
window.dshDesktop.interaction.onRequest((payload) => {
handleInteractionRequest(payload)
})
}
await Promise.all([refreshRuntime(), refreshDevStatus()])
await refreshSessions()
}
@@ -501,6 +534,8 @@ function applyTrace(trace: TracePayload, resetExpansion: boolean): void {
renderTrajTree()
renderWaterfall()
renderInspector()
renderPlanDock()
renderQueueDock()
scrollChatToBottom(resetExpansion)
scheduleTraceCatchup(sessionId)
}
@@ -636,11 +671,7 @@ function renderLiveTurn(): void {
if (body !== null) body.textContent = tool.detail
row.classList.toggle('failed', tool.status === 'failed')
}
const planHost = el.liveTurn.querySelector<HTMLElement>('[data-live="plan"]')
if (planHost !== null) {
planHost.hidden = live.plan === undefined || live.plan.length === 0
planHost.innerHTML = live.plan === undefined ? '' : renderPlanList(live.plan)
}
renderPlanDock()
const answer = el.liveTurn.querySelector<HTMLElement>('[data-live="answer"]')
if (answer !== null) {
answer.hidden = live.answer.length === 0
@@ -670,13 +701,12 @@ function ensureLiveSkeleton(live: LiveTurn): void {
el.liveTurn.innerHTML = `
${live.userText === undefined ? '' : `
<article class="message user">
<div class="message-card"><div class="user-bubble">${escapeHtml(live.userText)}</div></div>
<div class="message-card"><div class="user-bubble">${escapeHtml(live.userText.trim())}</div></div>
</article>
`}
<article class="message assistant live" data-live-key="${live.key}">
<div class="avatar">A</div>
<div class="message-card">
<div class="plan-host" data-live="plan" hidden></div>
<div class="activity-list">
<details class="chat-activity thinking" data-live="thinking" hidden>
<summary><span>${escapeHtml(t('chat.thinking'))}</span><strong></strong></summary>
@@ -734,9 +764,9 @@ function updateLiveJump(): void {
function updateComposerState(): void {
const busy = state.busySessionId !== undefined
el.sendButton.disabled = busy || !hasDesktopApi() || el.composerInput.value.trim().length === 0
el.sendButton.disabled = !hasDesktopApi() || el.composerInput.value.trim().length === 0
el.cancelButton.hidden = !busy
el.composerHint.textContent = busy ? t('chat.working') : t('composer.hint')
el.composerHint.textContent = busy ? t('queue.hintBusy') : t('composer.hint')
el.composerForm.classList.toggle('busy', busy)
el.composerForm.setAttribute('aria-busy', String(busy))
}
@@ -772,6 +802,12 @@ async function sendPrompt(prompt: string): Promise<void> {
const live = state.live.get(DRAFT_SESSION_ID)
state.live.delete(DRAFT_SESSION_ID)
if (live !== undefined) state.live.set(sessionId, live)
const draftQueue = state.queues.get(DRAFT_SESSION_ID)
if (draftQueue !== undefined) {
state.queues.set(sessionId, draftQueue)
state.queues.delete(DRAFT_SESSION_ID)
}
if (state.pausedQueues.delete(DRAFT_SESSION_ID)) state.pausedQueues.add(sessionId)
state.selectedSessionId = sessionId
state.draftChat = false
state.busySessionId = sessionId
@@ -794,8 +830,11 @@ async function sendPrompt(prompt: string): Promise<void> {
await loadTrace(sessionId)
}
else state.live.delete(sessionId)
maybeSendNextQueued(sessionId)
} catch (error) {
state.busySessionId = undefined
state.pausedQueues.add(sessionId ?? DRAFT_SESSION_ID)
renderQueueDock()
const live = sessionId === undefined ? state.live.get(DRAFT_SESSION_ID) : state.live.get(sessionId)
if (live !== undefined) {
live.status = 'error'
@@ -832,6 +871,8 @@ async function cancelActiveTurn(): Promise<void> {
const sessionId = state.busySessionId
if (sessionId === undefined || sessionId === DRAFT_SESSION_ID || !hasDesktopApi()) return
try {
state.pausedQueues.add(sessionId)
renderQueueDock()
await window.dshDesktop.sessions.cancel(sessionId)
toast(t('chat.cancelRequested'))
} catch (error) {
@@ -915,6 +956,8 @@ function showModule(module: AppModule): void {
el.sessionCanvas.hidden = module !== 'sessions'
el.devCanvas.hidden = module !== 'develop'
el.composerForm.hidden = module !== 'sessions'
renderPlanDock()
renderQueueDock()
if (module === 'develop') renderDevelop()
renderTopbar()
}
@@ -950,25 +993,28 @@ function renderConversationTurn(turn: ChatTurn): string {
const userTarget = turn.userTargetId === undefined ? undefined : state.graph.targets.get(turn.userTargetId)
const user = userTarget === undefined ? '' : `
<article class="message user ${selectedTargetClass(userTarget.id)}" role="button" tabindex="0" data-target-id="${escapeHtml(userTarget.id)}">
<div class="message-card"><div class="user-bubble">${escapeHtml(contentText(userTarget.output))}</div></div>
<div class="message-card"><div class="user-bubble">${escapeHtml(contentText(userTarget.output).trim())}</div></div>
</article>
`
if (turn.activities.length === 0) return user
const turnTarget = state.graph.targets.get(`turn:${turn.turn}`)
const reason = String(asRecord(asRecord(turnTarget?.metadata).data ?? turnTarget?.output).kind ?? asRecord(turnTarget?.output).kind ?? '')
const errorLine = turnTarget?.status === 'error'
? `<div class="turn-error" role="alert">${escapeHtml(t('chat.turnFailed'))}${reason.length > 0 ? ` · ${escapeHtml(reason)}` : ''}</div>`
: ''
if (turn.activities.length === 0) return `${user}${errorLine}`
return `${user}
<article class="message assistant">
<div class="avatar">A</div>
<div class="message-card"><div class="activity-list">${turn.activities.map(renderConversationActivity).join('')}</div></div>
</article>
`
${errorLine}`
}
function renderConversationActivity(activity: ChatActivity): string {
const target = state.graph.targets.get(activity.targetId)
if (target === undefined) return ''
if (activity.kind === 'tool') return renderChatToolActivity(target)
if (activity.kind === 'plan') {
return `<section class="plan-activity ${selectedTargetClass(target.id)}" data-target-id="${escapeHtml(target.id)}">${renderPlanList(planItemsOf(target))}</section>`
}
if (activity.kind === 'plan') return ''
if (activity.kind === 'text') {
return `<section class="assistant-prose assistant-segment ${selectedTargetClass(target.id)}" data-target-id="${escapeHtml(target.id)}">${renderMarkdown(assistantText(target.output) || contentText(target.output))}</section>`
}
@@ -998,6 +1044,117 @@ function renderPlanList(items: readonly PlanItem[]): string {
`
}
function handleInteractionRequest(payload: unknown): void {
const record = asRecord(payload)
const id = String(record.id ?? '')
if (id.length === 0 || state.interactions.some(item => item.id === id)) return
state.interactions.push({
id,
kind: record.kind === 'elicitation' ? 'elicitation' : 'permission',
...(record.sessionId === undefined ? {} : { sessionId: String(record.sessionId) }),
title: String(record.title ?? ''),
detail: record.detail,
options: (Array.isArray(record.options) ? record.options : []).map(option => ({
optionId: String(asRecord(option).optionId ?? ''),
name: String(asRecord(option).name ?? asRecord(option).optionId ?? ''),
kind: String(asRecord(option).kind ?? ''),
})),
})
renderInteractionDock()
}
async function respondInteraction(id: string, response: unknown): Promise<void> {
// Only answer requests this instance owns: guards double-clicks and replays.
if (!state.interactions.some(item => item.id === id)) return
state.interactions = state.interactions.filter(item => item.id !== id)
renderInteractionDock()
try {
await window.dshDesktop.interaction.respond(id, response)
} catch (error) {
showError(String(error))
}
}
/** Permission and question cards block the runtime: they always render. */
function renderInteractionDock(): void {
el.interactionDock.hidden = state.interactions.length === 0
el.interactionDock.innerHTML = state.interactions.map(request => `
<section class="interaction-card ${request.kind}" role="alertdialog" aria-label="${escapeHtml(interactionTitle(request))}">
<header>
<strong>${escapeHtml(interactionTitle(request))}</strong>
<span>${escapeHtml(truncate(request.title, 120))}</span>
</header>
${request.detail === undefined ? '' : `<details class="metadata-line"><summary>${escapeHtml(t('chat.details'))}</summary><pre>${escapeHtml(truncate(formatPayload(request.detail, 'json'), 2000))}</pre></details>`}
<div class="interaction-actions">
${request.kind === 'elicitation' ? `<button type="button" class="allow" data-respond-accept="${escapeHtml(request.id)}">${escapeHtml(t('interaction.accept'))}</button>` : ''}
${request.options.map(option => `<button type="button" class="${option.kind.startsWith('allow') ? 'allow' : 'reject'}" data-respond-option="${escapeHtml(option.optionId)}" data-respond-id="${escapeHtml(request.id)}">${escapeHtml(option.name)}</button>`).join('')}
<button type="button" data-respond-cancel="${escapeHtml(request.id)}">${escapeHtml(t('interaction.dismiss'))}</button>
</div>
</section>
`).join('')
}
function interactionTitle(request: InteractionRequest): string {
return request.kind === 'permission' ? t('interaction.permissionTitle') : t('interaction.questionTitle')
}
/* ── Message queue: prompts typed while a turn runs send when it ends ─────── */
function queueFor(sessionId: string): string[] {
const queue = state.queues.get(sessionId) ?? []
state.queues.set(sessionId, queue)
return queue
}
function renderQueueDock(): void {
const sessionId = state.selectedSessionId ?? DRAFT_SESSION_ID
const queue = state.queues.get(sessionId) ?? []
const paused = state.pausedQueues.has(sessionId)
el.queueDock.hidden = state.activeModule !== 'sessions' || queue.length === 0
if (el.queueDock.hidden) {
el.queueDock.innerHTML = ''
return
}
el.queueDock.innerHTML = `
<section class="queue-card">
<header><strong>${escapeHtml(t('queue.title'))}</strong><span>${queue.length}</span></header>
${queue.map((text, index) => `
<div class="queue-item">
<button type="button" class="queue-text" data-queue-edit="${index}" title="${escapeHtml(t('queue.edit'))}">${escapeHtml(truncate(text, 120))}</button>
<button type="button" class="queue-remove" data-queue-remove="${index}" aria-label="${escapeHtml(t('queue.remove'))}">×</button>
</div>
`).join('')}
${paused ? `<footer class="queue-paused"><span>${escapeHtml(t('queue.paused'))}</span><button type="button" data-queue-resume="true">${escapeHtml(t('queue.resume'))}</button></footer>` : ''}
</section>
`
}
/** Send the next queued prompt once the turn is over and the queue is running. */
function maybeSendNextQueued(sessionId: string): void {
if (state.busySessionId !== undefined || state.pausedQueues.has(sessionId)) return
if (state.selectedSessionId !== sessionId) return
const queue = state.queues.get(sessionId) ?? []
const next = queue.shift()
renderQueueDock()
if (next !== undefined) void sendPrompt(next)
}
/** Latest plan snapshot for the selected session: live beats persisted. */
function currentPlanItems(): PlanItem[] {
const live = currentLiveTurn()
if (live?.plan !== undefined && live.plan.length > 0) return [...live.plan]
const planTargets = [...state.graph.targets.values()].filter(target => target.kind === 'plan')
const last = planTargets[planTargets.length - 1]
return last === undefined ? [] : planItemsOf(last)
}
function renderPlanDock(): void {
const items = state.activeModule === 'sessions' ? currentPlanItems() : []
const allDone = items.length > 0 && items.every(item => item.status === 'completed')
el.planDock.hidden = items.length === 0 || allDone
el.planDock.innerHTML = el.planDock.hidden ? '' : renderPlanList(items)
}
function planItemsOf(target: TraceTarget): PlanItem[] {
return (Array.isArray(target.output) ? target.output : []).map(item => ({ content: String(asRecord(item).content ?? ''), status: String(asRecord(item).status ?? 'pending') }))
}
@@ -1618,7 +1775,14 @@ function wireStaticEvents(): void {
el.composerForm.addEventListener('submit', (event) => {
event.preventDefault()
const prompt = el.composerInput.value.trim()
if (prompt.length === 0 || state.busySessionId !== undefined) return
if (prompt.length === 0) return
if (state.busySessionId !== undefined) {
queueFor(state.selectedSessionId ?? DRAFT_SESSION_ID).push(prompt)
el.composerInput.value = ''
updateComposerState()
renderQueueDock()
return
}
void sendPrompt(prompt)
})
el.cancelButton.addEventListener('click', () => {
@@ -1720,6 +1884,46 @@ async function handleDelegatedClick(event: MouseEvent): Promise<void> {
return
}
const respondOption = target.closest<HTMLElement>('[data-respond-option]')
if (respondOption !== null) {
void respondInteraction(respondOption.dataset.respondId ?? '', { optionId: respondOption.dataset.respondOption })
return
}
const respondAccept = target.closest<HTMLElement>('[data-respond-accept]')?.dataset.respondAccept
if (respondAccept !== undefined) {
void respondInteraction(respondAccept, { accepted: true })
return
}
const respondCancel = target.closest<HTMLElement>('[data-respond-cancel]')?.dataset.respondCancel
if (respondCancel !== undefined) {
void respondInteraction(respondCancel, { cancelled: true })
return
}
const queueEdit = target.closest<HTMLElement>('[data-queue-edit]')?.dataset.queueEdit
if (queueEdit !== undefined) {
const queue = queueFor(state.selectedSessionId ?? DRAFT_SESSION_ID)
const [text] = queue.splice(Number(queueEdit), 1)
if (text !== undefined) {
el.composerInput.value = text
updateComposerState()
el.composerInput.focus()
}
renderQueueDock()
return
}
const queueRemove = target.closest<HTMLElement>('[data-queue-remove]')?.dataset.queueRemove
if (queueRemove !== undefined) {
queueFor(state.selectedSessionId ?? DRAFT_SESSION_ID).splice(Number(queueRemove), 1)
renderQueueDock()
return
}
if (target.closest<HTMLElement>('[data-queue-resume]') !== null) {
state.pausedQueues.delete(state.selectedSessionId ?? DRAFT_SESSION_ID)
renderQueueDock()
maybeSendNextQueued(state.selectedSessionId ?? DRAFT_SESSION_ID)
return
}
const openPath = target.closest<HTMLElement>('[data-open-path]')?.dataset.openPath
if (openPath !== undefined) {
try {
+4
View File
@@ -31,6 +31,10 @@ declare global {
status(): Promise<unknown>
openPath(path: string): Promise<unknown>
}
interaction: {
onRequest(callback: (payload: unknown) => void): () => void
respond(id: string, response: unknown): Promise<unknown>
}
}
}
}
+22
View File
@@ -80,6 +80,17 @@ const messages = {
'dev.openFailed': '打开失败',
'app.resizeSidebar': '调整侧栏宽度',
'app.resizeInspector': '调整检查器宽度',
'chat.turnFailed': '回合异常结束',
'queue.title': '排队消息',
'queue.hintBusy': 'Enter 加入队列 · 回合结束自动发送',
'queue.paused': '队列已暂停(你中断了当前回合)',
'queue.resume': '继续发送',
'queue.remove': '删除',
'queue.edit': '编辑',
'interaction.permissionTitle': '权限请求',
'interaction.questionTitle': '需要你确认',
'interaction.dismiss': '拒绝',
'interaction.accept': '接受',
'kind.plan': '计划',
'kind.verb.read': '读取',
'kind.verb.edit': '编辑',
@@ -351,6 +362,17 @@ const messages = {
'dev.openFailed': 'Failed to open',
'app.resizeSidebar': 'Resize sidebar',
'app.resizeInspector': 'Resize inspector',
'chat.turnFailed': 'Turn ended abnormally',
'queue.title': 'Queued messages',
'queue.hintBusy': 'Enter queues · sends when the turn ends',
'queue.paused': 'Queue paused (you interrupted the turn)',
'queue.resume': 'Resume',
'queue.remove': 'Remove',
'queue.edit': 'Edit',
'interaction.permissionTitle': 'Permission request',
'interaction.questionTitle': 'Your confirmation needed',
'interaction.dismiss': 'Reject',
'interaction.accept': 'Accept',
'kind.plan': 'Plan',
'kind.verb.read': 'Read',
'kind.verb.edit': 'Edit',
+46 -21
View File
@@ -30,6 +30,23 @@ let initializeResult
let stderrTail = ''
/** @type {Map<string, {sessionId: string, loaded: boolean, cwd: string, title?: string}>} */
const activeSessions = new Map()
let interactionCounter = 0
/** @type {Map<string, {resolve: (response: unknown) => void, payload: unknown}>} */
const pendingInteractions = new Map()
/**
* Ask the renderer to resolve a permission or elicitation card. The promise
* settles when any window responds; pending cards replay on renderer reload.
*/
function requestRendererInteraction(kind, request) {
return new Promise((resolve) => {
const id = `interaction-${++interactionCounter}`
const payload = { id, kind, ...request }
pendingInteractions.set(id, { resolve, payload })
broadcast('interaction:request', payload)
})
}
const replayingSessions = new Set()
function broadcast(channel, payload) {
@@ -112,31 +129,26 @@ async function startRuntime() {
return Promise.resolve()
},
async requestPermission(params) {
const options = params.options.map(option => `${option.name ?? option.optionId} (${option.kind})`)
const result = await dialog.showMessageBox(mainWindow, {
type: 'question',
buttons: [...options, 'Cancel'],
cancelId: options.length,
defaultId: 0,
title: 'Harness permission request',
message: params.toolCall.title ?? 'Tool permission request',
detail: JSON.stringify(params.toolCall.rawInput ?? params.toolCall, null, 2),
const response = await requestRendererInteraction('permission', {
sessionId: params.sessionId,
title: params.toolCall?.title ?? 'Tool permission request',
detail: params.toolCall?.rawInput ?? params.toolCall ?? {},
options: params.options.map(option => ({ optionId: option.optionId, name: option.name ?? option.optionId, kind: option.kind })),
})
const option = params.options[result.response]
if (option === undefined) return { outcome: { outcome: 'cancelled' } }
return { outcome: { outcome: 'selected', optionId: option.optionId } }
const optionId = response?.optionId
if (typeof optionId === 'string' && params.options.some(option => option.optionId === optionId)) {
return { outcome: { outcome: 'selected', optionId } }
}
return { outcome: { outcome: 'cancelled' } }
},
async unstable_createElicitation(params) {
const result = await dialog.showMessageBox(mainWindow, {
type: 'question',
buttons: ['Accept', 'Cancel'],
cancelId: 1,
defaultId: 0,
title: 'Harness needs input',
message: params.message,
detail: JSON.stringify(params, null, 2),
const response = await requestRendererInteraction('elicitation', {
sessionId: params.sessionId,
title: params.message,
detail: params,
options: [],
})
return result.response === 0 ? { action: 'accept', content: {} } : { action: 'cancel' }
return response?.accepted === true ? { action: 'accept', content: {} } : { action: 'cancel' }
},
}), stream)
@@ -545,6 +557,13 @@ function registerIpc() {
ipcMain.handle('feedback:list', (_event, { sessionId, targetId }) => readFeedback(String(sessionId), targetId === undefined ? undefined : String(targetId)))
ipcMain.handle('feedback:add', (_event, entry) => appendFeedback(entry))
ipcMain.handle('dev:status', () => devStatus())
ipcMain.handle('interaction:respond', (_event, { id, response }) => {
const pending = pendingInteractions.get(String(id))
if (pending === undefined) return { ok: false }
pendingInteractions.delete(String(id))
pending.resolve(response)
return { ok: true }
})
}
async function createWindow() {
@@ -563,6 +582,12 @@ async function createWindow() {
},
})
mainWindow.webContents.on('did-finish-load', () => {
for (const pending of pendingInteractions.values()) {
mainWindow?.webContents.send('interaction:request', pending.payload)
}
})
if (process.env.VITE_DEV_SERVER_URL !== undefined) {
await mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL)
if (process.env.DSH_DESKTOP_OPEN_DEVTOOLS === '1') mainWindow.webContents.openDevTools({ mode: 'detach' })
+8
View File
@@ -41,6 +41,14 @@ const api = {
status: () => ipcRenderer.invoke('dev:status'),
openPath: (path) => ipcRenderer.invoke('dev:open-path', { path }),
},
interaction: {
onRequest: (callback) => {
const listener = (_event, payload) => { callback(payload) }
ipcRenderer.on('interaction:request', listener)
return () => { ipcRenderer.removeListener('interaction:request', listener) }
},
respond: (id, response) => ipcRenderer.invoke('interaction:respond', { id, response }),
},
}
contextBridge.exposeInMainWorld('dshDesktop', api)
+163
View File
@@ -3088,3 +3088,166 @@ dd {
font-family: "SF Mono", ui-monospace, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
}
/* Plan dock: the checklist rides above the composer instead of the thread. */
.composer-dock {
width: min(920px, calc(100% - 68px));
margin: 0 auto;
}
.composer-dock .plan-card {
max-height: 180px;
overflow: auto;
box-shadow: var(--shadow-sm);
}
.composer-dock[hidden] {
display: none;
}
/* Abnormal turn endings surface inline, not only in a toast. */
.turn-error {
width: fit-content;
margin: 4px 0 0 47px;
padding: 6px 10px;
border: 1px solid color-mix(in oklab, var(--red) 30%, transparent);
border-radius: var(--radius-sm);
background: color-mix(in oklab, var(--red) 6%, transparent);
color: var(--red);
font-size: 12px;
}
/* ── Interaction cards and message queue above the composer ──────────────── */
.interaction-card {
overflow: hidden;
margin-bottom: 8px;
border: 1px solid color-mix(in oklab, var(--amber) 38%, transparent);
border-radius: var(--radius-lg);
background: color-mix(in oklab, var(--amber) 5%, #fff);
box-shadow: var(--shadow-md);
}
.interaction-card header {
display: flex;
align-items: baseline;
gap: 10px;
padding: 10px 12px 6px;
}
.interaction-card header strong {
font-size: 13px;
}
.interaction-card header span {
overflow: hidden;
color: var(--ink-soft);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.interaction-card .metadata-line {
margin: 0 12px;
}
.interaction-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 12px 12px;
}
.interaction-actions button {
height: var(--control-md);
padding: 0 12px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: #fff;
color: var(--ink-soft);
font-size: 12px;
font-weight: 650;
}
.interaction-actions button.allow {
border-color: transparent;
background: var(--ink);
color: #fff;
}
.interaction-actions button.reject {
color: var(--red);
}
.queue-card {
margin-bottom: 8px;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.9);
}
.queue-card header {
display: flex;
justify-content: space-between;
padding: 0 2px 4px;
color: var(--muted);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.queue-item {
display: flex;
align-items: center;
gap: 6px;
}
.queue-item .queue-text {
overflow: hidden;
flex: 1;
min-height: var(--control-sm);
padding: 2px 8px;
border-radius: var(--radius-xs);
color: var(--ink-soft);
font-size: 12px;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.queue-item .queue-text:hover {
background: var(--hover-overlay);
}
.queue-item .queue-remove {
width: var(--control-sm);
height: var(--control-sm);
border-radius: var(--radius-xs);
color: var(--muted);
}
.queue-item .queue-remove:hover {
background: var(--hover-overlay);
color: var(--red);
}
.queue-paused {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 6px;
padding: 4px 8px;
border-radius: var(--radius-xs);
background: color-mix(in oklab, var(--amber) 8%, transparent);
color: var(--amber);
font-size: 11px;
}
.queue-paused button {
height: 22px;
padding: 0 8px;
border: 1px solid color-mix(in oklab, var(--amber) 40%, transparent);
border-radius: var(--radius-xs);
font-weight: 650;
}
+126 -14
View File
@@ -16,6 +16,13 @@ function deferred<T>(): Deferred<T> {
return { promise, resolve }
}
function submitPrompt(text: string): void {
const composer = document.querySelector<HTMLTextAreaElement>('#composerInput')!
composer.value = text
composer.dispatchEvent(new Event('input', { bubbles: true }))
document.querySelector<HTMLFormElement>('#composerForm')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
}
describe('desktop renderer chat lifecycle', () => {
beforeEach(() => {
vi.resetModules()
@@ -94,6 +101,7 @@ describe('desktop renderer chat lifecycle', () => {
trace: { read: async () => traceRead },
feedback: { list: async () => [], add: async () => ({}) },
dev: { status: async () => ({ git: {} }), openPath: async () => ({}) },
interaction: { onRequest: () => () => {}, respond: async () => ({}) },
}
await import('../src/app.ts')
@@ -287,19 +295,14 @@ describe('desktop renderer chat lifecycle', () => {
trace: { read: async () => traceRead },
feedback: { list: async () => [], add: async () => ({}) },
dev: { status: async () => ({ git: {} }), openPath: async () => ({}) },
interaction: { onRequest: () => () => {}, respond: async () => ({}) },
}
await import('../src/app.ts')
await vi.waitFor(() => {
expect(document.querySelector('#composerInput')).not.toBeNull()
})
const composer = document.querySelector<HTMLTextAreaElement>('#composerInput')!
const form = document.querySelector<HTMLFormElement>('#composerForm')!
const send = (text: string): void => {
composer.value = text
composer.dispatchEvent(new Event('input', { bubbles: true }))
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
}
const send = submitPrompt
document.querySelector<HTMLButtonElement>('[data-action="new-session"]')!.click()
send('first')
@@ -329,9 +332,9 @@ describe('desktop renderer chat lifecycle', () => {
{ content: 'write the report', priority: 'medium', status: 'pending' },
] } })
await vi.waitFor(() => {
const livePlan = document.querySelector('[data-live="plan"] .plan-card')
expect(livePlan?.textContent).toContain('collect findings')
expect(livePlan?.textContent).toContain('0/2')
const dockPlan = document.querySelector('#planDock .plan-card')
expect(dockPlan?.textContent).toContain('collect findings')
expect(dockPlan?.textContent).toContain('0/2')
})
// Once the persisted log catches up, the view converges with no user action.
@@ -343,10 +346,119 @@ describe('desktop renderer chat lifecycle', () => {
}, { timeout: 4000 })
// The live workflow presentation survives the switch to the persisted view.
expect(document.querySelector('#conversation')?.textContent).toContain('workflow: run audit agents')
// The persisted todo/write renders as a checklist card with the streamed kind verb.
const planCard = document.querySelector('#conversation .plan-activity .plan-card')
expect(planCard?.textContent).toContain('write the report')
expect(planCard?.textContent).toContain('1/2')
// The checklist docks above the composer (latest persisted snapshot), and
// the thread itself does not repeat it.
const dockPlan = document.querySelector('#planDock .plan-card')
expect(dockPlan?.textContent).toContain('write the report')
expect(dockPlan?.textContent).toContain('1/2')
expect(document.querySelector('#conversation .plan-activity')).toBeNull()
expect(document.querySelector('#conversation .chat-activity.tool-use .activity-select span')?.textContent).toBe('执行')
})
it('queues prompts while a turn runs and renders permission cards from main', async () => {
const prompts: Deferred<unknown>[] = [deferred(), deferred()]
const promptQueue = [...prompts]
const promptedTexts: string[] = []
const responses: unknown[] = []
let interactionCallback: ((payload: unknown) => void) | undefined
const turnTrace = {
found: true,
sessionId: 's-q',
header: { id: 's-q' },
rawText: '',
feedback: [],
events: [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' } } },
{ type: 'user/message', seq: 1, time: 2, data: { content: [{ type: 'text', text: 'first' }] } },
{ type: 'step/start', seq: 2, time: 3, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: 3, time: 4, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'first answer' }] } },
{ type: 'step/end', seq: 4, time: 5, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 5, time: 6, data: { turn: 1, reason: { kind: 'completed' } } },
],
}
window.dshDesktop = {
runtime: {
start: async () => ({}),
stop: async () => ({}),
restart: async () => ({}),
status: async () => ({ state: 'running', repoRoot: '/repo' }),
onStatus: () => () => {},
onStderr: () => () => {},
},
sessions: {
list: async () => ({ sessions: [] }),
create: async () => ({ sessionId: 's-q', trace: { ...turnTrace, events: [] } }),
load: async () => ({}),
prompt: async (_sessionId: string, text: string) => {
promptedTexts.push(text)
return promptQueue.shift()!.promise
},
cancel: async () => ({}),
reveal: async () => ({}),
onUpdate: () => () => {},
},
trace: { read: async () => turnTrace },
feedback: { list: async () => [], add: async () => ({}) },
dev: { status: async () => ({ git: {} }), openPath: async () => ({}) },
interaction: {
onRequest: (callback: (payload: unknown) => void) => {
interactionCallback = callback
return () => {}
},
respond: async (id: string, response: unknown) => {
responses.push({ id, response })
return {}
},
},
}
await import('../src/app.ts')
await vi.waitFor(() => {
expect(document.querySelector('#composerInput')).not.toBeNull()
})
const composer = document.querySelector<HTMLTextAreaElement>('#composerInput')!
const send = submitPrompt
document.querySelector<HTMLButtonElement>('[data-action="new-session"]')!.click()
send('first')
await vi.waitFor(() => {
expect(promptedTexts).toEqual(['first'])
})
// Busy: the next prompt queues instead of being dropped or blocked.
send('second')
const queueDock = document.querySelector<HTMLElement>('#queueDock')!
expect(queueDock.hidden).toBe(false)
expect(queueDock.textContent).toContain('second')
expect(composer.value).toBe('')
// A permission request from main renders as a card; answering routes back.
interactionCallback?.({
id: 'i-1',
kind: 'permission',
sessionId: 's-q',
title: 'bash: rm -rf build',
detail: { command: 'rm -rf build' },
options: [
{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
{ optionId: 'reject-once', name: 'Reject', kind: 'reject_once' },
],
})
const card = document.querySelector<HTMLElement>('#interactionDock .interaction-card')!
expect(card.textContent).toContain('bash: rm -rf build')
card.querySelector<HTMLButtonElement>('[data-respond-option="allow-once"]')!.click()
await vi.waitFor(() => {
expect(responses).toEqual([{ id: 'i-1', response: { optionId: 'allow-once' } }])
expect(document.querySelector<HTMLElement>('#interactionDock')?.hidden).toBe(true)
})
// Turn completion drains the queue automatically.
prompts[0]!.resolve({ response: {}, trace: turnTrace })
await vi.waitFor(() => {
expect(promptedTexts).toEqual(['first', 'second'])
expect(document.querySelector<HTMLElement>('#queueDock')?.hidden).toBe(true)
})
expect(document.querySelector('#liveTurn .user-bubble')?.textContent).toBe('second')
})
})