feat: add custom queue and steering controls

This commit is contained in:
Agent committed 2026-10-13 11:10:52 +00:00
commit f43833ebf8
5 files changed
+162

No files matched your search

+73
View File
@@ -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)); } };
}
});