• feat(self-modification): add dynamic Cordis plugin runtime and UI
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
/* Cordis definition card: DisclosureRow chrome (leading/chevron/title come from
|
||||
that primitive) plus this card's own trailing run switch and source body.
|
||||
No running sweep: `cordis_define` only registers a definition in host memory,
|
||||
so the call settles in the same turn it starts and the state dot carries the
|
||||
whole signal. */
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* The shared cordis accent (matches ui-tool's [data-tool^='cordis_'] rows). */
|
||||
.card .title,
|
||||
.card .chevron {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.row {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.separator {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
margin: 0 8px;
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: none;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* The purpose absorbs the remaining width and clips first: the name and the
|
||||
switch are the two parts that must survive a narrow row. */
|
||||
.purpose {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
margin-left: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.errorSummary {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.requestError {
|
||||
flex: none;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
margin-left: 8px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.readout {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Said once in the expanded body: this card records, the panel operates. */
|
||||
.panelHint {
|
||||
margin: 4px 0 2px 4px;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.statusLabel {
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
/* The model's question, shown beside the approve/decline pair. */
|
||||
.approvalPrompt {
|
||||
flex: none;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
margin-left: 8px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* A qualified success (nothing acknowledged the dispatch, or the browser half
|
||||
parked on missing services): informational, so it must not read as the error
|
||||
line beside it. */
|
||||
.notice {
|
||||
flex: none;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
margin-left: 8px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.switch {
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Terminal definitions (unloaded, or lost to a host restart) stay in the flow
|
||||
as a greyed card: the define call is still in the session log, so removing
|
||||
the row would leave a replay hole. */
|
||||
.card[data-terminal] .title,
|
||||
.card[data-terminal] .name,
|
||||
.card[data-terminal] .purpose,
|
||||
.card[data-terminal] .statusLabel {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.card[data-terminal] .separator {
|
||||
background: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.bodyWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sourceCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 4px 0 4px 4px;
|
||||
}
|
||||
|
||||
.sourceTabs {
|
||||
display: flex;
|
||||
height: 32px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.sourceTab {
|
||||
position: relative;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
.sourceTab:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.sourceTab:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.sourceTabActive {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.sourceTabActive::after {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 0;
|
||||
left: 10px;
|
||||
height: 2px;
|
||||
border-radius: 1px 1px 0 0;
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.sourceTab:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.sourcePanel {
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sourceCode {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.codeSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 260px;
|
||||
margin: 4px 0 4px 4px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
flex: none;
|
||||
padding: 2px 0;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.output {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font: var(--dsw-font-markdown-code-block-small);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.output[data-error] {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.inspectButton {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 4px 0 2px 4px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.card:hover .inspectButton,
|
||||
.inspectButton:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.inspectButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover-solid);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.inspectButton {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
171
packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx
Normal file
171
packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
/** Read-only `cordis_define` card with Host and Client source tabs. */
|
||||
|
||||
import { useId, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
CodeBlock, DisclosureRow, IconCodeOutline16, IconInspectOutline12, StateDot,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
|
||||
import { cordisDefineCard, type CordisToolState } from './card-model.ts'
|
||||
import type { CordisCardFace } from './slots.ts'
|
||||
import { cordisVisibleStatus, type CordisVisibleStatus } from './status.ts'
|
||||
import type { CordisKey } from './locales.ts'
|
||||
import css from './CordisDefineRow.module.css'
|
||||
|
||||
/** Full card props composed by the keyed Tool slot. */
|
||||
export type CordisDefineRowProps = ToolCallViewProps & InjectFace<CordisCardFace> & PropsLocale<'cordis'>
|
||||
|
||||
type CardReading = CordisVisibleStatus | 'removed'
|
||||
type SourceTab = 'client' | 'host'
|
||||
|
||||
const READING_LABELS = {
|
||||
idle: 'status.idle',
|
||||
'client-pending': 'status.clientPending',
|
||||
running: 'status.running',
|
||||
removed: 'status.removed',
|
||||
} as const satisfies Record<CardReading, CordisKey>
|
||||
|
||||
function stateStatus(state: CordisToolState): CordisKey | null {
|
||||
switch (state) {
|
||||
case 'running': return 'a11y.defining'
|
||||
case 'error': return 'a11y.failed'
|
||||
case 'stopped': return 'a11y.stopped'
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
function leadingFor(state: CordisToolState): ReactNode {
|
||||
switch (state) {
|
||||
case 'error': return <StateDot state="error" />
|
||||
case 'stopped': return <StateDot state="warning" />
|
||||
default: return <IconCodeOutline16 size={14} />
|
||||
}
|
||||
}
|
||||
|
||||
/** Render one immutable Package definition. */
|
||||
export function CordisDefineRow({
|
||||
callId, block, inspect, useInventory, useLoaded, t,
|
||||
}: CordisDefineRowProps) {
|
||||
const card = cordisDefineCard(block)
|
||||
const inventory = useInventory(snapshot => snapshot)
|
||||
const loaded = useLoaded(snapshot => snapshot)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [selectedSource, setSelectedSource] = useState<SourceTab>(card.clientCode !== null ? 'client' : 'host')
|
||||
const sourcePanelId = useId()
|
||||
|
||||
const row = card.pluginId === null
|
||||
? undefined
|
||||
: inventory.rows.find(candidate => candidate.pluginId === card.pluginId)
|
||||
const reading: CardReading = card.pluginId !== null && inventory.removed.has(card.pluginId)
|
||||
? 'removed'
|
||||
: row !== undefined && card.packageId !== null
|
||||
? cordisVisibleStatus(row, card.packageId, loaded)
|
||||
: 'idle'
|
||||
const name = card.name ?? callId
|
||||
const expandable = card.hostCode !== null || card.clientCode !== null || card.output !== null
|
||||
const open = expanded && expandable
|
||||
const a11yState = stateStatus(card.state)
|
||||
const hasSource = card.clientCode !== null || card.hostCode !== null
|
||||
const activeSource: SourceTab = selectedSource === 'client' && card.clientCode !== null
|
||||
? 'client'
|
||||
: selectedSource === 'host' && card.hostCode !== null
|
||||
? 'host'
|
||||
: card.clientCode !== null ? 'client' : 'host'
|
||||
const activeCode = activeSource === 'client' ? card.clientCode : card.hostCode
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css.card}
|
||||
data-tool="cordis_define"
|
||||
data-state={card.state}
|
||||
data-terminal={reading === 'removed' || undefined}
|
||||
data-cordis-plugin-id={card.pluginId ?? undefined}
|
||||
data-cordis-package-id={card.packageId ?? undefined}
|
||||
data-cordis-status={reading}
|
||||
>
|
||||
{a11yState !== null && <span className={css.visuallyHidden}>{t(a11yState)}</span>}
|
||||
<DisclosureRow
|
||||
rowClassName={css.row}
|
||||
titleClassName={css.title}
|
||||
chevronClassName={css.chevron}
|
||||
icon={leadingFor(card.state)}
|
||||
title={t('row.defineTitle')}
|
||||
open={open}
|
||||
expandable={expandable}
|
||||
expandOnRowClick
|
||||
keepContentWhenOpen
|
||||
onToggle={() => { setExpanded(value => !value) }}
|
||||
collapsedContent={(
|
||||
<>
|
||||
<span className={css.separator} aria-hidden />
|
||||
<span className={card.errorSummary === null ? css.name : css.errorSummary}>
|
||||
{card.errorSummary ?? name}
|
||||
</span>
|
||||
{card.errorSummary === null && (
|
||||
<span className={css.purpose}>{card.purpose ?? t('purpose.missing')}</span>
|
||||
)}
|
||||
{card.pluginId !== null && (
|
||||
<span className={css.readout}>
|
||||
<span className={css.statusLabel}>{t(READING_LABELS[reading])}</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className={css.bodyWrap}>
|
||||
{hasSource && activeCode !== null && (
|
||||
<section className={css.sourceCard}>
|
||||
<div className={css.sourceTabs} role="tablist" aria-label={t('body.source')}>
|
||||
{(['client', 'host'] as const).map(source => {
|
||||
const available = source === 'client' ? card.clientCode !== null : card.hostCode !== null
|
||||
return (
|
||||
<button
|
||||
key={source}
|
||||
id={`${sourcePanelId}-${source}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls={sourcePanelId}
|
||||
aria-selected={activeSource === source}
|
||||
className={activeSource === source ? `${css.sourceTab} ${css.sourceTabActive}` : css.sourceTab}
|
||||
disabled={!available}
|
||||
onClick={() => { setSelectedSource(source) }}
|
||||
>
|
||||
{t(source === 'client' ? 'body.clientCode' : 'body.hostCode')}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
id={sourcePanelId}
|
||||
className={css.sourcePanel}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`${sourcePanelId}-${activeSource}`}
|
||||
>
|
||||
<CodeBlock
|
||||
code={activeCode}
|
||||
lang="javascript"
|
||||
copyLabel={t('body.copy')}
|
||||
copiedLabel={t('body.copied')}
|
||||
className={css.sourceCode}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{card.output !== null && (
|
||||
<section className={css.codeSection}>
|
||||
<div className={css.sectionLabel}>{t('body.output')}</div>
|
||||
<pre className={css.output} data-error={card.state === 'error' || undefined}>{card.output}</pre>
|
||||
</section>
|
||||
)}
|
||||
{card.pluginId !== null && <div className={css.panelHint}>{t('panel.hint')}</div>}
|
||||
{inspect !== undefined && (
|
||||
<button type="button" className={css.inspectButton} onClick={inspect}>
|
||||
<IconInspectOutline12 />
|
||||
Inspect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</DisclosureRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
370
packages/extensions/ui-cordis/src/client/CordisPanel.module.css
Normal file
370
packages/extensions/ui-cordis/src/client/CordisPanel.module.css
Normal file
@@ -0,0 +1,370 @@
|
||||
/* Sidebar-foot Cordis action and the fixed list it opens above the footer. */
|
||||
|
||||
.layer {
|
||||
position: relative;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 49px;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.footerButtons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 49px;
|
||||
padding: 0 8px 0 6px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.badge:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover-solid);
|
||||
}
|
||||
|
||||
.badge[data-active] {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.badgeLabel {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badgeCount {
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.layer.rail {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rail .badge {
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.rail .footerButtons {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
bottom: 128px;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 420px;
|
||||
max-width: calc(100vw - 24px);
|
||||
max-height: 60vh;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
/* Only `.body` scrolls; the header remains fixed above it. */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
.header {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 44px;
|
||||
padding: 10px 12px;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 4px 12px 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.note,
|
||||
.readError {
|
||||
margin: 4px 0;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.readError {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* Session headings replace per-row ownership markers. */
|
||||
.group {
|
||||
margin: 8px 0 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
.row[data-cordis-awaiting] {
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.rowHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rowId {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-family: var(--dsh-font-mono, monospace);
|
||||
font-size: 11px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.rowName {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rowStatus {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 10px;
|
||||
background: var(--dsw-alias-button-ghost-active-fill);
|
||||
font-size: 11px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.row[data-cordis-status='idle'] .rowStatus {
|
||||
background: var(--dsw-alias-button-ghost-active-fill);
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.row[data-cordis-status='awaiting-approval'] .rowStatus,
|
||||
.row[data-cordis-status='client-pending'] .rowStatus {
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
}
|
||||
|
||||
.row[data-cordis-status='failed'] .rowStatus {
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.row[data-cordis-status='running'] .rowStatus {
|
||||
background: var(--dsw-alias-state-success-tertiary);
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.rowDetail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.versionPicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.versionPicker select {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 7px;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.rowPurpose {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rowError {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.transition {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.transitionActions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.transitionActions button {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.transitionActions button:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.transitionActions button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.activeVersion {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
/* Matches GoalBar's action controls: compact circular icons whose visible copy
|
||||
lives in a tooltip and accessible name. */
|
||||
.actionButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.actionButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.actionButton:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.doubleCheck {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 17px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.doubleCheck svg {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
.doubleCheck svg:first-child {
|
||||
left: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.doubleCheck svg:last-child {
|
||||
left: 5px;
|
||||
}
|
||||
471
packages/extensions/ui-cordis/src/client/CordisPanel.tsx
Normal file
471
packages/extensions/ui-cordis/src/client/CordisPanel.tsx
Normal file
@@ -0,0 +1,471 @@
|
||||
/** Frame-wide dynamic Plugin inventory, approvals, versions, and lifecycle actions. */
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import {
|
||||
IconCheckOutline16, IconCloseOutline16, IconCordisPluginOutline14, IconPlayOutline16,
|
||||
IconStopFill16, IconTrashOutline16, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
import type { CordisRunActivity } from '@deepseek-ai/dsh-cordis-client-runner/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { CordisInventoryRow } from './dynamic-port.ts'
|
||||
import type { CordisPanelFace } from './slots.ts'
|
||||
import type { CordisKey } from './locales.ts'
|
||||
import type {
|
||||
ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId,
|
||||
} from './events.ts'
|
||||
import { cordisVisibleStatus, packageOf, type CordisVisibleStatus } from './status.ts'
|
||||
import css from './CordisPanel.module.css'
|
||||
|
||||
/** Full panel props composed by the sidebar footer-action slot. */
|
||||
export type CordisPanelProps =
|
||||
PropsRuntime<'sidebar.footer.action'> & InjectFace<CordisPanelFace> & PropsLocale<'cordis'>
|
||||
|
||||
type PanelStatus = CordisVisibleStatus | 'awaiting-approval' | 'failed'
|
||||
|
||||
const STATUS_LABELS = {
|
||||
idle: 'status.idle',
|
||||
'awaiting-approval': 'status.awaitingApproval',
|
||||
'client-pending': 'status.clientPending',
|
||||
running: 'status.running',
|
||||
failed: 'status.failed',
|
||||
} as const satisfies Record<PanelStatus, CordisKey>
|
||||
|
||||
const RENDER_FAILURE_LABELS = {
|
||||
abdicated: 'render.failedAbdicated',
|
||||
held: 'render.failedHeld',
|
||||
} as const satisfies Record<'abdicated' | 'held', CordisKey>
|
||||
|
||||
interface RowView {
|
||||
readonly pluginId: CordisDynamicPluginId
|
||||
readonly agentId: SessionId
|
||||
readonly listed?: CordisInventoryRow
|
||||
readonly activity?: CordisRunActivity
|
||||
}
|
||||
|
||||
function selectedPackageIdOf(
|
||||
{ pluginId, listed, activity }: RowView,
|
||||
selected: Readonly<Record<string, CordisDynamicPackageId>>,
|
||||
): CordisDynamicPackageId | undefined {
|
||||
const selectedPackageId = selected[pluginId]
|
||||
if (selectedPackageId !== undefined
|
||||
&& listed?.packages.some(pkg => pkg.packageId === selectedPackageId)) return selectedPackageId
|
||||
return listed?.nextPackageId
|
||||
?? listed?.currentPackageId
|
||||
?? listed?.packages.at(-1)?.packageId
|
||||
?? activity?.packageId
|
||||
}
|
||||
|
||||
function visiblePanelStatus(
|
||||
view: RowView,
|
||||
selectedPackageId: CordisDynamicPackageId | undefined,
|
||||
loaded: Parameters<typeof cordisVisibleStatus>[2],
|
||||
): PanelStatus {
|
||||
const { listed, activity } = view
|
||||
const latest = listed?.latestRun
|
||||
if (activity?.phase === 'awaiting-approval' || latest?.status === 'awaiting-approval') {
|
||||
return 'awaiting-approval'
|
||||
}
|
||||
if (latest?.status === 'failed' && latest.packageId === selectedPackageId) return 'failed'
|
||||
if (listed?.activeRun === undefined) return 'idle'
|
||||
return cordisVisibleStatus(listed, listed.activeRun.packageId, loaded)
|
||||
}
|
||||
|
||||
function blockingFirst(rows: readonly RowView[]): readonly RowView[] {
|
||||
return [
|
||||
...rows.filter(row => row.activity?.phase === 'awaiting-approval'),
|
||||
...rows.filter(row => row.activity?.phase !== 'awaiting-approval'),
|
||||
]
|
||||
}
|
||||
|
||||
function RowAction({ label, children, ...props }: {
|
||||
label: string
|
||||
children: ReactNode
|
||||
} & ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<Tooltip label={label} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.actionButton} aria-label={label} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function DoubleCheckIcon() {
|
||||
return (
|
||||
<span className={css.doubleCheck} aria-hidden>
|
||||
<IconCheckOutline16 size={12} />
|
||||
<IconCheckOutline16 size={12} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Render the inventory panel and its unified footer action. */
|
||||
export function CordisPanel({
|
||||
wide,
|
||||
useSessions, useInventory, useActiveRuns, useRunErrors, useLoaded, useRenderFailures,
|
||||
onApprove, onDecline, onRun, onStop, onRemove, onRefresh, t,
|
||||
}: CordisPanelProps) {
|
||||
const inventory = useInventory(snapshot => snapshot)
|
||||
const activeRuns = useActiveRuns(snapshot => snapshot)
|
||||
const errors = useRunErrors(snapshot => snapshot)
|
||||
const loaded = useLoaded(snapshot => snapshot)
|
||||
const renderFailures = useRenderFailures(snapshot => snapshot)
|
||||
const current = useSessions(state => state.current)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selected, setSelected] = useState<Record<string, CordisDynamicPackageId>>({})
|
||||
const [pending, setPending] = useState<ReadonlySet<CordisDynamicPluginId>>(new Set())
|
||||
const [actionErrors, setActionErrors] = useState<ReadonlyMap<CordisDynamicPluginId, string>>(new Map())
|
||||
const visibleRequests = useRef<Set<ApprovalRequestId>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
const now = new Set<ApprovalRequestId>()
|
||||
for (const activity of activeRuns.values()) {
|
||||
if (activity.phase === 'awaiting-approval') now.add(activity.requestId)
|
||||
}
|
||||
const discovered = [...now].some(requestId => !visibleRequests.current.has(requestId))
|
||||
visibleRequests.current = now
|
||||
if (discovered) setOpen(true)
|
||||
}, [activeRuns])
|
||||
|
||||
useEffect(() => { onRefresh() }, [onRefresh])
|
||||
useEffect(() => { if (open) onRefresh() }, [onRefresh, open])
|
||||
|
||||
const byPlugin = new Map<CordisDynamicPluginId, RowView>()
|
||||
for (const listed of inventory.rows) {
|
||||
const activity = activeRuns.get(listed.pluginId)
|
||||
byPlugin.set(listed.pluginId, {
|
||||
pluginId: listed.pluginId,
|
||||
agentId: activity?.agentId ?? listed.agentId,
|
||||
listed,
|
||||
...activity === undefined ? {} : { activity },
|
||||
})
|
||||
}
|
||||
for (const [pluginId, activity] of activeRuns) {
|
||||
if (byPlugin.has(pluginId)) continue
|
||||
byPlugin.set(pluginId, { pluginId, agentId: activity.agentId, activity })
|
||||
}
|
||||
const all = [...byPlugin.values()]
|
||||
const mine = blockingFirst(all.filter(row => current !== undefined && row.agentId === current))
|
||||
const theirs = blockingFirst(all.filter(row => current === undefined || row.agentId !== current))
|
||||
const approvals = [...activeRuns.values()].filter(activity => activity.phase === 'awaiting-approval').length
|
||||
const running = all.filter(view => visiblePanelStatus(
|
||||
view,
|
||||
selectedPackageIdOf(view, selected),
|
||||
loaded,
|
||||
) === 'running').length
|
||||
|
||||
if (all.length === 0) return null
|
||||
|
||||
const runAction = async (pluginId: CordisDynamicPluginId, action: () => Promise<void | { ok: boolean; message?: string }>) => {
|
||||
if (pending.has(pluginId)) return
|
||||
setPending(currentPending => new Set(currentPending).add(pluginId))
|
||||
setActionErrors(currentErrors => {
|
||||
const next = new Map(currentErrors)
|
||||
next.delete(pluginId)
|
||||
return next
|
||||
})
|
||||
try {
|
||||
const result = await action()
|
||||
if (result !== undefined && !result.ok) {
|
||||
setActionErrors(currentErrors => new Map(currentErrors).set(pluginId, result.message ?? 'operation failed'))
|
||||
}
|
||||
} catch (error) {
|
||||
setActionErrors(currentErrors => new Map(currentErrors).set(
|
||||
pluginId,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
))
|
||||
} finally {
|
||||
setPending(currentPending => {
|
||||
const next = new Set(currentPending)
|
||||
next.delete(pluginId)
|
||||
return next
|
||||
})
|
||||
onRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
const renderRow = (view: RowView) => {
|
||||
const { pluginId, listed, activity } = view
|
||||
const selectedPackageId = selectedPackageIdOf(view, selected)
|
||||
const selectedPackage = listed !== undefined && selectedPackageId !== undefined
|
||||
? packageOf(listed, selectedPackageId)
|
||||
: undefined
|
||||
const activePackage = listed?.activeRun === undefined
|
||||
? undefined
|
||||
: packageOf(listed, listed.activeRun.packageId)
|
||||
const name = selectedPackage?.name
|
||||
?? (activity?.phase === 'awaiting-approval' ? activity.name : pluginId)
|
||||
const purpose = selectedPackage?.purpose
|
||||
?? (activity?.phase === 'awaiting-approval' ? activity.purpose : '')
|
||||
const latest = listed?.latestRun
|
||||
const awaiting = activity?.phase === 'awaiting-approval'
|
||||
? activity.requestId
|
||||
: latest?.status === 'awaiting-approval' ? latest.approvalRequestId : undefined
|
||||
const status = visiblePanelStatus(view, selectedPackageId, loaded)
|
||||
const busy = pending.has(pluginId) || activity?.phase === 'orchestrating'
|
||||
const failure = errors.get(pluginId)
|
||||
const hostFailure = latest?.status === 'failed' ? latest.error : undefined
|
||||
const renderFailure = renderFailures.get(pluginId)
|
||||
const actionError = actionErrors.get(pluginId)
|
||||
const hasFailedTransition = listed?.nextPackageId !== undefined
|
||||
&& listed.nextPackageId !== listed.currentPackageId
|
||||
const runMode = listed?.currentPackageId !== undefined
|
||||
&& selectedPackageId !== listed.currentPackageId ? 'update' as const : 'run' as const
|
||||
|
||||
return (
|
||||
<li
|
||||
key={pluginId}
|
||||
className={css.row}
|
||||
data-cordis-row={pluginId}
|
||||
data-cordis-status={status}
|
||||
data-cordis-awaiting={awaiting !== undefined || undefined}
|
||||
>
|
||||
<div className={css.rowHead}>
|
||||
<span className={css.rowId}>{pluginId}</span>
|
||||
<span className={css.rowName}>{name}</span>
|
||||
<span className={css.rowStatus}>{t(STATUS_LABELS[status])}</span>
|
||||
</div>
|
||||
{listed !== undefined && listed.packages.length > 1 && selectedPackageId !== undefined && (
|
||||
<label className={css.versionPicker}>
|
||||
<span>{t('panel.version')}</span>
|
||||
<select
|
||||
value={selectedPackageId}
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
setSelected(currentSelected => ({
|
||||
...currentSelected,
|
||||
[pluginId]: event.target.value as CordisDynamicPackageId,
|
||||
}))
|
||||
}}
|
||||
>
|
||||
{listed.packages.map(pkg => (
|
||||
<option key={pkg.packageId} value={pkg.packageId}>{`${pkg.name} · ${pkg.packageId}`}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className={css.rowDetail}>
|
||||
<span className={css.rowPurpose}>{purpose}</span>
|
||||
<div className={css.rowActions}>
|
||||
{awaiting !== undefined && (
|
||||
<>
|
||||
<RowAction
|
||||
label={t('action.approveOnce')}
|
||||
data-cordis-approve={awaiting}
|
||||
disabled={busy}
|
||||
onClick={() => { void runAction(pluginId, async () => {
|
||||
await onApprove(awaiting, false)
|
||||
setOpen(false)
|
||||
}) }}
|
||||
>
|
||||
<IconCheckOutline16 size={14} />
|
||||
</RowAction>
|
||||
<RowAction
|
||||
label={t('action.approvePlugin')}
|
||||
data-cordis-approve-plugin={awaiting}
|
||||
disabled={busy}
|
||||
onClick={() => { void runAction(pluginId, async () => {
|
||||
await onApprove(awaiting, true)
|
||||
setOpen(false)
|
||||
}) }}
|
||||
>
|
||||
<DoubleCheckIcon />
|
||||
</RowAction>
|
||||
<RowAction
|
||||
label={t('action.decline')}
|
||||
data-cordis-decline={awaiting}
|
||||
disabled={busy}
|
||||
onClick={() => { void runAction(pluginId, async () => {
|
||||
await onDecline(awaiting)
|
||||
setOpen(false)
|
||||
}) }}
|
||||
>
|
||||
<IconCloseOutline16 size={14} />
|
||||
</RowAction>
|
||||
</>
|
||||
)}
|
||||
{awaiting === undefined && listed !== undefined
|
||||
&& selectedPackageId !== undefined && listed.activeRun === undefined && (
|
||||
<RowAction
|
||||
label={t('action.run')}
|
||||
data-cordis-switch="run"
|
||||
disabled={busy}
|
||||
onClick={() => { void runAction(pluginId, () => onRun({
|
||||
agentId: listed.agentId,
|
||||
pluginId,
|
||||
packageId: selectedPackageId,
|
||||
mode: runMode,
|
||||
hasClientHalf: selectedPackage?.hasClientHalf === true,
|
||||
})) }}
|
||||
>
|
||||
<IconPlayOutline16 size={14} />
|
||||
</RowAction>
|
||||
)}
|
||||
{awaiting === undefined && listed !== undefined && listed.activeRun !== undefined
|
||||
&& selectedPackageId !== listed.activeRun.packageId && selectedPackage !== undefined && (
|
||||
<RowAction
|
||||
label={t('action.run')}
|
||||
data-cordis-switch="run"
|
||||
disabled={busy}
|
||||
onClick={() => { void runAction(pluginId, () => onRun({
|
||||
agentId: listed.agentId,
|
||||
pluginId,
|
||||
packageId: selectedPackageId!,
|
||||
mode: runMode,
|
||||
hasClientHalf: selectedPackage.hasClientHalf,
|
||||
})) }}
|
||||
>
|
||||
<IconPlayOutline16 size={14} />
|
||||
</RowAction>
|
||||
)}
|
||||
{awaiting === undefined && listed !== undefined && listed.activeRun !== undefined && status === 'client-pending'
|
||||
&& activePackage !== undefined && selectedPackageId === listed.activeRun.packageId && (
|
||||
<RowAction
|
||||
label={t('action.run')}
|
||||
data-cordis-switch="run"
|
||||
disabled={busy}
|
||||
onClick={() => { void runAction(pluginId, () => onRun({
|
||||
agentId: listed.agentId,
|
||||
pluginId,
|
||||
packageId: activePackage.packageId,
|
||||
mode: 'run',
|
||||
hasClientHalf: true,
|
||||
})) }}
|
||||
>
|
||||
<IconPlayOutline16 size={14} />
|
||||
</RowAction>
|
||||
)}
|
||||
{awaiting === undefined && listed !== undefined && listed.activeRun !== undefined && (
|
||||
<RowAction
|
||||
label={t('action.stop')}
|
||||
data-cordis-switch="stop"
|
||||
disabled={busy}
|
||||
onClick={() => { void runAction(pluginId, () => onStop(listed.agentId, pluginId)) }}
|
||||
>
|
||||
<IconStopFill16 size={14} />
|
||||
</RowAction>
|
||||
)}
|
||||
{awaiting === undefined && listed !== undefined && (
|
||||
<RowAction
|
||||
label={t('action.remove')}
|
||||
data-cordis-remove={pluginId}
|
||||
disabled={busy}
|
||||
onClick={() => { void runAction(pluginId, () => onRemove(listed.agentId, pluginId)) }}
|
||||
>
|
||||
<IconTrashOutline16 size={14} />
|
||||
</RowAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{awaiting === undefined && hasFailedTransition && listed !== undefined && (
|
||||
<div className={css.transition}>
|
||||
<span>{listed.currentPackageId === undefined ? '' : t('panel.current', { packageId: listed.currentPackageId })}</span>
|
||||
<span>{t('panel.next', { packageId: listed.nextPackageId! })}</span>
|
||||
<div className={css.transitionActions}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => { void runAction(pluginId, () => onRun({
|
||||
agentId: listed.agentId,
|
||||
pluginId,
|
||||
packageId: listed.nextPackageId!,
|
||||
mode: listed.currentPackageId === undefined ? 'run' : 'update',
|
||||
hasClientHalf: packageOf(listed, listed.nextPackageId!)?.hasClientHalf === true,
|
||||
})) }}
|
||||
>{t('action.retry')}</button>
|
||||
{listed.currentPackageId !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => { void runAction(pluginId, () => onRun({
|
||||
agentId: listed.agentId,
|
||||
pluginId,
|
||||
packageId: listed.currentPackageId!,
|
||||
mode: 'run',
|
||||
hasClientHalf: packageOf(listed, listed.currentPackageId!)?.hasClientHalf === true,
|
||||
})) }}
|
||||
>{t('action.rollback')}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{failure !== undefined && (
|
||||
<div className={css.rowError} role="alert">{`${failure.message} (${failure.reason})`}</div>
|
||||
)}
|
||||
{failure === undefined && hostFailure !== undefined && (
|
||||
<div className={css.rowError} role="alert">{`${hostFailure.message} (${hostFailure.phase})`}</div>
|
||||
)}
|
||||
{actionError !== undefined && <div className={css.rowError} role="alert">{actionError}</div>}
|
||||
{renderFailure !== undefined && (
|
||||
<div
|
||||
className={css.rowError}
|
||||
role="alert"
|
||||
data-cordis-render-failure={renderFailure.slot}
|
||||
data-cordis-render-abdicated={renderFailure.abdicated || undefined}
|
||||
>
|
||||
{`${t(RENDER_FAILURE_LABELS[renderFailure.abdicated ? 'abdicated' : 'held'], {
|
||||
slot: renderFailure.slot,
|
||||
})} ${renderFailure.message}`}
|
||||
</div>
|
||||
)}
|
||||
{activePackage !== undefined && activePackage.packageId !== selectedPackageId && (
|
||||
<span className={css.activeVersion}>{`${t('status.running')}: ${activePackage.name} · ${activePackage.packageId}`}</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={wide ? css.layer : `${css.layer} ${css.rail}`}>
|
||||
{open && (
|
||||
<section className={css.panel} data-cordis-panel aria-label={t('panel.title')}>
|
||||
<header className={css.header}>
|
||||
<span className={css.title}>{t('panel.title')}</span>
|
||||
</header>
|
||||
<div className={css.body}>
|
||||
{inventory.error !== undefined && (
|
||||
<p className={css.readError} role="alert">{t('panel.readFailed', { message: inventory.error })}</p>
|
||||
)}
|
||||
{!inventory.read && inventory.error === undefined && <p className={css.note}>{t('panel.loading')}</p>}
|
||||
{inventory.read && all.length === 0 && <p className={css.note}>{t('panel.empty')}</p>}
|
||||
{mine.length > 0 && (
|
||||
<section>
|
||||
<h3 className={css.group}>{t('panel.group.current')}</h3>
|
||||
<ul className={css.rows}>{mine.map(renderRow)}</ul>
|
||||
</section>
|
||||
)}
|
||||
{theirs.length > 0 && (
|
||||
<section>
|
||||
<h3 className={css.group}>{t('panel.group.others')}</h3>
|
||||
<ul className={css.rows}>{theirs.map(renderRow)}</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<div className={css.footerButtons}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.badge}
|
||||
data-cordis-badge={all.length}
|
||||
data-cordis-approval-badge={approvals}
|
||||
data-active={approvals > 0 || undefined}
|
||||
aria-label={t('panel.plugins.aria')}
|
||||
aria-expanded={open}
|
||||
onClick={() => { setOpen(value => !value) }}
|
||||
>
|
||||
<IconCordisPluginOutline14 />
|
||||
{wide && (
|
||||
<>
|
||||
<span className={css.badgeLabel}>{t('panel.trigger')}</span>
|
||||
<span className={css.badgeCount}>{t('panel.runningCount', { count: running })}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
132
packages/extensions/ui-cordis/src/client/CordisRunRow.module.css
Normal file
132
packages/extensions/ui-cordis/src/client/CordisRunRow.module.css
Normal file
@@ -0,0 +1,132 @@
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
margin-right: 8px;
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
margin: 0 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.summary,
|
||||
.error {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.status {
|
||||
flex: none;
|
||||
margin-left: 8px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.card[data-cordis-status='awaiting-approval'] .status,
|
||||
.card[data-cordis-status='client-pending'] .status {
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
}
|
||||
|
||||
.card[data-cordis-status='running'] .status {
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.card[data-cordis-status='failed'] .status {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.inspect {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-left: 4px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.card:hover .inspect,
|
||||
.inspect:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.inspect:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-button-ghost-active-fill);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.business {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
.output {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-markdown-code-block-small);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.business .output {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
135
packages/extensions/ui-cordis/src/client/CordisRunRow.tsx
Normal file
135
packages/extensions/ui-cordis/src/client/CordisRunRow.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
/** `cordis_run` card and the host seat for Package-owned interactive UI. */
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import {
|
||||
IconCodeOutline16, IconInspectOutline12, StateDot,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
|
||||
import { cordisRunCard } from './card-model.ts'
|
||||
import { cordisToolViewKey } from './run-card-index.ts'
|
||||
import type { CordisRunCardFace } from './slots.ts'
|
||||
import { cordisVisibleStatus, type CordisVisibleStatus } from './status.ts'
|
||||
import type { CordisKey } from './locales.ts'
|
||||
import css from './CordisRunRow.module.css'
|
||||
|
||||
/** Full Run-card props including its declared Package business-view child slot. */
|
||||
export type CordisRunRowProps = ToolCallViewProps
|
||||
& InjectFace<CordisRunCardFace>
|
||||
& PropsRenderSlots<'tool.view.cordis'>
|
||||
& PropsLocale<'cordis'>
|
||||
|
||||
type RunReading = CordisVisibleStatus | 'awaiting-approval' | 'failed' | 'removed' | 'superseded'
|
||||
|
||||
const READING_LABELS = {
|
||||
idle: 'status.idle',
|
||||
'awaiting-approval': 'status.awaitingApproval',
|
||||
failed: 'status.failed',
|
||||
'client-pending': 'status.clientPending',
|
||||
running: 'status.running',
|
||||
removed: 'status.removed',
|
||||
superseded: 'status.superseded',
|
||||
} as const satisfies Record<RunReading, CordisKey>
|
||||
|
||||
/** Render one activation result and, when eligible, its Package-owned view. */
|
||||
export function CordisRunRow({
|
||||
callId, block, inspect, renderSlot, useInventory, useLoaded, useRunCards, useActiveRuns,
|
||||
onObserveRunCard, t,
|
||||
}: CordisRunRowProps) {
|
||||
const card = cordisRunCard(block)
|
||||
const inventory = useInventory(snapshot => snapshot)
|
||||
const loaded = useLoaded(snapshot => snapshot)
|
||||
const latest = useRunCards(snapshot => snapshot)
|
||||
const activeRuns = useActiveRuns(snapshot => snapshot)
|
||||
const successful = card.state === 'ok'
|
||||
&& card.pluginId !== null
|
||||
&& card.packageId !== null
|
||||
&& card.pluginRunId !== null
|
||||
&& card.seq !== null
|
||||
const key = successful ? cordisToolViewKey(card.pluginId!, card.packageId!) : null
|
||||
|
||||
useEffect(() => {
|
||||
if (!successful || key === null) return
|
||||
onObserveRunCard({ key, callId, seq: card.seq!, pluginRunId: card.pluginRunId! })
|
||||
}, [callId, card.pluginRunId, card.seq, key, onObserveRunCard, successful])
|
||||
|
||||
const row = card.pluginId === null
|
||||
? undefined
|
||||
: inventory.rows.find(candidate => candidate.pluginId === card.pluginId)
|
||||
const pointer = key === null ? undefined : latest.get(key)
|
||||
const superseded = pointer !== undefined && pointer.callId !== callId && pointer.seq >= (card.seq ?? -1)
|
||||
const activity = card.pluginId === null ? undefined : activeRuns.get(card.pluginId)
|
||||
const attempt = card.pluginRunId !== null && row?.latestRun?.pluginRunId === card.pluginRunId
|
||||
? row.latestRun
|
||||
: undefined
|
||||
const awaitingApproval = attempt?.status === 'awaiting-approval' || (card.packageId !== null
|
||||
&& activity?.phase === 'awaiting-approval'
|
||||
&& activity.packageId === card.packageId
|
||||
&& (card.mode === null || activity.mode === card.mode))
|
||||
const reading: RunReading = card.pluginId !== null && inventory.removed.has(card.pluginId)
|
||||
? 'removed'
|
||||
: superseded
|
||||
? 'superseded'
|
||||
: awaitingApproval
|
||||
? 'awaiting-approval'
|
||||
: attempt?.status === 'failed'
|
||||
? 'failed'
|
||||
: row !== undefined && card.packageId !== null
|
||||
? cordisVisibleStatus(row, card.packageId, loaded)
|
||||
: 'idle'
|
||||
const status = t(READING_LABELS[reading])
|
||||
const summary = card.errorSummary
|
||||
?? (card.pluginId === null ? callId : `${card.pluginId}${card.packageId === null ? '' : ` · ${card.packageId}`}`)
|
||||
const showBusiness = successful && reading === 'running' && key !== null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css.card}
|
||||
data-tool="cordis_run"
|
||||
data-state={card.state}
|
||||
data-cordis-plugin-id={card.pluginId ?? undefined}
|
||||
data-cordis-package-id={card.packageId ?? undefined}
|
||||
data-cordis-run-id={card.pluginRunId ?? undefined}
|
||||
data-cordis-status={reading}
|
||||
>
|
||||
<div className={css.row}>
|
||||
<span className={css.icon}>
|
||||
{card.state === 'error'
|
||||
? <StateDot state="error" />
|
||||
: card.state === 'stopped'
|
||||
? <StateDot state="warning" />
|
||||
: <IconCodeOutline16 size={14} />}
|
||||
</span>
|
||||
<span className={css.title}>{t(card.mode === 'update' ? 'row.updateTitle' : 'row.runTitle')}</span>
|
||||
<span className={css.separator} aria-hidden />
|
||||
<span className={card.errorSummary === null ? css.summary : css.error}>{summary}</span>
|
||||
<span className={css.status}>{status}</span>
|
||||
{inspect !== undefined && (
|
||||
<button type="button" className={css.inspect} aria-label="Inspect" onClick={inspect}>
|
||||
<IconInspectOutline12 />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{reading === 'removed' && <div className={css.message}>{t('run.removed')}</div>}
|
||||
{reading === 'superseded' && <div className={css.message}>{t('run.superseded')}</div>}
|
||||
{reading === 'failed' && attempt?.error !== undefined && (
|
||||
<div className={css.message}>{attempt.error.message}</div>
|
||||
)}
|
||||
{showBusiness && card.pluginId !== null && card.packageId !== null && card.pluginRunId !== null && (
|
||||
<div className={css.business} data-cordis-business-view={key ?? undefined}>
|
||||
{renderSlot('tool.view.cordis', {
|
||||
pluginId: card.pluginId,
|
||||
packageId: card.packageId,
|
||||
pluginRunId: card.pluginRunId,
|
||||
}, {
|
||||
entryKey: key ?? undefined,
|
||||
fallback: card.output === null ? null : <pre className={css.output}>{card.output}</pre>,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!showBusiness && reading !== 'removed' && reading !== 'superseded' && card.output !== null && (
|
||||
<pre className={css.output}>{card.output}</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
126
packages/extensions/ui-cordis/src/client/card-model.ts
Normal file
126
packages/extensions/ui-cordis/src/client/card-model.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/** Replay-stable view models for `cordis_define` and `cordis_run` calls. */
|
||||
|
||||
import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
|
||||
import type {
|
||||
CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, CordisDynamicRunMode,
|
||||
} from './events.ts'
|
||||
|
||||
type Block = ToolCallViewProps['block']
|
||||
|
||||
/** Lifecycle of the tool call itself. */
|
||||
export type CordisToolState = 'running' | 'ok' | 'error' | 'stopped'
|
||||
|
||||
/** Frozen `cordis_define` presentation data. */
|
||||
export interface CordisDefineCard {
|
||||
readonly pluginId: CordisDynamicPluginId | null
|
||||
readonly packageId: CordisDynamicPackageId | null
|
||||
readonly name: string | null
|
||||
readonly purpose: string | null
|
||||
readonly hostCode: string | null
|
||||
readonly clientCode: string | null
|
||||
readonly output: string | null
|
||||
readonly errorSummary: string | null
|
||||
readonly state: CordisToolState
|
||||
}
|
||||
|
||||
/** Frozen `cordis_run` presentation data. */
|
||||
export interface CordisRunCard {
|
||||
readonly pluginId: CordisDynamicPluginId | null
|
||||
readonly packageId: CordisDynamicPackageId | null
|
||||
readonly pluginRunId: CordisDynamicPluginRunId | null
|
||||
readonly mode: CordisDynamicRunMode | null
|
||||
readonly seq: number | null
|
||||
readonly output: string | null
|
||||
readonly errorSummary: string | null
|
||||
readonly state: CordisToolState
|
||||
}
|
||||
|
||||
function firstLine(text: string): string {
|
||||
const newline = text.indexOf('\n')
|
||||
return newline === -1 ? text : text.slice(0, newline)
|
||||
}
|
||||
|
||||
function stringAt(source: Record<string, unknown>, key: string): string | null {
|
||||
const value = source[key]
|
||||
return typeof value === 'string' && value !== '' ? value : null
|
||||
}
|
||||
|
||||
function objectAt(source: Record<string, unknown>, key: string): Record<string, unknown> | null {
|
||||
const value = source[key]
|
||||
return typeof value === 'object' && value !== null ? value as Record<string, unknown> : null
|
||||
}
|
||||
|
||||
function parseArgs(argsRaw: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(argsRaw) as unknown
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : null
|
||||
} catch {
|
||||
// Running calls can expose a truncated JSON prefix.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function resultText(block: Extract<Block, { kind: 'tool-result' }>): string | null {
|
||||
const text = block.content
|
||||
.map(item => item.type === 'text' ? item.text : JSON.stringify(item, null, 2))
|
||||
.join('\n')
|
||||
if (text !== '') return text
|
||||
return block.error === undefined ? null : `${block.error.name}: ${block.error.code}`
|
||||
}
|
||||
|
||||
function stateOf(block: Block): CordisToolState {
|
||||
if (!('kind' in block)) return 'running'
|
||||
if (block.error?.code === 'interrupted') return 'stopped'
|
||||
return block.isError ? 'error' : 'ok'
|
||||
}
|
||||
|
||||
function metaObject(block: Block): Record<string, unknown> | null {
|
||||
if (!('kind' in block) || block.isError || typeof block.meta !== 'object' || block.meta === null) return null
|
||||
return block.meta as Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Derive one Define card from its frozen call/result slice. */
|
||||
export function cordisDefineCard(block: Block): CordisDefineCard {
|
||||
const settled = 'kind' in block
|
||||
const argsRaw = (settled ? block.call?.argsRaw : block.argsRaw) ?? ''
|
||||
const args = parseArgs(argsRaw)
|
||||
const code = args === null ? null : objectAt(args, 'code')
|
||||
const state = stateOf(block)
|
||||
const output = settled ? resultText(block) : null
|
||||
const meta = metaObject(block)
|
||||
const rawName = argsRaw === '' ? null : firstLine(argsRaw)
|
||||
return {
|
||||
pluginId: meta === null ? null : stringAt(meta, 'pluginId') as CordisDynamicPluginId | null,
|
||||
packageId: meta === null ? null : stringAt(meta, 'packageId') as CordisDynamicPackageId | null,
|
||||
name: args === null ? rawName : stringAt(args, 'name') ?? rawName,
|
||||
purpose: args === null ? null : stringAt(args, 'purpose'),
|
||||
hostCode: code === null ? null : stringAt(code, 'host'),
|
||||
clientCode: code === null ? null : stringAt(code, 'client'),
|
||||
output,
|
||||
errorSummary: state === 'error' && output !== null ? firstLine(output) : null,
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
/** Derive one Run card and its successful activation metadata. */
|
||||
export function cordisRunCard(block: Block): CordisRunCard {
|
||||
const settled = 'kind' in block
|
||||
const argsRaw = (settled ? block.call?.argsRaw : block.argsRaw) ?? ''
|
||||
const args = parseArgs(argsRaw)
|
||||
const meta = metaObject(block)
|
||||
const state = stateOf(block)
|
||||
const output = settled ? resultText(block) : null
|
||||
const rawMode = args === null ? null : stringAt(args, 'mode')
|
||||
const argsPluginId = args === null ? null : stringAt(args, 'pluginId')
|
||||
const argsPackageId = args === null ? null : stringAt(args, 'packageId')
|
||||
return {
|
||||
pluginId: (meta === null ? argsPluginId : stringAt(meta, 'pluginId') ?? argsPluginId) as CordisDynamicPluginId | null,
|
||||
packageId: (meta === null ? argsPackageId : stringAt(meta, 'packageId') ?? argsPackageId) as CordisDynamicPackageId | null,
|
||||
pluginRunId: (meta === null ? null : stringAt(meta, 'pluginRunId')) as CordisDynamicPluginRunId | null,
|
||||
mode: rawMode === 'run' || rawMode === 'update' ? rawMode : null,
|
||||
seq: settled ? block.seq : null,
|
||||
output,
|
||||
errorSummary: state === 'error' && output !== null ? firstLine(output) : null,
|
||||
state,
|
||||
}
|
||||
}
|
||||
24
packages/extensions/ui-cordis/src/client/dynamic-port.ts
Normal file
24
packages/extensions/ui-cordis/src/client/dynamic-port.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/** Host operations used directly by the frame-wide Cordis panel. */
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
CordisDynamicPluginId, DynamicCordisInventoryRow,
|
||||
} from './events.ts'
|
||||
|
||||
/** Result of a panel lifecycle gesture. */
|
||||
export type CordisActionResult =
|
||||
| { readonly ok: true }
|
||||
| { readonly ok: false; readonly message: string }
|
||||
|
||||
/** RPC seam kept outside the React surface. */
|
||||
export interface CordisDynamicPort {
|
||||
/** Stop one Plugin while retaining its immutable Packages. */
|
||||
stop(sessionId: SessionId, pluginId: CordisDynamicPluginId): Promise<CordisActionResult>
|
||||
/** Stop and remove one Plugin together with every Package. */
|
||||
remove(sessionId: SessionId, pluginId: CordisDynamicPluginId): Promise<CordisActionResult>
|
||||
/** Read the frame-wide Plugin inventory. */
|
||||
inventory(): Promise<readonly DynamicCordisInventoryRow[]>
|
||||
}
|
||||
|
||||
/** One stable Plugin row as the panel receives it. */
|
||||
export type CordisInventoryRow = DynamicCordisInventoryRow
|
||||
17
packages/extensions/ui-cordis/src/client/events.ts
Normal file
17
packages/extensions/ui-cordis/src/client/events.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/** Client-safe dynamic Cordis vocabulary re-exported through the Remote assembly. */
|
||||
|
||||
// Type-only: merges `ctx.remote` and the forwarded-event key set into this program.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
|
||||
export type {
|
||||
ApprovalRequestId,
|
||||
CordisDynamicPackageId,
|
||||
CordisDynamicPluginId,
|
||||
CordisDynamicPluginRunId,
|
||||
CordisDynamicRunMode,
|
||||
DynamicCordisInventoryRow,
|
||||
DynamicCordisPackage,
|
||||
DynamicCordisRequestResolved,
|
||||
DynamicCordisRetracted,
|
||||
DynamicCordisRunRequest,
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
160
packages/extensions/ui-cordis/src/client/index.ts
Normal file
160
packages/extensions/ui-cordis/src/client/index.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/** Cordis dynamic-plugin cards, inventory panel, business-view host, and `@pluginId` source. */
|
||||
|
||||
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-tool/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type {} from './events.ts'
|
||||
import { CordisDefineRow } from './CordisDefineRow.tsx'
|
||||
import { CordisRunRow } from './CordisRunRow.tsx'
|
||||
import { CordisPanel } from './CordisPanel.tsx'
|
||||
import { createCordisInventory } from './inventory.ts'
|
||||
import { CordisRunCardRegistry } from './run-card-index.ts'
|
||||
import type { CordisDynamicPort } from './dynamic-port.ts'
|
||||
import type { CordisCardFace, CordisPanelFace, CordisRunCardFace } from './slots.ts'
|
||||
import { en, NS, zh } from './locales.ts'
|
||||
|
||||
export type { CordisCardFace, CordisPanelFace, CordisRunCardFace, CordisToolViewOwnerProps } from './slots.ts'
|
||||
export type { CordisActionResult, CordisDynamicPort, CordisInventoryRow } from './dynamic-port.ts'
|
||||
export type { CordisDefineRowProps } from './CordisDefineRow.tsx'
|
||||
export type { CordisRunRowProps } from './CordisRunRow.tsx'
|
||||
export type {
|
||||
CordisRunCardPointer, CordisRunCardStore, CordisToolViewKey,
|
||||
} from './run-card-index.ts'
|
||||
export type {
|
||||
ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId,
|
||||
DynamicCordisInventoryRow, DynamicCordisPackage, DynamicCordisRetracted,
|
||||
} from './events.ts'
|
||||
export type { CordisKey } from './locales.ts'
|
||||
|
||||
/** Required services for the two Tool cards, panel, Remote lifecycle, and Slash source. */
|
||||
export const inject = [
|
||||
'slots', 'locale', 'slash', 'remote', 'remote.dynamicCordisRunner', 'dynamicCordisRunner',
|
||||
]
|
||||
|
||||
/** Mount every Cordis browser surface over the shared Host inventory. */
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-cordis: dictionaries')
|
||||
|
||||
const port: CordisDynamicPort = {
|
||||
stop: async (sessionId, pluginId) => {
|
||||
const answered = await ctx.remote.dynamicCordisRunner.stopFromPanel(sessionId, pluginId)
|
||||
if (!answered.ok) return { ok: false, message: `${answered.error.code}: ${answered.error.message}` }
|
||||
if (answered.value.ok || answered.value.reason === 'not-running') return { ok: true }
|
||||
return { ok: false, message: answered.value.message }
|
||||
},
|
||||
remove: async (sessionId, pluginId) => {
|
||||
const answered = await ctx.remote.dynamicCordisRunner.undefineFromPanel(sessionId, pluginId)
|
||||
if (!answered.ok) return { ok: false, message: `${answered.error.code}: ${answered.error.message}` }
|
||||
return answered.value.ok ? { ok: true } : { ok: false, message: answered.value.message }
|
||||
},
|
||||
inventory: async () => {
|
||||
const answered = await ctx.remote.dynamicCordisRunner.inventory()
|
||||
if (!answered.ok) throw new Error(`${answered.error.code}: ${answered.error.message}`)
|
||||
return answered.value
|
||||
},
|
||||
}
|
||||
const inventory = createCordisInventory(port, (error) => {
|
||||
console.error('[ui-cordis] reading the Cordis inventory failed:', error)
|
||||
})
|
||||
const runner = ctx.dynamicCordisRunner
|
||||
const loaded = { getSnapshot: () => runner.getSnapshot(), subscribe: (fn: () => void) => runner.subscribe(fn) }
|
||||
const runCards = new CordisRunCardRegistry()
|
||||
|
||||
ctx.effect(() => inventory.subscribe(() => {
|
||||
const snapshot = inventory.getSnapshot()
|
||||
if (snapshot.read) runner.reconcileApprovals(snapshot.rows)
|
||||
}), 'ui-cordis: reconcile pending approvals')
|
||||
|
||||
ctx.remote.$on('cordis/dynamic-package', () => { inventory.refresh() })
|
||||
ctx.remote.$on('cordis/dynamic-retract', () => { inventory.refresh() })
|
||||
ctx.remote.$on('cordis/request-run', (request) => {
|
||||
if (!inventory.getSnapshot().rows.some(row => row.pluginId === request.pluginId)) inventory.refresh()
|
||||
})
|
||||
ctx.remote.$on('cordis/request-run-resolved', () => { inventory.refresh() })
|
||||
ctx.on('connection/reset', () => {
|
||||
inventory.reset()
|
||||
inventory.refresh()
|
||||
})
|
||||
|
||||
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
|
||||
name: 'sidebar.footer.action',
|
||||
id: 'cordis-panel',
|
||||
locale: NS,
|
||||
inject: (): CordisPanelFace => ({
|
||||
hooks: {
|
||||
inventory,
|
||||
activeRuns: runner.activeRuns,
|
||||
runErrors: runner.lastRunError,
|
||||
loaded,
|
||||
renderFailures: runner.renderFailures,
|
||||
},
|
||||
onApprove: (requestId, approveFutureVersions) => runner.approve(requestId, approveFutureVersions),
|
||||
onDecline: requestId => runner.decline(requestId),
|
||||
onRun: request => runner.startUserRun(request),
|
||||
onStop: async (sessionId, pluginId) => {
|
||||
const result = await port.stop(sessionId, pluginId)
|
||||
inventory.refresh()
|
||||
return result
|
||||
},
|
||||
onRemove: async (sessionId, pluginId) => {
|
||||
const result = await port.remove(sessionId, pluginId)
|
||||
if (result.ok) inventory.retire(pluginId)
|
||||
inventory.refresh()
|
||||
return result
|
||||
},
|
||||
onRefresh: () => { inventory.refresh() },
|
||||
}),
|
||||
}, CordisPanel))
|
||||
|
||||
const cardFace = (): CordisCardFace => ({ hooks: { inventory, loaded } })
|
||||
ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
|
||||
name: 'tool.call.toolview',
|
||||
key: 'cordis_define',
|
||||
locale: NS,
|
||||
inject: cardFace,
|
||||
}, CordisDefineRow))
|
||||
|
||||
ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
|
||||
name: 'tool.call.toolview',
|
||||
key: 'cordis_run',
|
||||
locale: NS,
|
||||
children: { 'tool.view.cordis': { kind: 'keyed', scope: 'session' } },
|
||||
inject: (sessionId: SessionId): CordisRunCardFace => {
|
||||
const store = runCards.forSession(sessionId)
|
||||
return {
|
||||
hooks: { inventory, loaded, runCards: store, activeRuns: runner.activeRuns },
|
||||
onObserveRunCard: pointer => { store.observe(pointer) },
|
||||
}
|
||||
},
|
||||
}, CordisRunRow))
|
||||
|
||||
const rowsOf = (sessionId: SessionId, query: string) => inventory.getSnapshot().rows
|
||||
.filter(row => row.agentId === sessionId && String(row.pluginId).includes(query))
|
||||
const source: SlashSource = {
|
||||
trigger: '@',
|
||||
name: 'cordis',
|
||||
order: 1,
|
||||
candidates(session, { query }) {
|
||||
const rows = rowsOf(session.sessionId, query)
|
||||
return Promise.resolve(rows.map(row => {
|
||||
const packageId = row.nextPackageId ?? row.currentPackageId ?? row.packages.at(-1)?.packageId
|
||||
const pkg = packageId === undefined ? undefined : row.packages.find(candidate => candidate.packageId === packageId)
|
||||
return {
|
||||
name: String(row.pluginId),
|
||||
...pkg === undefined ? {} : { description: pkg.purpose },
|
||||
}
|
||||
}))
|
||||
},
|
||||
warm() { inventory.refresh() },
|
||||
lexicon(session) { return rowsOf(session.sessionId, '').map(row => String(row.pluginId)) },
|
||||
subscribeLexicon(_session, listener) { return inventory.subscribe(listener) },
|
||||
onPick({ candidate }) { return { text: `@${candidate.name} ` } },
|
||||
}
|
||||
const slash = ctx.get('slash') as SlashServiceContract
|
||||
ctx.effect(() => slash.registerSource(source), 'ui-cordis: @pluginId source')
|
||||
|
||||
inventory.refresh()
|
||||
}
|
||||
113
packages/extensions/ui-cordis/src/client/inventory.ts
Normal file
113
packages/extensions/ui-cordis/src/client/inventory.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* The host's definition registry as this page last read it, owned by the
|
||||
* plugin's apply closure.
|
||||
*
|
||||
* The panel is a frame-wide surface, so it cannot derive this from any session:
|
||||
* the registry is global and the read is a single global call. The rows are
|
||||
* re-read rather than patched, because the wire announcements
|
||||
* (`cordis/dynamic-package` / `/retract`) carry no labels and a definition
|
||||
* can appear or disappear between them — a patch-in-place cache would drift into
|
||||
* showing definitions the host no longer holds.
|
||||
*
|
||||
* Reads are single-flight: several announcements settling at once, or a badge
|
||||
* opening while a reconnect re-reads, must not multiply the call. Single-flight
|
||||
* alone would be wrong across a reconnect, though — the in-flight read belongs to
|
||||
* the previous connection, so a reset both discards its answer and frees the slot
|
||||
* for a fresh one. Without that, a reconnect either loses its re-read to the old
|
||||
* call or has the old host's rows published on top of it.
|
||||
*/
|
||||
|
||||
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { CordisDynamicPort, CordisInventoryRow } from './dynamic-port.ts'
|
||||
import type { CordisDynamicPluginId } from './events.ts'
|
||||
|
||||
/** What the panel reads: the rows, and whether the first read has happened. */
|
||||
export interface CordisInventorySnapshot {
|
||||
readonly rows: readonly CordisInventoryRow[]
|
||||
/** Plugins explicitly removed through this page, retained for historical cards. */
|
||||
readonly removed: ReadonlySet<CordisDynamicPluginId>
|
||||
/**
|
||||
* False until a read settles. The panel shows a loading line rather than an
|
||||
* empty state, so "nothing defined yet" is never claimed before it is known.
|
||||
*/
|
||||
readonly read: boolean
|
||||
/** Last read failure, so the panel can say why it is empty. */
|
||||
readonly error?: string | undefined
|
||||
}
|
||||
|
||||
/** Inventory source: an observable of the rows plus the read trigger. */
|
||||
export interface CordisInventory extends HostObservable<CordisInventorySnapshot> {
|
||||
/** Read the registry unless a read is already in flight. */
|
||||
refresh(): void
|
||||
/** Record an explicit remove and drop the live row immediately. */
|
||||
retire(pluginId: CordisDynamicPluginId): void
|
||||
/** Drop what was read; the next refresh starts from nothing (a reconnect may be a new host). */
|
||||
reset(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the inventory source.
|
||||
* @param port - the RPC seam the read goes through.
|
||||
* @param onError - reporter for a failed read (console in production, captured in specs).
|
||||
* @returns the inventory observable and its read trigger.
|
||||
*/
|
||||
export function createCordisInventory(
|
||||
port: CordisDynamicPort,
|
||||
onError: (error: unknown) => void,
|
||||
): CordisInventory {
|
||||
const listeners = new Set<() => void>()
|
||||
let snapshot: CordisInventorySnapshot = { rows: [], removed: new Set(), read: false }
|
||||
let inFlight: Promise<void> | undefined
|
||||
// Bumped by reset; a read whose generation is stale publishes nothing.
|
||||
let generation = 0
|
||||
|
||||
const publish = (next: CordisInventorySnapshot): void => {
|
||||
snapshot = next
|
||||
for (const listener of [...listeners]) listener()
|
||||
}
|
||||
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe: (fn) => {
|
||||
listeners.add(fn)
|
||||
return () => { listeners.delete(fn) }
|
||||
},
|
||||
refresh: () => {
|
||||
if (inFlight !== undefined) return
|
||||
const issued = generation
|
||||
inFlight = port.inventory().then(
|
||||
(rows) => {
|
||||
if (issued !== generation) return
|
||||
const removed = new Set(snapshot.removed)
|
||||
const live = new Set(rows.map(row => row.pluginId))
|
||||
for (const previous of snapshot.rows) {
|
||||
if (!live.has(previous.pluginId)) removed.add(previous.pluginId)
|
||||
}
|
||||
publish({ rows, removed, read: true })
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (issued !== generation) return
|
||||
onError(error)
|
||||
// A failed read keeps whatever was shown and says why: dropping the
|
||||
// rows would turn a transient wire failure into "nothing is defined".
|
||||
publish({
|
||||
rows: snapshot.rows,
|
||||
removed: snapshot.removed,
|
||||
read: snapshot.read,
|
||||
error: error instanceof Error ? error.message : 'reading the cordis inventory failed',
|
||||
})
|
||||
},
|
||||
).then(() => { if (issued === generation) inFlight = undefined })
|
||||
},
|
||||
retire: (pluginId) => {
|
||||
const removed = new Set(snapshot.removed)
|
||||
removed.add(pluginId)
|
||||
publish({ ...snapshot, rows: snapshot.rows.filter(row => row.pluginId !== pluginId), removed })
|
||||
},
|
||||
reset: () => {
|
||||
generation += 1
|
||||
inFlight = undefined
|
||||
publish({ rows: [], removed: snapshot.removed, read: false })
|
||||
},
|
||||
}
|
||||
}
|
||||
112
packages/extensions/ui-cordis/src/client/locales.ts
Normal file
112
packages/extensions/ui-cordis/src/client/locales.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/** Cordis dynamic-plugin UI dictionaries. */
|
||||
|
||||
export const NS = 'cordis'
|
||||
|
||||
export const zh = {
|
||||
'row.defineTitle': '注册 Cordis 插件',
|
||||
'row.runTitle': '运行 Cordis 插件',
|
||||
'row.updateTitle': '更新 Cordis 插件',
|
||||
'purpose.missing': '(未填写用途)',
|
||||
'status.idle': '待激活',
|
||||
'status.awaitingApproval': '待审批',
|
||||
'status.failed': '运行失败',
|
||||
'status.clientPending': 'Client 待激活',
|
||||
'status.running': '运行中',
|
||||
'status.removed': '已移除',
|
||||
'status.superseded': '已有更新',
|
||||
'run.removed': '包已不存在',
|
||||
'run.superseded': '已有更新的运行卡片,请查看下方',
|
||||
'panel.hint': '运行控制在左下角设置上方的 Cordis 面板',
|
||||
'panel.plugins.aria': 'Cordis 插件',
|
||||
'panel.approvals.aria': 'Cordis 审批',
|
||||
'panel.trigger': 'Cordis Plugin',
|
||||
'panel.runningCount': '{count} running',
|
||||
'panel.title': 'Cordis 插件',
|
||||
'panel.empty': '还没有定义任何插件',
|
||||
'panel.loading': '读取中…',
|
||||
'panel.readFailed': '读取插件清单失败:{message}',
|
||||
'panel.group.current': '当前会话',
|
||||
'panel.group.others': '其他会话',
|
||||
'panel.version': '版本',
|
||||
'panel.current': '当前:{packageId}',
|
||||
'panel.next': '待切换:{packageId}',
|
||||
'action.approve': '允许',
|
||||
'action.approveOnce': '仅允许此版本',
|
||||
'action.approvePlugin': '允许此插件的后续版本',
|
||||
'action.decline': '拒绝',
|
||||
'action.run': '运行',
|
||||
'action.stop': '停止',
|
||||
'action.remove': '移除',
|
||||
'action.retry': '重试',
|
||||
'action.rollback': '回退',
|
||||
'render.failedAbdicated': '{slot} 渲染失败,已恢复默认界面:',
|
||||
'render.failedHeld': '{slot} 渲染失败:',
|
||||
'a11y.defining': '正在定义插件',
|
||||
'a11y.failed': '定义失败',
|
||||
'a11y.stopped': '定义已中断',
|
||||
'body.source': '插件代码',
|
||||
'body.hostCode': 'Host',
|
||||
'body.clientCode': 'Client',
|
||||
'body.output': '结果',
|
||||
'body.copy': '复制',
|
||||
'body.copied': '已复制',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
export type CordisKey = keyof typeof zh
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** Dynamic Cordis UI copy. */
|
||||
cordis: CordisKey
|
||||
}
|
||||
}
|
||||
|
||||
export const en = {
|
||||
'row.defineTitle': 'Define Cordis Plugin',
|
||||
'row.runTitle': 'Run Cordis Plugin',
|
||||
'row.updateTitle': 'Update Cordis Plugin',
|
||||
'purpose.missing': '(no purpose given)',
|
||||
'status.idle': 'Ready',
|
||||
'status.awaitingApproval': 'Awaiting approval',
|
||||
'status.failed': 'Run failed',
|
||||
'status.clientPending': 'Client ready to activate',
|
||||
'status.running': 'Running',
|
||||
'status.removed': 'Removed',
|
||||
'status.superseded': 'Newer run available',
|
||||
'run.removed': 'This package no longer exists',
|
||||
'run.superseded': 'A newer run card is available below',
|
||||
'panel.hint': 'Run controls live in the Cordis panel above Settings',
|
||||
'panel.plugins.aria': 'Cordis plugins',
|
||||
'panel.approvals.aria': 'Cordis approvals',
|
||||
'panel.trigger': 'Cordis Plugin',
|
||||
'panel.runningCount': '{count} running',
|
||||
'panel.title': 'Cordis plugins',
|
||||
'panel.empty': 'No plugins defined yet',
|
||||
'panel.loading': 'Reading…',
|
||||
'panel.readFailed': 'Reading the plugin inventory failed: {message}',
|
||||
'panel.group.current': 'This session',
|
||||
'panel.group.others': 'Other sessions',
|
||||
'panel.version': 'Version',
|
||||
'panel.current': 'Current: {packageId}',
|
||||
'panel.next': 'Next: {packageId}',
|
||||
'action.approve': 'Allow',
|
||||
'action.approveOnce': 'Allow this version only',
|
||||
'action.approvePlugin': 'Allow future versions of this plugin',
|
||||
'action.decline': 'Decline',
|
||||
'action.run': 'Run',
|
||||
'action.stop': 'Stop',
|
||||
'action.remove': 'Remove',
|
||||
'action.retry': 'Retry',
|
||||
'action.rollback': 'Roll back',
|
||||
'render.failedAbdicated': 'Rendering failed in {slot}; the default UI was restored:',
|
||||
'render.failedHeld': 'Rendering failed in {slot}:',
|
||||
'a11y.defining': 'Defining the plugin',
|
||||
'a11y.failed': 'Definition failed',
|
||||
'a11y.stopped': 'Definition interrupted',
|
||||
'body.source': 'Plugin source',
|
||||
'body.hostCode': 'Host',
|
||||
'body.clientCode': 'Client',
|
||||
'body.output': 'Result',
|
||||
'body.copy': 'Copy',
|
||||
'body.copied': 'Copied',
|
||||
} satisfies Record<CordisKey, string>
|
||||
67
packages/extensions/ui-cordis/src/client/run-card-index.ts
Normal file
67
packages/extensions/ui-cordis/src/client/run-card-index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/** Session-local ownership index for Package business views on `cordis_run` cards. */
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId,
|
||||
} from './events.ts'
|
||||
|
||||
/** Stable keyed-slot identity of one Package-owned business view. */
|
||||
export type CordisToolViewKey = `${CordisDynamicPluginId}.${CordisDynamicPackageId}`
|
||||
|
||||
/** One successful tool result competing to host a Package business view. */
|
||||
export interface CordisRunCardPointer {
|
||||
readonly key: CordisToolViewKey
|
||||
readonly callId: string
|
||||
readonly seq: number
|
||||
readonly pluginRunId: CordisDynamicPluginRunId
|
||||
}
|
||||
|
||||
/** Per-session observable index consumed by every mounted Run card. */
|
||||
export interface CordisRunCardStore extends HostObservable<ReadonlyMap<CordisToolViewKey, CordisRunCardPointer>> {
|
||||
/** Publish one successful Run result; only a greater log sequence can replace it. */
|
||||
observe(pointer: CordisRunCardPointer): void
|
||||
}
|
||||
|
||||
function createStore(): CordisRunCardStore {
|
||||
const pointers = new Map<CordisToolViewKey, CordisRunCardPointer>()
|
||||
const listeners = new Set<() => void>()
|
||||
let cache: ReadonlyMap<CordisToolViewKey, CordisRunCardPointer> | undefined
|
||||
return {
|
||||
getSnapshot: () => cache ??= new Map(pointers),
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
},
|
||||
observe: (pointer) => {
|
||||
const current = pointers.get(pointer.key)
|
||||
if (current !== undefined && current.seq >= pointer.seq) return
|
||||
pointers.set(pointer.key, pointer)
|
||||
cache = undefined
|
||||
for (const listener of [...listeners]) listener()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Page-lifetime registry that gives all cards of one session the same Store. */
|
||||
export class CordisRunCardRegistry {
|
||||
private readonly sessions = new Map<SessionId, CordisRunCardStore>()
|
||||
|
||||
/** Return the persistent page-local Store for a session. */
|
||||
forSession(sessionId: SessionId): CordisRunCardStore {
|
||||
let store = this.sessions.get(sessionId)
|
||||
if (store === undefined) {
|
||||
store = createStore()
|
||||
this.sessions.set(sessionId, store)
|
||||
}
|
||||
return store
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the Package business-view key shared by registrations and Run cards. */
|
||||
export function cordisToolViewKey(
|
||||
pluginId: CordisDynamicPluginId,
|
||||
packageId: CordisDynamicPackageId,
|
||||
): CordisToolViewKey {
|
||||
return `${pluginId}.${packageId}` as CordisToolViewKey
|
||||
}
|
||||
67
packages/extensions/ui-cordis/src/client/slots.ts
Normal file
67
packages/extensions/ui-cordis/src/client/slots.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/** Injected faces and the Package-owned `tool.view.cordis` slot declaration. */
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
CordisRunActivity, CordisRunFailure, CordisUserRunRequest, DynamicCordisLivePackage,
|
||||
DynamicCordisRenderFailure,
|
||||
} from '@deepseek-ai/dsh-cordis-client-runner/client'
|
||||
import type { CordisActionResult } from './dynamic-port.ts'
|
||||
import type { CordisInventory } from './inventory.ts'
|
||||
import type { CordisRunCardPointer, CordisRunCardStore } from './run-card-index.ts'
|
||||
import type {
|
||||
ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId,
|
||||
} from './events.ts'
|
||||
|
||||
/** Owner currency delivered to a dynamic Package's business view. */
|
||||
export interface CordisToolViewOwnerProps {
|
||||
readonly pluginId: CordisDynamicPluginId
|
||||
readonly packageId: CordisDynamicPackageId
|
||||
readonly pluginRunId: CordisDynamicPluginRunId
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
/** Package-owned interactive UI hosted by the latest successful Run card. */
|
||||
'tool.view.cordis': {
|
||||
kind: 'keyed'
|
||||
scope: 'session'
|
||||
owner: CordisToolViewOwnerProps
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Live facts used by the read-only Define card. */
|
||||
export interface CordisCardFace {
|
||||
hooks: {
|
||||
inventory: CordisInventory
|
||||
loaded: HostObservable<readonly DynamicCordisLivePackage[]>
|
||||
}
|
||||
}
|
||||
|
||||
/** Live facts used by the Run card and its business-view ownership index. */
|
||||
export interface CordisRunCardFace extends CordisCardFace {
|
||||
hooks: CordisCardFace['hooks'] & {
|
||||
runCards: CordisRunCardStore
|
||||
activeRuns: HostObservable<ReadonlyMap<CordisDynamicPluginId, CordisRunActivity>>
|
||||
}
|
||||
/** Publish this successful result into the session's latest-card index. */
|
||||
onObserveRunCard(pointer: CordisRunCardPointer): void
|
||||
}
|
||||
|
||||
/** Frame-wide panel state and lifecycle verbs. */
|
||||
export interface CordisPanelFace {
|
||||
hooks: {
|
||||
inventory: CordisInventory
|
||||
activeRuns: HostObservable<ReadonlyMap<CordisDynamicPluginId, CordisRunActivity>>
|
||||
runErrors: HostObservable<ReadonlyMap<CordisDynamicPluginId, CordisRunFailure>>
|
||||
renderFailures: HostObservable<ReadonlyMap<CordisDynamicPluginId, DynamicCordisRenderFailure>>
|
||||
loaded: HostObservable<readonly DynamicCordisLivePackage[]>
|
||||
}
|
||||
onApprove(requestId: ApprovalRequestId, approveFutureVersions: boolean): Promise<void>
|
||||
onDecline(requestId: ApprovalRequestId): Promise<void>
|
||||
onRun(request: CordisUserRunRequest): Promise<void>
|
||||
onStop(sessionId: SessionId, pluginId: CordisDynamicPluginId): Promise<CordisActionResult>
|
||||
onRemove(sessionId: SessionId, pluginId: CordisDynamicPluginId): Promise<CordisActionResult>
|
||||
onRefresh(): void
|
||||
}
|
||||
37
packages/extensions/ui-cordis/src/client/status.ts
Normal file
37
packages/extensions/ui-cordis/src/client/status.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/** Shared status derivation over Host inventory and this page's Client live set. */
|
||||
|
||||
import type { DynamicCordisLivePackage } from '@deepseek-ai/dsh-cordis-client-runner/client'
|
||||
import type {
|
||||
CordisDynamicPackageId, DynamicCordisInventoryRow,
|
||||
} from './events.ts'
|
||||
|
||||
/** The three product-visible lifecycle readings. */
|
||||
export type CordisVisibleStatus = 'idle' | 'client-pending' | 'running'
|
||||
|
||||
/** Locate one immutable Package inside a Plugin row. */
|
||||
export function packageOf(row: DynamicCordisInventoryRow, packageId: CordisDynamicPackageId) {
|
||||
return row.packages.find(pkg => pkg.packageId === packageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the visible state of one Package.
|
||||
* @param row - owning Plugin inventory row.
|
||||
* @param packageId - Package being described.
|
||||
* @param loaded - Client activations loaded in this page.
|
||||
* @returns idle, Host-running/Client-pending, or fully running.
|
||||
*/
|
||||
export function cordisVisibleStatus(
|
||||
row: DynamicCordisInventoryRow,
|
||||
packageId: CordisDynamicPackageId,
|
||||
loaded: readonly DynamicCordisLivePackage[],
|
||||
): CordisVisibleStatus {
|
||||
const run = row.activeRun
|
||||
if (run === undefined || run.packageId !== packageId) return 'idle'
|
||||
const pkg = packageOf(row, packageId)
|
||||
if (pkg?.hasClientHalf !== true) return 'running'
|
||||
return loaded.some(live => live.pluginId === row.pluginId
|
||||
&& live.packageId === packageId
|
||||
&& live.pluginRunId === run.pluginRunId)
|
||||
? 'running'
|
||||
: 'client-pending'
|
||||
}
|
||||
6
packages/extensions/ui-cordis/src/css-modules.d.ts
vendored
Normal file
6
packages/extensions/ui-cordis/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
9
packages/extensions/ui-cordis/src/index.ts
Normal file
9
packages/extensions/ui-cordis/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Cordis dynamic-plugin card, node half. Pure UI plugin: the empty apply
|
||||
* exists so the plugin appears in the host cordis.yml / Loader; the browser
|
||||
* half ships via exports["./client"], discovered through the package.json
|
||||
* dshClient declaration.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this surface plugin. */
|
||||
export function apply(): void {}
|
||||
33
packages/extensions/ui-cordis/src/invariant.ts
Normal file
33
packages/extensions/ui-cordis/src/invariant.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-cordis`.
|
||||
* @module @deepseek-ai/dsh-client-ui-cordis/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-cordis'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-cordis-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: a single keyed toolview registration whose disposal is
|
||||
* proven by the HMR-safety spec. The one mutable relation this package owns —
|
||||
* the per-definition run-state observable — lives in the browser process, out
|
||||
* of reach of the host invariant service, and the node half emits no cordis
|
||||
* events and holds no cross-plugin state.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
Reference in New Issue
Block a user