mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
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:
+220
-16
@@ -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 {
|
||||
|
||||
Vendored
+4
@@ -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>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user