feat: add custom queue and steering controls
This commit is contained in:
commit
f43833ebf8
5 files changed
+162
No files matched your search
@@ -0,0 +1,18 @@
|
|||||||
|
# @local/dsh-queue-steer
|
||||||
|
|
||||||
|
Custom queue and steering controls for DeepSeek Harness.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- `/queue [message]` appends a message to the current agent's next-turn queue.
|
||||||
|
- `/steer [message]` sends a message to the active step (or follows up when idle).
|
||||||
|
- **Queue & Steering** settings page controls the default busy-message mode, default list, and the batch template.
|
||||||
|
- Replaces the built-in queue dock with two lists:
|
||||||
|
- **List 1 — all at once:** entries are presented as a batch.
|
||||||
|
- **List 2 — one at a time:** entries remain individual messages.
|
||||||
|
- Queue rows can be reordered by dragging, moved between lists, edited inline, removed, or steered.
|
||||||
|
- Escape exits editing and stores the in-progress value as a local draft; reopening the row restores it.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
The plugin is loaded from the `link:` entry in the web profile. Host changes require a DSH restart; client changes require a browser refresh.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
window.__ModuleLoader__.load({
|
||||||
|
id: '@local/dsh-queue-steer',
|
||||||
|
factory(require) {
|
||||||
|
const React = require('react');
|
||||||
|
const h = React.createElement;
|
||||||
|
const NS = 'dshQueueSteer';
|
||||||
|
const P = require('@deepseek-ai/dsh-client-ui-primitives') || {};
|
||||||
|
const css = `
|
||||||
|
.dqs-dock{width:calc(100% - 32px);max-width:760px;margin:0 auto -3px;padding:0 8px;font:13px var(--dsw-font-family,Inter,sans-serif)}
|
||||||
|
.dqs-panel{background:var(--dsw-specific-menu);border:1px solid var(--dsw-alias-border-l1);border-bottom:0;border-radius:12px 12px 0 0;padding:4px 8px;overflow:hidden}
|
||||||
|
.dqs-header{width:100%;display:flex;align-items:center;gap:8px;border:0;background:none;color:var(--dsw-alias-label-primary);padding:7px 5px;cursor:pointer;text-align:left}
|
||||||
|
.dqs-count{flex:1;font-weight:600}.dqs-tabs{display:flex;gap:4px;border-bottom:1px solid var(--dsw-alias-border-l2);padding:2px 0 5px}
|
||||||
|
.dqs-tab{border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);padding:5px 8px;cursor:pointer}.dqs-tab.active{background:var(--dsw-alias-bg-l2);color:var(--dsw-alias-label-primary);font-weight:600}
|
||||||
|
.dqs-list{list-style:none;padding:3px 0;margin:0;display:grid;gap:2px}.dqs-row{display:flex;align-items:center;gap:7px;padding:6px;border-radius:7px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-l1)}
|
||||||
|
.dqs-row.editing{outline:2px solid var(--dsw-alias-state-business-primary);background:var(--dsw-alias-bg-l2)}.dqs-row.over{border-top:2px solid var(--dsw-alias-state-business-primary)}.dqs-grip{cursor:grab;opacity:.6}.dqs-preview{white-space:pre-wrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0;max-height:3.2em}.dqs-editor{flex:1;min-width:0;resize:none;border:1px solid var(--dsw-alias-border-l1);border-radius:6px;background:var(--dsw-alias-bg-base);color:inherit;padding:5px;font:inherit}.dqs-actions{display:flex;gap:2px}.dqs-action{border:0;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;padding:4px;border-radius:5px}.dqs-action:hover{background:var(--dsw-alias-bg-l2);color:var(--dsw-alias-label-primary)}.dqs-empty{padding:12px;color:var(--dsw-alias-label-tertiary);text-align:center}.dqs-draft{font-size:10px;color:var(--dsw-alias-state-business-primary)}
|
||||||
|
.dqs-settings{padding:4px 0}.dqs-settings h2{font-size:18px;margin:0 0 6px}.dqs-settings p{color:var(--dsw-alias-label-secondary);line-height:1.45}.dqs-field{display:grid;gap:5px;margin:14px 0}.dqs-field select,.dqs-field textarea{font:inherit;color:inherit;background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1);border-radius:7px;padding:8px}.dqs-field textarea{min-height:80px}.dqs-save{border:0;border-radius:7px;background:var(--dsw-alias-state-business-primary);color:white;padding:8px 14px;cursor:pointer}.dqs-status{margin-left:10px;color:var(--dsw-alias-state-positive)}
|
||||||
|
`;
|
||||||
|
let style;
|
||||||
|
const installCss = () => { if (!style) { style = document.createElement('style'); style.textContent = css; document.head.appendChild(style); } };
|
||||||
|
const icon = (name, fallback) => P[name] ? h(P[name], { size: 14 }) : h('span', null, fallback);
|
||||||
|
const key = (id) => `dqs:${id}`;
|
||||||
|
const read = (id, fallback) => { try { const v = localStorage.getItem(key(id)); return v == null ? fallback : JSON.parse(v); } catch { return fallback; } };
|
||||||
|
const write = (id, v) => { try { localStorage.setItem(key(id), JSON.stringify(v)); } catch {} };
|
||||||
|
const rowText = (row) => (row.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('');
|
||||||
|
|
||||||
|
function SettingsPanel() {
|
||||||
|
const [value, setValue] = React.useState({ defaultMode: 'queue', defaultQueueList: 'batch', batchHeaderTemplate: 'Queued messages batch ({count}):\\n\\n{messages}' });
|
||||||
|
const [status, setStatus] = React.useState('');
|
||||||
|
React.useEffect(() => { fetch('dsh-queue-steer/settings', { credentials: 'same-origin' }).then((r) => r.json()).then(setValue).catch(() => {}); }, []);
|
||||||
|
const save = () => { setStatus('Saving…'); fetch('dsh-queue-steer/settings', { method: 'POST', credentials: 'same-origin', headers: {'content-type':'application/json'}, body: JSON.stringify(value) }).then((r) => r.json()).then((v) => { setValue(v); setStatus('Saved'); }).catch(() => setStatus('Save failed')); };
|
||||||
|
return h('section', { className: 'dqs-settings', 'data-dsh-plugin': 'dsh-queue-steer' },
|
||||||
|
h('h2', null, 'Queue & Steering'), h('p', null, 'Choose how busy-composer messages are delivered and which queue list receives new entries.'),
|
||||||
|
h('label', { className: 'dqs-field' }, 'Default busy message action', h('select', { value: value.defaultMode, onChange: (e) => setValue({...value, defaultMode:e.target.value}) }, h('option', {value:'queue'}, 'Queue'), h('option', {value:'steer'}, 'Steer'))),
|
||||||
|
h('label', { className: 'dqs-field' }, 'Default queue list', h('select', { value: value.defaultQueueList, onChange: (e) => setValue({...value, defaultQueueList:e.target.value}) }, h('option', {value:'batch'}, 'List 1 — send all entries at once'), h('option', {value:'sequential'}, 'List 2 — send one entry at a time'))),
|
||||||
|
h('label', { className: 'dqs-field' }, 'Batch message template', h('textarea', { value: value.batchHeaderTemplate, onChange: (e) => setValue({...value, batchHeaderTemplate:e.target.value}) }), h('small', null, 'Placeholders: {count} and {messages}')),
|
||||||
|
h('button', { className: 'dqs-save', onClick: save }, 'Save settings'), status && h('span', {className:'dqs-status'}, status)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QueueDock({ useSession, useProjection, updateQueue, notify }) {
|
||||||
|
const inbox = useProjection('inbox');
|
||||||
|
const rows = inbox?.['next-turn'] || [];
|
||||||
|
const sessionId = useSession((s) => s.sessionId || s.id || 'current');
|
||||||
|
const running = useSession((s) => !!s.running);
|
||||||
|
const [tab, setTab] = React.useState(() => read(`${sessionId}:tab`, 'batch'));
|
||||||
|
const [open, setOpen] = React.useState(true);
|
||||||
|
const [editing, setEditing] = React.useState(null);
|
||||||
|
const [busy, setBusy] = React.useState(null);
|
||||||
|
const [drag, setDrag] = React.useState(null);
|
||||||
|
const [assign, setAssign] = React.useState(() => read(`${sessionId}:assign`, {}));
|
||||||
|
const [order, setOrder] = React.useState(() => read(`${sessionId}:order`, []));
|
||||||
|
const [drafts, setDrafts] = React.useState(() => read(`${sessionId}:drafts`, {}));
|
||||||
|
React.useEffect(installCss, []);
|
||||||
|
React.useEffect(() => { write(`${sessionId}:assign`, assign); }, [sessionId, assign]);
|
||||||
|
React.useEffect(() => { write(`${sessionId}:order`, order); }, [sessionId, order]);
|
||||||
|
React.useEffect(() => { write(`${sessionId}:drafts`, drafts); }, [sessionId, drafts]);
|
||||||
|
const sorted = React.useMemo(() => { const rank = new Map(order.map((id, i) => [id, i])); return [...rows].sort((a,b) => (rank.get(a.id) ?? 1e9) - (rank.get(b.id) ?? 1e9)); }, [rows, order]);
|
||||||
|
const listOf = (row) => assign[row.id] || 'batch';
|
||||||
|
const visible = sorted.filter((r) => listOf(r) === tab);
|
||||||
|
const move = (id, target) => { setAssign((v) => ({...v, [id]: target})); setTab(target); };
|
||||||
|
const startEdit = (row) => setEditing({ id: row.id, value: drafts[row.id] ?? rowText(row) });
|
||||||
|
const cancelEdit = () => { if (editing) setDrafts((v) => ({...v, [editing.id]: editing.value})); setEditing(null); };
|
||||||
|
const saveEdit = async () => { if (!editing || !editing.value.trim()) return; setBusy(editing.id); try { await updateQueue(editing.id, {kind:'edit', content:[{type:'text', text:editing.value}]}); setDrafts((v) => { const n={...v}; delete n[editing.id]; return n; }); setEditing(null); } catch { notify('error', 'Queue edit failed'); } finally { setBusy(null); } };
|
||||||
|
const action = async (id, value) => { setBusy(id); try { await updateQueue(id, value); setDrafts((v) => { const n={...v}; delete n[id]; return n; }); } catch { notify('error', 'Queue action failed'); } finally { setBusy(null); } };
|
||||||
|
const drop = (targetId) => { if (!drag || drag.id === targetId) return; const ids = sorted.map((r) => r.id).filter((id) => id !== drag.id); const at = ids.indexOf(targetId); ids.splice(Math.max(0, at), 0, drag.id); setOrder(ids); if (drag.list !== tab) move(drag.id, tab); setDrag(null); };
|
||||||
|
if (!rows.length) return null;
|
||||||
|
const Item = ({ row }) => { const isEdit = editing?.id === row.id; const draft = drafts[row.id]; return h('li', { className: `dqs-row${isEdit?' editing':''}${drag?.over===row.id?' over':''}`, draggable: !isEdit, onDragStart: () => setDrag({id:row.id,list:tab}), onDragOver: (e) => { e.preventDefault(); setDrag((v) => v && ({...v, over:row.id})); }, onDrop: () => drop(row.id), onDragEnd: () => setDrag(null) }, h('span',{className:'dqs-grip','aria-hidden':true},'⋮⋮'), isEdit ? h('textarea',{className:'dqs-editor',autoFocus:true,value:editing.value,onChange:(e)=>setEditing({...editing,value:e.target.value}),onKeyDown:(e)=>{if(e.key==='Escape')cancelEdit();if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();saveEdit();}}}) : h('span',{className:'dqs-preview'}, draft || rowText(row)), draft && !isEdit && h('small',{className:'dqs-draft'},'draft'), h('span',{className:'dqs-actions'}, isEdit ? [h('button',{key:'save',className:'dqs-action',disabled:!!busy,onClick:saveEdit},icon('IconCheckOutlineRegular','✓')),h('button',{key:'cancel',className:'dqs-action',disabled:!!busy,onClick:cancelEdit},icon('IconCloseOutlineRegular','×'))] : [h('button',{key:'edit',className:'dqs-action',disabled:!!busy,onClick:()=>startEdit(row)},icon('IconEditOutlineRegular','✎')),h('button',{key:'move',className:'dqs-action',disabled:!!busy,onClick:()=>move(row.id,tab==='batch'?'sequential':'batch')},tab==='batch'?'⇢':'⇠'),h('button',{key:'steer',className:'dqs-action',disabled:!!busy||!running,onClick:()=>action(row.id,{kind:'steer'})},icon('IconSendOutlineRegular','➤')),h('button',{key:'remove',className:'dqs-action',disabled:!!busy,onClick:()=>action(row.id,{kind:'remove'})},icon('IconTrashOutlineRegular','×'))])); };
|
||||||
|
return h('div',{className:'dqs-dock','data-queue-dock':'dsh-queue-steer'},h('div',{className:'dqs-panel'},h('button',{className:'dqs-header',onClick:()=>setOpen(!open)},icon('IconQueueOutlineRegular','☷'),h('span',{className:'dqs-count'},`Queue & Steering (${rows.length})`),h('span',null,open?'⌃':'⌄')),open && h(React.Fragment,null,h('div',{className:'dqs-tabs'},h('button',{className:`dqs-tab ${tab==='batch'?'active':''}`,onClick:()=>{setTab('batch');write(`${sessionId}:tab`,'batch')}},`List 1 · all at once (${sorted.filter((r)=>listOf(r)==='batch').length})`),h('button',{className:`dqs-tab ${tab==='sequential'?'active':''}`,onClick:()=>{setTab('sequential');write(`${sessionId}:tab`,'sequential')}},`List 2 · one at a time (${sorted.filter((r)=>listOf(r)==='sequential').length})`)),visible.length?h('ul',{className:'dqs-list'},visible.map((r)=>h(Item,{key:r.id,row:r}))):h('div',{className:'dqs-empty'},'No entries in this list'))));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { inject: ['slots', 'conversation'], apply(ctx) { installCss(); ctx.slots.inject('settings.section', () => ctx.slots.register({name:'settings.section', id:'dsh-queue-steer', order:170, label:'Queue & Steering'}, SettingsPanel)); ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({name:'conversation.input.dock', id:'queue', priority:-1, order:20, inject:(sessionId)=>({updateQueue:(id,a)=>ctx.conversation.updateQueue(id,a), notify:(level,msg)=>ctx.conversation.input.for(ctx.sessions.scope(sessionId)).notify(level,msg)})}, QueueDock)); } };
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# cordis.patch.yml for dsh-queue-steer
|
||||||
|
bundles:
|
||||||
|
- id: dsh-queue-steer
|
||||||
|
name: '@local/dsh-queue-steer'
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import os from 'node:os'
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import { CommandDefinitionId } from '@deepseek-ai/dsh-commands'
|
||||||
|
|
||||||
|
export const name = 'dsh-queue-steer'
|
||||||
|
export const inject = { commands: { required: true }, webServer: { required: true }, connection: { required: true } }
|
||||||
|
const file = path.join(process.env.DSH_PROFILE_DIR || path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'profiles', 'web'), 'dsh-queue-steer', 'settings.json')
|
||||||
|
const defaults = { defaultMode: 'queue', defaultQueueList: 'batch', batchHeaderTemplate: 'Queued messages batch ({count}):\n\n{messages}' }
|
||||||
|
let settings = { ...defaults }
|
||||||
|
try { settings = { ...defaults, ...JSON.parse(fs.readFileSync(file, 'utf8')) } } catch {}
|
||||||
|
const save = (next) => { settings = { ...settings, ...next }; fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(settings, null, 2)) }
|
||||||
|
const message = (value, via) => ({ id: crypto.randomUUID(), role: 'user', content: [{ type: 'text', text: value }], source: { kind: 'user', via } })
|
||||||
|
|
||||||
|
export function apply(ctx) {
|
||||||
|
const add = (name, mode) => ctx.commands.register({
|
||||||
|
definitionId: CommandDefinitionId(`@local/dsh-queue-steer#${name}`), name,
|
||||||
|
description: mode === 'steer' ? 'Steer the running agent with a message' : 'Queue a message for the agent',
|
||||||
|
input: { hint: '[message]' },
|
||||||
|
handler: ({ rawInput, agent }) => {
|
||||||
|
const value = rawInput.trim()
|
||||||
|
if (!value) return { kind: 'error', text: `Usage: /${name} <message>` }
|
||||||
|
const m = message(value, `command-${name}`)
|
||||||
|
if (mode === 'steer' && agent.status === 'running') agent.steer(m)
|
||||||
|
else if (mode === 'steer') agent.followup(m)
|
||||||
|
else agent.inbox.append('next-turn', m)
|
||||||
|
return { kind: 'success', text: `${mode === 'steer' ? 'Sent' : 'Queued'}: ${value.slice(0, 120)}` }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
add('queue', 'queue'); add('steer', 'steer')
|
||||||
|
ctx.inject(['webServer', 'connection'], (wctx) => wctx.effect(() => wctx.webServer.register({
|
||||||
|
kind: 'exact', path: '/dsh-queue-steer/settings',
|
||||||
|
handler: async (req, res) => {
|
||||||
|
const denied = wctx.connection.requestRejection(req)
|
||||||
|
res.setHeader('content-type', 'application/json; charset=utf-8'); res.setHeader('cache-control', 'no-store')
|
||||||
|
if (denied !== undefined) { res.statusCode = denied; return res.end(JSON.stringify({ error: 'rejected' })) }
|
||||||
|
if (req.method === 'GET') { res.statusCode = 200; return res.end(JSON.stringify(settings)) }
|
||||||
|
if (req.method !== 'POST') { res.statusCode = 405; return res.end() }
|
||||||
|
let body = ''; for await (const chunk of req) body += chunk
|
||||||
|
try { save(JSON.parse(body || '{}')); res.statusCode = 200; res.end(JSON.stringify(settings)) } catch (e) { res.statusCode = 400; res.end(JSON.stringify({ error: String(e.message || e) })) }
|
||||||
|
},
|
||||||
|
}), 'dsh-queue-steer: settings'))
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "@local/dsh-queue-steer",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"description": "Custom queue and steering logic, settings page, /queue and /steer commands, and two-list queue widget for DeepSeek Harness.",
|
||||||
|
"type": "module",
|
||||||
|
"main": "index.js",
|
||||||
|
"dsh": {
|
||||||
|
"bundle": { "patch": "./cordis.patch.yml" },
|
||||||
|
"client": {
|
||||||
|
"platform": "web",
|
||||||
|
"immediately": true,
|
||||||
|
"inject": [
|
||||||
|
"@deepseek-ai/dsh-client-ui-conversation",
|
||||||
|
"@deepseek-ai/dsh-client-ui-settings",
|
||||||
|
"@deepseek-ai/dsh-client-ui-primitives"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exports": { ".": "./index.js", "./client": "./client.js", "./package.json": "./package.json" },
|
||||||
|
"files": ["index.js", "client.js", "cordis.patch.yml", "README.md"],
|
||||||
|
"scripts": { "test": "node --test test/*.test.mjs" },
|
||||||
|
"dependencies": {}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user