Files
session-delete/client.js
T
pyh 37468d6ac9
publish / npm (push) Failing after 1m49s
feat!: publish as @dsh-plugin/session-delete with an automated Gitea npm release
The registry owner is the scope the package must publish under, so the name moves from @local/ to @dsh-plugin/ in all three places that must agree: package.json, cordis.patch.yml, and the client module id. `private` goes away because npm refuses to publish such a package, and publishConfig pins the registry to this organization's npm endpoint.

The tag workflow publishes whatever version the pushed v* tag names, using the built-in Gitea job token, and refuses a tag that disagrees with package.json.
2026-09-30 11:00:39 +08:00

663 lines
30 KiB
JavaScript

/**
* Browser half of the session-delete bundle.
*
* One row in a conversation's "..." menu opens 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.
*
* Harness Client packages may change without notice, so this bundle imports no
* Harness Client module: its menu row and dialog copy the shipped primitives'
* markup, stylesheet declarations, and focus/Escape behavior, keeping only
* `--dsw-*` theme-token references. React comes from the page's module table.
*/
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'
const MENU_ORDER = 500
/** The shipped hover button's id; reusing its id takes that cell over. */
const ROW_ACTION_ID = 'archive'
const ROW_ACTION_ORDER = 100
/** Below the shipped entry's implicit 0: the lowest priority renders. */
const ROW_ACTION_PRIORITY = -1
/** General-settings position: after the log-upload row, before the version. */
const SETTINGS_ORDER = 95
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. 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-row{border-bottom:.5px solid var(--dsw-alias-border-l2);align-items:center;gap:8px;padding:16px 0;display:flex}
.dsd-row-text{flex-direction:column;flex:1;gap:4px;min-width:0;padding-right:48px;display:flex}
.dsd-row-title{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:22px}
.dsd-row-desc{color:var(--dsw-alias-label-tertiary);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. Redrawing a smaller glyph in a 24-unit box is what
* made an earlier version of this row look undersized.
*/
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
}
/**
* Withdraw the session row's hover preview while this button's own tooltip
* is showing.
*
* The shipped row buttons get this for free: `Tooltip` reports itself
* through the primitives' private `TooltipSuppression` context, and the
* enclosing `HoverCard` — whose anchor is the whole row, so a button inside
* it counts as hovering the row — hides its card while that bubble is up.
* That context object belongs to another module instance and cannot be
* reached from here, so this reproduces the same outcome through the DOM:
* a `pointerout` on the card's anchor wrapper makes React deliver
* `onPointerLeave` to that wrapper alone, which is exactly the cancel the
* hover card performs on a real leave. Nothing else in the tree is affected
* because the related target is the wrapper's own parent.
*
* @param button - the hovered row button.
*/
function withdrawRowPreview(button) {
try {
const wrapper = button.closest('[data-row-key]')?.parentElement
if (wrapper === null || wrapper === undefined) return
wrapper.dispatchEvent(new PointerEvent('pointerout', {
bubbles: true,
cancelable: true,
composed: true,
pointerType: 'mouse',
relatedTarget: wrapper.parentElement,
}))
} catch {
/* an unchanged host simply keeps showing its card */
}
}
/**
* The row's hover button, in the cell the shipped archive action occupied.
* Its tooltip copies the primitive's bubble: the same delay, the same
* bottom/end placement, and the same fade.
*/
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 = () => {
if (buttonRef.current !== null) withdrawRowPreview(buttonRef.current)
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))
}
/**
* The General-settings row: sweep spilled tool output whose conversation no
* longer exists. The owner supplies nothing, so copy, control, and result
* all belong to this entry.
*/
function TempCleanupRow(props) {
const t = translate(props.t)
const cleanOrphans = props.cleanOrphans
const [state, setState] = React.useState({ phase: 'idle', failed: false, message: '' })
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-row' },
h('div', { className: 'dsd-row-text' },
h('div', { className: 'dsd-row-title' }, t('cleanup.title')),
h('div', { className: 'dsd-row-desc' }, t('cleanup.description')),
state.phase === 'done' && h('div', {
className: `dsd-status${state.failed ? ' dsd-status-error' : ''}`,
role: state.failed ? 'alert' : 'status',
}, state.message)),
h('button', {
type: 'button',
className: 'dsd-button dsd-outline',
disabled: busy,
onClick: run,
}, busy ? t('cleanup.pending') : t('cleanup.action'))))
}
/** Render a byte count the way the settings row 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. 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) {
throw new Error(payload?.error?.message ?? `HTTP ${response.status}`)
}
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 reuses the shipped archive action's id, so the row keeps
// one hover button and archiving stays available from the "..." menu.
// Same id at the same priority is refused as a duplicate, and the
// lowest priority renders, so this row must sit below the shipped 0.
ctx.slots.inject('sidebar.workspaces.session.row.action', () => ctx.slots.register({
name: 'sidebar.workspaces.session.row.action',
id: ROW_ACTION_ID,
order: ROW_ACTION_ORDER,
priority: ROW_ACTION_PRIORITY,
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))
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
name: 'settings.general.item',
id: `${NS}-temp-cleanup`,
order: SETTINGS_ORDER,
locale: NS,
inject: () => ({ cleanOrphans }),
}, TempCleanupRow))
},
}
},
})
/** 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?.()
}
}
}