/** * 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.row.config` under the `#` key the manager * dispatches, which also gives the row its configure control. That is the same * seat a bundled plugin's configuration form occupies, so the plugin's only * control sits beside everything else the manager says about it instead of * taking a row in General settings. * * 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.row.config` is dispatched under: the manager composes it * as `#`, with the row id read from this bundle's own * patch. Both halves are literals from `package.json` and * `cordis.patch.yml`; a typo costs the configure control silently, so they * are the only two places renaming has to reach. */ const PACKAGE = '@dsh-plugin/session-delete' const ROW_ID = 'session-delete' const CONFIG_KEY = `${PACKAGE}#${ROW_ID}` 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 asks every `plugins.row.config` entry for two views. `summary` * is the one-liner on the row, and this plugin's manifest description * already fills that seat, so `summary` renders nothing and the page keeps * the text the manager read from `locale/*.json`. `page` is the section the * manager renders with its own heading, breadcrumb, and title; copy, * control, and result belong to this entry. */ 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: registering // this cell key is what makes the row grow a configure control, and the // manager renders the entry for both its summary and page views. ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ name: 'plugins.row.config', key: CONFIG_KEY, 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?.() } } }