Files
session-delete/client.js
T

680 lines
32 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Browser half of the session-delete bundle.
*
* One row in a conversation's "..." menu and one hover button on the
* conversation's sidebar row open an irreversible-deletion dialog. Confirming
* posts to the Host half's authenticated route; the Host owns every policy
* decision and the Client only renders its answer.
*
* The temporary-file sweep is the plugin's other operation, and it lives on
* this plugin's own page under Settings → Plugins: the entry registers into
* `plugins.bundle.config` under this package's name, the page-level seat the
* manager renders on a bundle's page between its description and its rows.
*
* That page-level seat is deliberate, not the row-level `plugins.row.config`
* one. The row seat grows a configure control on the row listed under
* 「包含的组件」 and nothing else, so it parks this plugin's one and only control
* one click deeper than the page the user just opened; the page seat puts it
* where the page is read.
*
* Two client-authoring rules shape this file:
*
* - No Harness Client module is imported. Those packages change without notice,
* a plain-JavaScript bundle has no type check, and a throwing component blanks
* the whole slot entry, so the menu row, the row button, the dialog, and the
* plugin page's section re-implement the shipped markup, stylesheet
* declarations, and focus/Escape behavior themselves. Class names are renamed
* under `dsd-` and every color, radius, elevation, and motion value stays a
* `--dsw-*` theme-token reference.
* - Nothing reads another package's DOM. An earlier version nudged the row's
* hover preview out of the way by dispatching a synthetic pointer event at a
* `[data-row-key]` ancestor; that is exactly the kind of dependency on a
* self-owned rendering surface a plugin must not take. The tooltip below is
* positioned from its own button alone.
*
* @module @dsh-plugin/session-delete/client
*/
window.__ModuleLoader__.load({
id: '@dsh-plugin/session-delete',
factory(require) {
const React = require('react')
const h = React.createElement
const NS = 'session-delete'
const ROUTE = 'api/plugin/session-delete/delete'
const ORPHAN_ROUTE = 'api/plugin/session-delete/orphans'
/** The shipped menu rows occupy 100–400; this one follows them. */
const MENU_ORDER = 500
/** Between the shipped `archive` (100) and `pin` (200) hover buttons. */
const ROW_ACTION_ORDER = 150
/**
* The key `plugins.bundle.config` is dispatched under: the manager keys a
* bundle's own configuration by its npm package name and renders the entry
* on that bundle's page, between the description and the rows. The value is
* the literal from `package.json` — a typo costs the section silently, so
* it is the only place renaming has to reach.
*/
const PACKAGE = '@dsh-plugin/session-delete'
const TOOLTIP_DELAY_MS = 500
const TOOLTIP_GAP = 8
/** Document base captured at bundle registration, before any routing. */
const BASE = typeof document === 'undefined' ? 'http://127.0.0.1/' : document.baseURI
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
const zh = {
'menu.item': '删除会话',
'actions.delete': '删除会话',
'dialog.title': '永久删除此会话?',
'dialog.body': '“{title}”的会话日志将被彻底删除,无法恢复',
'dialog.note': '工作区中的文件不受影响;该会话的对话内容不会保留',
'dialog.cancel': '取消',
'dialog.confirm': '永久删除',
'dialog.pending': '正在删除…',
'dialog.close': '关闭',
'toast.deleted': '会话及其子智能体会话已彻底删除。',
'toast.deletedSpill': '会话及其子智能体会话已彻底删除,并清理了 {n} 个溢出临时文件',
'toast.scheduled': '该会话仍在本进程中运行,已先停止并归档;它的会话日志会在下次启动 DeepSeek Harness 时删除',
'toast.failed': '删除失败:{message}',
'error.unknown': '未知错误',
'cleanup.title': '清理临时文件',
'cleanup.description': '删除已不存在会话遗留的工具输出溢出文件,不影响现有会话',
'cleanup.action': '清理',
'cleanup.pending': '正在清理…',
'cleanup.empty': '没有发现孤立的临时文件',
'cleanup.done': '已清理 {directories} 个目录、{n} 个文件,释放 {size}',
'cleanup.failed': '清理失败:{message}',
}
const en = {
'menu.item': 'Delete conversation',
'actions.delete': 'Delete conversation',
'dialog.title': 'Delete this conversation permanently?',
'dialog.body': 'The stored log of “{title}” will be permanently deleted. This cannot be undone.',
'dialog.note': 'Files in the workspace are not affected; nothing of this conversation is kept.',
'dialog.cancel': 'Cancel',
'dialog.confirm': 'Delete permanently',
'dialog.pending': 'Deleting…',
'dialog.close': 'Close',
'toast.deleted': 'The conversation and its subagent conversations were permanently deleted.',
'toast.deletedSpill': 'The conversation and its subagent conversations were permanently deleted, and {n} spilled tool-output files were removed.',
'toast.scheduled': 'This conversation is still live in this process. It was stopped and archived; its stored log will be deleted the next time DeepSeek Harness starts.',
'toast.failed': 'Deletion failed: {message}',
'error.unknown': 'unknown error',
'cleanup.title': 'Clean up temporary files',
'cleanup.description': 'Deletes spilled tool-output files left behind by conversations that no longer exist; existing conversations are untouched.',
'cleanup.action': 'Clean up',
'cleanup.pending': 'Cleaning…',
'cleanup.empty': 'No orphaned temporary files were found.',
'cleanup.done': 'Removed {n} files from {directories} directories, freeing {size}.',
'cleanup.failed': 'Cleanup failed: {message}',
}
/**
* Menu-row declarations mirror `dsh-client-ui-primitives`' Menu.module.css
* `.item/.itemIcon/.itemLabel`; dialog declarations mirror its
* Modal.module.css and Button.module.css plus the workspace dialog's
* destructive accent and secondary status lines; the settings row copies
* the General-section row pattern. Class names are renamed under `dsd-`,
* and every color, radius, elevation, and transition stays a
* `--dsw-*`/`--ds-*` token reference.
*/
const CSS = `
.dsd-item{display:flex;align-items:center;gap:6px;width:100%;min-height:34px;padding:6px 8px;border:none;border-radius:var(--dsw-radius-md);background:transparent;cursor:pointer;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);text-align:left}
.dsd-item:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
.dsd-item:focus-visible:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);outline:none}
.dsd-item:disabled{opacity:.4;cursor:not-allowed}
.dsd-item-icon{display:inline-flex;flex:none;width:14px;height:14px;align-items:center;justify-content:center;color:var(--dsw-alias-menu-icon)}
.dsd-item-icon svg{width:14px;height:14px}
.dsd-item-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dsd-row-button{border-radius:var(--dsw-radius-xs);cursor:pointer;width:16px;height:16px;color:var(--dsw-alias-label-tertiary);background:0 0;border:none;flex:none;justify-content:center;align-items:center;padding:0;display:inline-flex}
.dsd-row-button:hover{color:var(--dsw-alias-label-primary)}
.dsd-row-button:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}
.dsd-stack{flex-direction:column;flex:1;gap:4px;min-width:0;padding-right:48px;display:flex}
.dsd-section{flex-direction:column;gap:12px;display:flex}
.dsd-section-head{align-items:center;gap:16px;display:flex}
.dsd-section-title{color:var(--dsw-alias-label-primary);margin:0;font-size:14px;font-weight:500;line-height:22px}
.dsd-section-desc{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;font-weight:400;line-height:18px}
.dsd-tooltip{display:inline-flex;align-items:center;gap:8px;position:fixed;z-index:100;width:max-content;max-width:50vw;padding:3px 7px;border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-tooltip-bg);color:var(--dsw-static-neutral-bluish-00);font-size:13px;line-height:20px;white-space:pre-line;overflow-wrap:break-word;pointer-events:none;transform:translateX(-100%);animation:dsd-tooltip-in 150ms var(--ds-ease-in-out)}
@keyframes dsd-tooltip-in{from{opacity:0}}
@media (prefers-reduced-motion: reduce){.dsd-tooltip{animation:none}}
.dsd-modal-root{pointer-events:auto;position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;padding:max(24px,var(--dsh-frame-overlay-top,24px)) 24px}
.dsd-mask{position:absolute;inset:var(--dsh-frame-chrome-top,0px) 0 0;backdrop-filter:var(--dsw-mask-blur)}
.dsd-mask::after{content:'';position:absolute;inset:0;background:var(--dsw-alias-bg-mask-1);animation:dsd-enter var(--ds-transition-duration) var(--ds-ease-in-out)}
.dsd-dialog{box-sizing:border-box;position:relative;z-index:1;display:flex;flex-direction:column;gap:20px;width:min(380px,100%);padding:0 0 24px;overflow:hidden;border:0;border-radius:var(--dsw-radius-panel);background:var(--dsw-alias-bg-layer-2);box-shadow:var(--dsw-elevation-prominent);animation:dsd-enter var(--ds-transition-duration) var(--ds-ease-in-out);color:var(--dsw-alias-label-primary)}
.dsd-dialog:focus{outline:none}
@keyframes dsd-enter{from{opacity:0}to{opacity:1}}
@media (prefers-reduced-motion: reduce){.dsd-mask::after,.dsd-dialog{animation:none}}
.dsd-content{display:flex;flex-direction:column;width:100%}
.dsd-header{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:22px 14px 12px 24px}
.dsd-title{margin:0;font-size:16px;line-height:24px;font-weight:500;color:var(--dsw-alias-label-primary)}
.dsd-close{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;cursor:pointer;color:var(--dsw-alias-label-secondary)}
.dsd-close:hover{background:var(--dsw-alias-interactive-bg-hover)}
.dsd-description{margin:0;padding:0 24px;font-size:14px;line-height:22px;font-weight:400;color:var(--dsw-alias-label-primary)}
.dsd-body{display:flex;flex-direction:column;min-width:0;margin-top:20px;padding:0 24px}
.dsd-note{margin:0;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}
.dsd-status{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
.dsd-status + .dsd-status,.dsd-note + .dsd-status{margin-top:8px}
.dsd-status-error{color:var(--dsw-alias-state-error-primary)}
.dsd-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:0 24px}
.dsd-button{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:4px;height:36px;padding:0 14px;border:none;border-radius:var(--dsw-radius-md);cursor:pointer;font-size:14px;line-height:22px;color:var(--dsw-alias-label-primary);background:transparent}
.dsd-button:disabled{cursor:not-allowed;opacity:.4}
.dsd-outline{border:.5px solid var(--dsw-alias-border-l3);background:transparent}
.dsd-outline:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
.dsd-destructive:not(:disabled){color:var(--dsw-alias-state-error-primary)}
`
/** Minimal observable store; the shape the Client slot framework injects as a hook. */
function createStore(initial) {
let value = initial
const listeners = new Set()
return {
getSnapshot: () => value,
subscribe: (listener) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
set: (next) => {
if (next === value) return
value = next
for (const listener of [...listeners]) listener()
},
}
}
/**
* Use the locale seat the slot owner hands this entry; fall back to this
* package's own dictionaries so a missing seat degrades copy, never render.
*/
function translate(seat) {
if (typeof seat === 'function') return seat
const isChinese = typeof document === 'undefined'
|| !/^en/i.test(document.documentElement?.getAttribute?.('lang') ?? 'zh')
const dictionary = isChinese ? zh : en
return (key, params) => {
const template = dictionary[key] ?? key
if (params === undefined) return template
return template.replace(/\{(\w+)\}/g, (match, name) => (
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match
))
}
}
/**
* Icon paths are the shipped `IconTrashOutline` and `IconCloseOutline`
* artwork verbatim: a 16-unit viewBox the glyph fills, stroked at the
* regular weight of 1.
*/
function TrashIcon({ size = 16 }) {
return h('svg', {
width: size, height: size, viewBox: '0 0 16 16', fill: 'none',
xmlns: 'http://www.w3.org/2000/svg', 'aria-hidden': true, strokeWidth: 1,
},
h('path', { d: 'M1.28149 3.88831H14.7187', stroke: 'currentColor' }),
h('path', {
d: 'M5.41602 3.88833V2.47962C5.41602 2.29282 5.52492 2.11366 5.71876 1.98157C5.9126 1.84948 6.17551 1.77527 6.44964 1.77527H9.55053C9.82466 1.77527 10.0876 1.84948 10.2814 1.98157C10.4753 2.11366 10.5842 2.29282 10.5842 2.47962V3.88833',
stroke: 'currentColor',
}),
h('path', {
d: 'M2.57349 3.88831L3.19366 13.2943C3.21937 13.5502 3.33952 13.7872 3.53065 13.9593C3.72178 14.1313 3.97016 14.2259 4.22729 14.2246H11.7728C12.0299 14.2259 12.2783 14.1313 12.4694 13.9593C12.6605 13.7872 12.7807 13.5502 12.8064 13.2943L13.4266 3.88831',
stroke: 'currentColor',
}),
h('path', { d: 'M6.44946 6.98926V11.1238', stroke: 'currentColor' }),
h('path', { d: 'M9.55054 6.98926V11.1238', stroke: 'currentColor' }))
}
/** Close glyph of the dialog header, matching the primitive close button. */
function CloseIcon({ size = 16 }) {
return h('svg', {
width: size, height: size, viewBox: '0 0 16 16', fill: 'none',
xmlns: 'http://www.w3.org/2000/svg', 'aria-hidden': true, strokeWidth: 1,
},
h('path', { d: 'M2.5 2.5L13.5 13.5', stroke: 'currentColor' }),
h('path', { d: 'M13.5 2.5L2.5 13.5', stroke: 'currentColor' }))
}
/**
* The menu row: raise one deletion request for the conversation it belongs
* to. The menu's own open-state hook is declared by the slot owner, so the
* row closes it before the dialog appears.
*/
function DeleteSessionMenuItem(props) {
const sessionId = props.sessionId
const displayTitle = props.displayTitle
const t = translate(props.t)
const requestSessionDelete = props.requestSessionDelete
const closeMenu = useMenuCloser(props.useMenuOpenState)
return h(React.Fragment, null,
h('style', null, CSS),
h('button', {
type: 'button',
role: 'menuitem',
className: 'dsd-item',
'aria-label': t('menu.item'),
onClick: () => {
closeMenu()
requestSessionDelete(sessionId, displayTitle ?? '')
},
},
h('span', { className: 'dsd-item-icon' }, h(TrashIcon, { size: 14 })),
h('span', { className: 'dsd-item-label' }, t('menu.item'))))
}
/** Close the owning menu through the slot's declared hook, when it has one. */
function useMenuCloser(useMenuOpenState) {
const closer = React.useRef(() => {})
if (typeof useMenuOpenState === 'function') {
/** The hook belongs to the entry for its whole life, so the order is stable. */
const openState = useMenuOpenState()
if (Array.isArray(openState) && typeof openState[1] === 'function') {
closer.current = () => openState[1](false)
}
}
return closer.current
}
/**
* The row's hover button. It carries its own id in the row-action list, so
* the shipped `archive` and `pin` buttons keep their cells instead of being
* shadowed.
*
* Its tooltip copies the primitive's bubble — the same delay, the same
* bottom/end placement, the same fade — and is placed from this button's own
* rect alone. While the pointer rests on the row, the row's own hover
* preview may appear alongside it; that is accepted rather than suppressed
* by reaching into the row's DOM.
*/
function DeleteSessionRowButton(props) {
const t = translate(props.t)
const requestSessionDelete = props.requestSessionDelete
const buttonRef = React.useRef(null)
const timerRef = React.useRef(null)
const [anchor, setAnchor] = React.useState(null)
const show = () => {
clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
const rect = buttonRef.current?.getBoundingClientRect()
if (rect === undefined) return
setAnchor({ left: rect.right, top: rect.bottom + TOOLTIP_GAP })
}, TOOLTIP_DELAY_MS)
}
const hide = () => {
clearTimeout(timerRef.current)
setAnchor(null)
}
React.useEffect(() => () => clearTimeout(timerRef.current), [])
const label = t('actions.delete')
return h(React.Fragment, null,
h('style', null, CSS),
h('button', {
ref: buttonRef,
type: 'button',
className: 'dsd-row-button',
'aria-label': label,
onMouseEnter: show,
onMouseLeave: hide,
onFocus: show,
onBlur: hide,
onClick: () => {
hide()
requestSessionDelete(props.sessionId, props.displayTitle ?? '')
},
}, h(TrashIcon, { size: 14 })),
anchor !== null && h('span', {
className: 'dsd-tooltip',
role: 'tooltip',
style: { left: anchor.left, top: anchor.top },
}, label))
}
/**
* This plugin's page under Settings → Plugins: sweep spilled tool output
* whose conversation no longer exists.
*
* The owner dispatches the `page` view only: a bundle's configuration has
* no one-line form, because the page header already prints this package's
* title and description from the manifest. So the section opens with the
* operation's own name and the rule it applies — what the header cannot say
* — and the guard below keeps rendering nothing if a `summary` view ever
* arrives.
*/
function TempCleanupPanel(props) {
const t = translate(props.t)
const cleanOrphans = props.cleanOrphans
// Every hook sits above the view guard: React must see the same hook
// order in both views, so the guard can only skip the output.
const [state, setState] = React.useState({ phase: 'idle', failed: false, message: '' })
if (props.view !== 'page') return null
const busy = state.phase === 'busy'
const run = async () => {
setState({ phase: 'busy', failed: false, message: '' })
try {
const value = await cleanOrphans()
const files = Number(value.files) || 0
const directories = Number(value.directories) || 0
setState({
phase: 'done',
failed: false,
message: files === 0
? t('cleanup.empty')
: t('cleanup.done', { n: files, directories, size: formatBytes(Number(value.bytes) || 0) }),
})
} catch (error) {
setState({
phase: 'done',
failed: true,
message: t('cleanup.failed', { message: error instanceof Error ? error.message : t('error.unknown') }),
})
}
}
return h(React.Fragment, null,
h('style', null, CSS),
h('div', { className: 'dsd-section' },
h('div', { className: 'dsd-section-head' },
h('div', { className: 'dsd-stack' },
h('h4', { className: 'dsd-section-title' }, t('cleanup.title')),
h('p', { className: 'dsd-section-desc' }, t('cleanup.description'))),
h('button', {
type: 'button',
className: 'dsd-button dsd-outline',
disabled: busy,
onClick: run,
}, busy ? t('cleanup.pending') : t('cleanup.action'))),
state.phase === 'done' && h('p', {
className: `dsd-status${state.failed ? ' dsd-status-error' : ''}`,
role: state.failed ? 'alert' : 'status',
}, state.message)))
}
/** Render a byte count the way the cleanup result reports reclaimed space. */
function formatBytes(bytes) {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
let value = bytes
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit += 1
}
return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`
}
/** The overlay entry: the dialog, or nothing while no request is pending. */
function DeleteSessionDialog(props) {
const request = props.useDeleteRequest((pending) => pending)
if (request === null) return null
return h(DeleteSessionForm, {
request,
deleteConversation: props.deleteConversation,
onSettle: props.settleSessionDelete,
t: props.t,
}, request.sessionId)
}
/**
* One request's dialog; its in-flight and result state die with the request.
* Focus, Escape, and Tab behave as the shipped modal does: focus moves into
* the card on open, Tab stays inside it, Escape closes it, and focus returns
* to the element that opened it.
*/
function DeleteSessionForm({ request, deleteConversation, onSettle, t: seat }) {
const t = translate(seat)
const [phase, setPhase] = React.useState('idle')
const [result, setResult] = React.useState(null)
const dialogRef = React.useRef(null)
const busy = phase === 'deleting'
// The layer effect mounts once per dialog, exactly as the shipped modal
// does: the current close command is read through a ref so a state change
// never re-runs focus setup or steals focus mid-request.
const busyRef = React.useRef(busy)
busyRef.current = busy
const closeRef = React.useRef(null)
closeRef.current = () => {
if (busyRef.current) return
onSettle()
}
const close = () => {
closeRef.current()
}
React.useEffect(() => {
const dialog = dialogRef.current
if (dialog === null) return undefined
const owner = dialog.ownerDocument
const previous = owner.activeElement
const initial = dialog.querySelector('[data-dsd-autofocus]')
?? dialog.querySelector(FOCUSABLE)
?? dialog
initial.focus?.({ preventScroll: true })
const onKeyDown = (event) => {
if (event.defaultPrevented || event.ctrlKey || event.altKey || event.metaKey) return
if (event.key === 'Escape' && !event.shiftKey) {
event.preventDefault()
if (!event.repeat) closeRef.current()
return
}
if (event.key !== 'Tab') return
if (owner.activeElement?.closest('[role="menu"]') != null) return
const items = [...dialog.querySelectorAll(FOCUSABLE)].filter((item) => item.closest('[inert], [hidden]') === null)
const first = items[0] ?? dialog
const last = items.at(-1) ?? dialog
const atEdge = event.shiftKey ? owner.activeElement === first : owner.activeElement === last
if (owner.activeElement === dialog || !dialog.contains(owner.activeElement) || atEdge) {
event.preventDefault()
;(event.shiftKey ? last : first).focus()
}
}
owner.addEventListener('keydown', onKeyDown)
return () => {
owner.removeEventListener('keydown', onKeyDown)
if (previous instanceof HTMLElement && previous.isConnected) previous.focus({ preventScroll: true })
}
}, [])
React.useEffect(() => {
if (phase !== 'done' || result === null || result.kind !== 'deleted') return undefined
const timer = setTimeout(() => closeRef.current(), 2500)
return () => clearTimeout(timer)
}, [phase, result])
const confirm = async () => {
setPhase('deleting')
setResult(null)
try {
const value = await deleteConversation(request.sessionId)
if (value.status === 'scheduled') {
setResult({ kind: 'scheduled', message: t('toast.scheduled') })
} else {
const spilled = Number(value.spilledFiles) || 0
setResult({
kind: 'deleted',
message: spilled > 0 ? t('toast.deletedSpill', { n: spilled }) : t('toast.deleted'),
})
}
setPhase('done')
} catch (error) {
setResult({
kind: 'failed',
message: t('toast.failed', { message: error instanceof Error ? error.message : t('error.unknown') }),
})
setPhase('error')
}
}
const title = request.displayTitle !== undefined && request.displayTitle !== ''
? request.displayTitle
: request.sessionId
return h(React.Fragment, null,
h('style', null, CSS),
h('div', { className: 'dsd-modal-root', role: 'presentation' },
h('div', { className: 'dsd-mask', 'aria-hidden': 'true', onClick: close }),
h('div', {
ref: dialogRef,
tabIndex: -1,
className: 'dsd-dialog',
role: 'dialog',
'aria-modal': 'true',
'aria-label': t('dialog.title'),
},
h('div', { className: 'dsd-content' },
h('div', { className: 'dsd-header' },
h('h2', { className: 'dsd-title' }, t('dialog.title')),
h('button', {
type: 'button',
className: 'dsd-close',
'aria-label': t('dialog.close'),
onClick: close,
}, h(CloseIcon, { size: 14 }))),
h('p', { className: 'dsd-description' }, t('dialog.body', { title })),
h('div', { className: 'dsd-body' },
h('p', { className: 'dsd-note' }, t('dialog.note')),
busy && h('div', { className: 'dsd-status', role: 'status' }, t('dialog.pending')),
result !== null && h('div', {
className: `dsd-status${result.kind === 'failed' ? ' dsd-status-error' : ''}`,
role: result.kind === 'failed' ? 'alert' : 'status',
}, result.message))),
h('div', { className: 'dsd-footer' },
phase === 'done'
? h('button', { type: 'button', className: 'dsd-button dsd-outline', onClick: close }, t('dialog.close'))
: [
h('button', {
key: 'cancel',
type: 'button',
className: 'dsd-button dsd-outline',
disabled: busy,
'data-dsd-autofocus': true,
onClick: close,
}, t('dialog.cancel')),
h('button', {
key: 'confirm',
type: 'button',
className: 'dsd-button dsd-outline dsd-destructive',
disabled: busy,
onClick: confirm,
}, t('dialog.confirm')),
]))))
}
/**
* Post to one of this package's Host routes. The registration path on the
* Host is absolute while this one is document-relative, so the request is
* built against the base captured at bundle registration and works under a
* mounted prefix. Routes sit behind the connection's trust fence, so the
* page's own credentials apply.
* @param route - document-relative route path.
* @param body - JSON body to send.
* @returns the Host's result value.
*/
async function post(route, body) {
const response = await fetch(new URL(route, BASE).href, {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
let payload = null
try {
payload = await response.json()
} catch {
payload = null
}
if (!response.ok || payload?.ok === false) {
const code = payload?.error?.code
const detail = payload?.error?.message ?? `HTTP ${response.status}`
throw new Error(code === undefined ? detail : `${detail} (${code})`)
}
return payload
}
/**
* Delete one conversation through the Host half.
* @param sessionId - conversation to delete.
* @returns the Host's result value.
*/
function deleteConversation(sessionId) {
return post(ROUTE, { sessionId })
}
/**
* Sweep spilled tool output whose conversation no longer exists.
* @returns the Host's cleanup report.
*/
function cleanOrphans() {
return post(ORPHAN_ROUTE, {})
}
return {
inject: ['slots', 'locale'],
apply(ctx) {
const request = createStore(null)
const requestSessionDelete = (sessionId, displayTitle) => {
request.set({ sessionId, displayTitle })
}
const locale = ctx.get('locale')
if (locale !== undefined) {
ctx.effect(() => registerDictionaries(locale, NS, { zh, en }), 'session-delete: dictionaries')
}
ctx.slots.inject('sidebar.workspaces.session.menu.item', () => ctx.slots.register({
name: 'sidebar.workspaces.session.menu.item',
id: NS,
order: MENU_ORDER,
locale: NS,
inject: () => ({ requestSessionDelete }),
}, DeleteSessionMenuItem))
// This entry carries its own id, so the shipped `archive` and `pin`
// hover buttons keep their cells and are not shadowed by a lower
// priority. Archiving therefore stays exactly where the harness put it.
ctx.slots.inject('sidebar.workspaces.session.row.action', () => ctx.slots.register({
name: 'sidebar.workspaces.session.row.action',
id: NS,
order: ROW_ACTION_ORDER,
locale: NS,
inject: () => ({ requestSessionDelete }),
}, DeleteSessionRowButton))
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
name: 'shell.overlay',
id: `${NS}-confirm`,
locale: NS,
inject: () => ({
hooks: { deleteRequest: request },
deleteConversation,
settleSessionDelete: () => {
request.set(null)
},
}),
}, DeleteSessionDialog))
// The cleanup control belongs on this plugin's own page itself, not
// behind the component row listed under 「包含的组件」:
// `plugins.bundle.config` keyed by this package's name is the seat the
// manager renders between the page's description and its rows.
ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({
name: 'plugins.bundle.config',
key: PACKAGE,
locale: NS,
inject: () => ({ cleanOrphans }),
}, TempCleanupPanel))
},
}
},
})
/** Register this package's dictionaries, tolerating either registration form. */
function registerDictionaries(locale, namespace, dictionaries) {
try {
return locale.register(namespace, dictionaries)
} catch {
const disposers = Object.entries(dictionaries).map(([id, dict]) => locale.register(namespace, id, dict))
return () => {
for (const dispose of disposers) dispose?.()
}
}
}