feat: add the dsh-superagent research prototype
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
/** Execution status view (active Tickets and pause control) and Execution settings group (Executor capacity and workspace environment). */
|
||||
import { useRef, useState } from 'react'
|
||||
import { TICKET_FILTERS, type TicketFilter } from '../app/api.ts'
|
||||
import { LIMITS } from '../limits.ts'
|
||||
import { usePoll, type SettingsView, type SourceView, type StateView } from './api.ts'
|
||||
import { DisclosureRow, Icon, InfoTip, Input, Pill } from './controls.tsx'
|
||||
import { SaveBar } from './draft.tsx'
|
||||
import { usePanel } from './provider.tsx'
|
||||
import { TicketLine } from './rows.tsx'
|
||||
import { ConfigHistory, Field, Fields, ModelField, numberOf, useBlockDraft, useSettings } from './settings-kit.tsx'
|
||||
import { localized } from './text.ts'
|
||||
import { ConfirmButton, Hint, LabelValue, LoadMore, LoadState, Section, StatusHead } from './widgets.tsx'
|
||||
|
||||
export function ExecutionStatus({ filter: current, state, reload }: {
|
||||
filter: TicketFilter; state: StateView; reload: () => Promise<void>
|
||||
}) {
|
||||
const { t, call, navigate } = usePanel()
|
||||
const [limit, setLimit] = useState(20)
|
||||
const { data, error, reload: reloadTickets } = usePoll(() => call('GET tickets', { query: { state: current, limit } }), [current, limit])
|
||||
const { paused, load: { maxWorkers } } = state
|
||||
// Pausing uses its own block revision so toggling pause does not invalidate an open Execution settings draft.
|
||||
const pause = async () => { await call('POST executor/pause', { body: { paused: !paused } }); await reload() }
|
||||
const status = state.setup.problem ? { tone: 'warning', label: t('executorWaitingSetup') } : paused ? { tone: 'warning',
|
||||
label: t('processingPaused') }
|
||||
: { tone: 'ongoing', label: t('executorRunning') }
|
||||
const action = paused ? 'resumeProcessing' : 'pauseProcessing'
|
||||
return <>
|
||||
<StatusHead hint={t('executionStatusIntro')} settings="execution" />
|
||||
<div className="dt-row-meta dt-status-line">
|
||||
<span className="dt-status-text" data-state={status.tone}>{status.label}</span>
|
||||
{!state.setup.problem && <>
|
||||
<LabelValue label={t('executingLabel')}
|
||||
value={`${state.load.running} / ${maxWorkers}`} /><LabelValue label={t('queuedLabel')} value={state.load.queued} />
|
||||
</>}
|
||||
<ConfirmButton variant="outline" icon={paused ? 'play' : 'pause'} label={t(action)} title={t(action)} message={t(paused
|
||||
? 'resumeProcessingConfirm' : 'pauseProcessingConfirm')}
|
||||
onConfirm={pause} />
|
||||
</div>
|
||||
<div className="dt-filters">
|
||||
{TICKET_FILTERS.map(value => <Pill key={value} active={current === value}
|
||||
onClick={() => { setLimit(20); navigate({ page: 'execution', query: value === 'open' ? {} : { filter: value } },
|
||||
true) }}>{t(`ticketFilter.${value}`)}</Pill>)}
|
||||
</div>
|
||||
{current === 'idle' && <Hint>{t('idleHint')}</Hint>}
|
||||
<LoadState error={error} loading={!data} />
|
||||
{data && (data.items.length ? <ul className="dt-list">{data.items.map(ticket => ticket.stage === 'person'
|
||||
? <TicketLine key={ticket.id} ticket={ticket} question
|
||||
onChanged={reloadTickets} /> : <TicketLine key={ticket.id} ticket={ticket} />)}</ul> : <Hint>{t('none')}</Hint>)}
|
||||
{data?.next_offset != null && <LoadMore onMore={() => setLimit(limit + 20)} />}
|
||||
</>
|
||||
}
|
||||
|
||||
const firstSentence = (text: string): string => /^[^。;;.]*(?:[。;;]|\.(?=\s|$))?/.exec(text)?.[0] ?? text
|
||||
function EnvironmentVariables({ groups, sources,
|
||||
onPick }: { groups: SettingsView['environment']['variables']; sources: SourceView[]; onPick: (key: string) => void }) {
|
||||
const { t } = usePanel()
|
||||
const [open, setOpen] = useState(false)
|
||||
const owner = (id: string) => id === 'executor' ? t('term.executor') : localized(t, sources.find(source => source.id === id)?.title ?? id)
|
||||
const count = groups.reduce((sum, group) => sum + group.variables.length, 0)
|
||||
return <DisclosureRow icon={<Icon name="hash" />} title={<>{t('environmentVariables')} <span className="dt-count">{count}</span></>}
|
||||
open={open} onToggle={() => setOpen(!open)}>
|
||||
<div className="dt-disclosure-body">
|
||||
<Hint>{t('environmentPick')}</Hint>
|
||||
<table className="dt-table">
|
||||
<thead><tr><th>{t('environmentKey')}</th><th>{t('environmentDescription')}</th><th>{t('environmentOwner')}</th></tr></thead>
|
||||
<tbody>{groups.flatMap(group => group.variables.map(variable => <tr key={variable.key}>
|
||||
<td><button type="button" className="dt-textlink dt-mono" onClick={() => onPick(variable.key)}>{variable.key}</button></td>
|
||||
<td title={localized(t, variable.description)}>{firstSentence(localized(t, variable.description))}</td>
|
||||
<td>{owner(group.owner)}{!group.enabled && <span className="dt-hint"> · {t('sourceOff')}</span>}</td>
|
||||
</tr>))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</DisclosureRow>
|
||||
}
|
||||
export function ExecutionSettings() {
|
||||
const { t } = usePanel()
|
||||
const { blocks, environment, sources } = useSettings().settings
|
||||
// Keep raw user text (such as separators between blocked commands) in the draft and parse only on save.
|
||||
const executor = blocks.executor.value
|
||||
const saved = { ...executor, maxWorkers: String(executor.maxWorkers), blockedCommands: executor.blockedCommands.join(' ') }
|
||||
const draft = useBlockDraft('executor', saved, blocks.executor.revision,
|
||||
value => ({ ...value, maxWorkers: numberOf(value.maxWorkers), blockedCommands: value.blockedCommands.split(/[\s,]+/).filter(Boolean) }))
|
||||
const value = draft.value
|
||||
const edit = (next: Partial<typeof value>) => draft.edit({ ...value, ...next })
|
||||
const prepare = useRef<HTMLTextAreaElement>(null)
|
||||
const insert = (key: string) => {
|
||||
const field = prepare.current
|
||||
const at = field?.selectionStart ?? value.prepare.length, to = field?.selectionEnd ?? at
|
||||
edit({ prepare: `${value.prepare.slice(0, at)}"$${key}"${value.prepare.slice(to)}` })
|
||||
requestAnimationFrame(() => { field?.focus(); const caret = at + key.length + 3; field?.setSelectionRange(caret, caret) })
|
||||
}
|
||||
return <div className="dt-form">
|
||||
<form className="dt-form" onSubmit={event => { void draft.submit(event) }}>
|
||||
<Fields disabled={draft.busy}>
|
||||
<div className="dt-form dt-block">
|
||||
<h2 className="dt-block-title">{t('term.executor')}<InfoTip text={t('executorIntro')} /></h2>
|
||||
<Section title={t('executorModel')} hint={t('executorModelHint')}><ModelField id="dt-worker-model" value={value.model}
|
||||
onChange={model => edit({ model })} /></Section>
|
||||
<Field label={t('maxWorkers')} hint={t('maxWorkersHint')}>
|
||||
<Input type="number" min={LIMITS.maxWorkers.min} max={LIMITS.maxWorkers.max} required value={value.maxWorkers}
|
||||
onChange={event => edit({ maxWorkers: event.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="dt-form dt-block">
|
||||
<h2 className="dt-block-title">{t('term.environment')}<InfoTip text={t('environmentIntro')} /></h2>
|
||||
<Field label={t('executionDirectory')} hint={t('executionDirectoryHint')} note={value.directory ? undefined
|
||||
: t('executionDirectoryDefault', { root: environment.root })}>
|
||||
<Input className="dt-mono" value={value.directory} placeholder={t('executionDirectoryEmpty')} spellCheck={false} pattern="/.*"
|
||||
onChange={event => edit({ directory: event.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('prepareCommand')} hint={t('prepareCommandHint')}>
|
||||
<textarea ref={prepare} className="dt-textarea dt-mono" rows={3} maxLength={LIMITS.prepareCommand} spellCheck={false}
|
||||
placeholder={t('prepareCommandEmpty')} value={value.prepare}
|
||||
onChange={event => edit({ prepare: event.target.value })} />
|
||||
</Field>
|
||||
<EnvironmentVariables groups={environment.variables} sources={sources} onPick={insert} />
|
||||
<Field label={t('blockedCommands')} hint={t('blockedCommandsHint')}>
|
||||
<Input className="dt-mono" value={value.blockedCommands} placeholder={t('blockedCommandsNone')} spellCheck={false}
|
||||
onChange={event => edit({ blockedCommands: event.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
</Fields>
|
||||
<SaveBar {...draft} onDiscard={draft.discard} />
|
||||
</form>
|
||||
<ConfigHistory blocks={['executor', 'pause']} />
|
||||
</div>
|
||||
}
|
||||
Reference in New Issue
Block a user