/**
* Session-row action-menu injection: on touch-primary devices, adds a
* "delete session" item to the host's per-row ⋯ menu (beside the host's own
* items — rename / fork / archive, plus the 0.1.7 pin item; blank rows stay
* host-native) and drives the whole delete flow: row → session id
* resolution, a confirm dialog, the host delete endpoint, and the list
* refresh.
*
* The host menu is React-owned (ui-workspace) with no extension slot, so the
* item is injected into the portaled `[role="menu"]` list by cloning the
* host's own item markup (reusing the hashed classes keeps the styling
* identical), and re-injected whenever React recreates the menu. Two menu
* shapes are supported: rc.2 nests icon/label spans in the item, while 0.1.5
* renders the label directly in the item button (shared `_item_1nxmc_92`
* menu component, no child elements) — label reads and the injected text
* fall back across both.
*
* Row → session id: session rows carry no id in the DOM, so the id is read
* off the host's own stable anchors (see `rowSessionId`).
*
* Ported from community-fork wzxmt-zhc v2.7.0; the only mainline delta is
* where the delete-dialog CSS lives: base.css.ts, with corrected animation
* names (`dsh-web-mobile-*`; the fork's originals referenced the pre-rename
* `dsh-mobile-nav-*` names, which silently no-op).
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { MOBILE_QUERY, TOUCH_QUERY, installMobileEffect } from '../platform/breakpoints.ts'
import { getFrame } from '../platform/frame.ts'
import { toggleDrawer } from './drawer-close.ts'
import { currentSessionIdOf, sessionsCanClear, verifySessionDeleted } from '../core/sessions-compat.ts'
import { NS } from '../i18n/locales.ts'
import { findSessionIdInFiber, reactFiberOf } from './session-row-fiber.ts'
/** The ui-workspace dictionary namespace the host session menu labels come from. */
const WORKSPACE_NS = 'workspace'
/** Marker on the injected menu item (idempotence across React re-renders). */
const DELETE_ITEM_MARKER = 'data-mobile-nav="session-delete"'
/** Danger accent read from the theme, with a fixed fallback. */
const DANGER_COLOR = 'var(--dsw-alias-state-error-primary, #b91c1c)'
/** 16px outline trash glyph (IconTrashOutline16 path), currentColor-filled. */
const TRASH_SVG = ''
/** One captured session-row menu anchor. */
interface MenuAnchor {
/** The ⋯ button that opened the menu (used to close it). */
button: HTMLButtonElement
/** The row's displayed session title (display + legacy fallback only). */
title: string
/**
* The session id read straight off the row's React fiber — the primary source
* (2026-10-07). `null` when the fiber walk found nothing, in which case the
* delete flow falls back to the title/group heuristic.
*/
sessionId: string | null
}
/** Host delete-endpoint response shape. */
interface DeleteResponse {
ok?: true
deleted?: string
error?: { code?: string; message?: string }
}
/** Escape text destined for innerHTML (session titles are user content). */
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
}
/**
* Install the mobile session-delete menu machinery. Touch-gated: the whole
* effect arms under TOUCH_QUERY — (pointer: coarse) at EVERY width — so a
* large tablet in landscape keeps the desktop layout but still gets the
* delete item, while any mouse-driven or pointer-less window stays a
* complete no-op. Returns a disposer (via installMobileEffect) that removes
* every listener, observer, injected node, and the confirm dialog.
* @param ctx - client root context.
*/
export function installSessionMenuDelete(ctx: ClientContext): void {
installMobileEffect(ctx, 'dsh-web-mobile: session-menu delete', () => {
const navT = ctx.locale.bind(NS)
// Host workspace-browser dictionary for menu-signature detection. Bound
// lazily so a later-registered dictionary is picked up; the general
// overload accepts the raw namespace id.
const wsT = (key: string, params?: Record): string =>
ctx.locale.bind(WORKSPACE_NS)(key, params)
let anchor: MenuAnchor | null = null
let injectRaf = 0
let dialogHost: { backdrop: HTMLElement; card: HTMLElement } | null = null
let closeDialogOnKey: ((event: KeyboardEvent) => void) | null = null
// 请求身份(epoch),每次确认点击自增。更晚的删除一发起,在途响应即过期 ——
// 过期结果不得 closeDialog / clear / 收抽屉,否则会拿旧响应改新对话框与新当前会话。
let deleteEpoch = 0
/**
* The session id of one row, from the host's own stable anchors.
*
* 2026-10-07 — this replaces the whole "match the displayed title, then
* disambiguate duplicates by group position" heuristic, which 0.2.0-rc.2
* broke twice over on the real drawer: every row is now wrapped in the
* host's `HoverCard` ``, so `:scope > [class*="_sessionRow"]` and the
* `_projectRow` group header matched NOTHING → duplicate titles silently
* resolved to the FIRST session with that title, i.e. **deleting the wrong
* conversation**.
*
* Order of preference, all host-owned:
* 1. `data-row-key="session:"` — the same attribute the host's own
* `AnimatedRows.readPositions()` uses, so it is a real contract;
* 2. `data-dsha-session-select` — the DSHA build stamps the id directly;
* 3. the React fiber (`session-row-fiber.ts`, the proven path long-press
* navigation already uses).
* @param row - the session row element.
* @returns the session id, or null when the row offers none.
*/
const rowSessionId = (row: HTMLElement): string | null => {
const rowKey = row.getAttribute('data-row-key') ?? ''
if (rowKey.startsWith('session:')) {
const id = rowKey.slice('session:'.length)
if (id !== '') return id
}
const stamped = row.getAttribute('data-dsha-session-select')
if (stamped !== null && stamped !== '') return stamped
return findSessionIdInFiber(reactFiberOf(row), isKnownSessionId)
}
/**
* Read one menu item's visible label across host generations: rc.2 nests
* the text in an `_itemLabel` span (beside an `_itemIcon`), while 0.1.5
* puts it directly in the button (`_item_1nxmc_92`, no child elements).
* Falling back to the item's own textContent covers both — svg icons
* contribute no text, so rc.2 items read identically either way.
*/
const itemLabel = (item: HTMLElement): string => {
const label = item.querySelector('[class*="_itemLabel"]')
return (label ?? item).textContent?.trim() ?? ''
}
/**
* Whether a menu list is the host's per-session row menu. Containment
* style, never an exact item count: 0.1.7 added a fourth 「置顶会话」
* item (menu.pinSession, alongside rename / fork / archive) — a
* `length === 3` gate silently disabled the whole feature on 0.1.7.
* rename + fork + archiveSession is the discriminating triple (a
* full-host label audit: the fork label exists only in ui-workspace's
* session menu). Archived rows swap archive for 取消归档, so they do NOT
* match this signature and get no delete item — the host's own look
* (delete via unarchive first); resolution excludes archived ids anyway,
* so an injected item there would be a doomed deleteErrorResolve tap
* (#V1 N1: the removed unarchive branch used to inject exactly that).
*/
const isSessionMenu = (menu: HTMLElement): boolean => {
const labels = [...menu.querySelectorAll('[role="menuitem"]')]
.map(itemLabel)
const rename = wsT('rename')
const fork = wsT('menu.fork')
return labels.includes(rename) && labels.includes(fork)
&& labels.includes(wsT('menu.archiveSession'))
}
const closeDialog = (): void => {
if (closeDialogOnKey !== null) {
document.removeEventListener('keydown', closeDialogOnKey, true)
closeDialogOnKey = null
}
if (dialogHost !== null) {
dialogHost.backdrop.remove()
dialogHost.card.remove()
dialogHost = null
}
}
/** Show the delete confirmation as a centered frosted-glass modal over
* the frame. Mounted on , NOT in the frame: the third-party mobile
* shim (@linxin666/dsh-web-all) listens in the CAPTURE phase on the frame
* and, while the drawer is open, answers every click inside the frame but
* outside [data-pane="sidebar"] with preventDefault + stopPropagation.
* A card inside the frame therefore had dead buttons — measured
* 2026-09-14: a real touch tap on 「取消」 left the card open, and only
* Escape closed it. Body-level, the shim's listener never sees these
* clicks (its sibling menus are portaled there for the same reason), and
* the dialog's band lives in base.css (backdrop z 1400 above the drawer
* on the mobile branch). The card is appended INTO the backdrop so the
* backdrop's flex centers it (base.css 2026-09-24 rework). */
const showDeleteDialog = (sessionId: string, title: string): void => {
closeDialog()
const host = document.body
const backdrop = document.createElement('div')
backdrop.dataset.mobileNav = 'delete-dialog-backdrop'
const card = document.createElement('div')
card.dataset.mobileNav = 'delete-dialog'
card.setAttribute('role', 'dialog')
card.setAttribute('aria-modal', 'true')
card.innerHTML = `
${escapeHtml(navT('deleteConfirmTitle'))}
${escapeHtml(navT('deleteConfirmDesc', { title }))}
`
const noButton = card.querySelector('[data-mobile-nav="delete-confirm-no"]')
const yesButton = card.querySelector('[data-mobile-nav="delete-confirm-yes"]')
const errorLine = card.querySelector('[data-mobile-nav="delete-error"]')
noButton?.addEventListener('click', closeDialog)
// The card is a CHILD of the backdrop (the CSS centers it through the
// backdrop's flex), so close only on genuine backdrop taps — without
// the target guard every card click (the async yes tap included) would
// bubble here and close the dialog before the fetch settles, killing
// the pending state and the error display path.
backdrop.addEventListener('click', (event) => {
if (event.target !== backdrop) return
closeDialog()
})
const onKey = (event: KeyboardEvent): void => {
if (event.key === 'Escape') closeDialog()
}
document.addEventListener('keydown', onKey, true)
closeDialogOnKey = onKey
const resetButtons = (): void => {
if (yesButton !== null) {
yesButton.disabled = false
yesButton.textContent = navT('deleteConfirmYes')
}
if (noButton !== null) noButton.disabled = false
}
const fail = (message: string): void => {
if (errorLine !== null) {
errorLine.textContent = message
errorLine.hidden = false
}
resetButtons()
}
const mapError = (payload: DeleteResponse | null, reason: unknown): string => {
const code = payload?.error?.code
if (code === 'session-not-found') return navT('deleteErrorNotFound')
if (code === 'session-busy') return navT('deleteErrorBusy')
const message = payload?.error?.message ?? (reason instanceof Error ? reason.message : String(reason))
return navT('deleteErrorGeneric', { message })
}
yesButton?.addEventListener('click', async () => {
yesButton.disabled = true
if (noButton !== null) noButton.disabled = true
yesButton.textContent = navT('deletePending')
if (errorLine !== null) errorLine.hidden = true
// 请求身份:本次点击的序号。期间又发起删除时,这次的结果就过期了。
const epoch = ++deleteEpoch
try {
const response = await fetch('/api/mobile-nav.session.delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId }),
})
const payload = await response.json().catch(() => null) as DeleteResponse | null
if (!response.ok || payload === null || payload.ok !== true) {
fail(mapError(payload, new Error(`HTTP ${response.status}`)))
return
}
} catch (reason) {
// A rejected fetch proves nothing (DSHA's host half aborts the reply
// after the trash move — see verifySessionDeleted). Ask the session
// list instead; only a session that survives every re-read failed.
// Verification itself must not become the new failure mode.
let landed = false
try {
landed = await verifySessionDeleted({
listed: () => ctx.sessions.list.getSnapshot()?.byId?.[sessionId] !== undefined,
// Called AS A METHOD: refresh() reads `this.manager`.
refresh: async () => {
await (ctx.sessions as { refresh?: () => Promise }).refresh?.()
},
sleep: (ms) => new Promise((resolve) => {
window.setTimeout(() => { resolve() }, ms)
}),
})
} catch {
landed = false
}
if (!landed) {
fail(mapError(null, reason))
return
}
}
// 「是否仍是当前会话」必须在**请求完成时**重新核对:请求期间用户可能已经切到
// 别的会话,用请求前算好的旧值 clear() 会把新当前会话的选中态清掉(2026-10-11)。
const wasCurrent = currentSessionIdOf(ctx.sessions.list.getSnapshot()) === sessionId
// 过期结果(期间又发起过删除)只丢弃,不关别人的对话框、也不动状态。
if (epoch !== deleteEpoch) return
closeDialog()
if (wasCurrent && sessionsCanClear(ctx.sessions)) ctx.sessions.clear()
// Repull the baseline so the deleted row disappears; called AS A METHOD
// on ctx.sessions because refresh() reads `this.manager`.
const sessions = ctx.sessions as { refresh?: () => Promise }
try {
await sessions.refresh?.()
} catch (reason) {
// 列表重拉失败只影响刷新,不代表删除失败 —— 单独 warn,也不许变成未处理 rejection。
console.warn('[dsh-web-mobile] session list refresh after delete failed:', reason)
}
// 手机档抽屉里就是列表,删完当前会话顺手收起;桌面档的面板不得被收起(MOBILE_QUERY
// 门保持)。只有抽屉**确实开着**才关:盲 toggle 会把已关的抽屉重新打开(2026-10-11)。
const frame = getFrame()
if (frame === null || frame.hasAttribute('data-sidebar-collapsed')) return
if (wasCurrent && window.matchMedia(MOBILE_QUERY).matches) toggleDrawer(ctx)
})
host.appendChild(backdrop)
backdrop.appendChild(card)
dialogHost = { backdrop, card }
}
/** Show a non-destructive error card (session could not be resolved). */
const showError = (message: string): void => {
closeDialog()
const host = document.body
const backdrop = document.createElement('div')
backdrop.dataset.mobileNav = 'delete-dialog-backdrop'
const card = document.createElement('div')
card.dataset.mobileNav = 'delete-dialog'
card.setAttribute('role', 'dialog')
card.setAttribute('aria-modal', 'true')
card.innerHTML = `
${escapeHtml(navT('deleteSession'))}
${escapeHtml(message)}
`
card.querySelector('[data-mobile-nav="delete-confirm-no"]')?.addEventListener('click', closeDialog)
// Same child-of-backdrop target guard as showDeleteDialog: error-card
// taps must not bubble into the backdrop's close.
backdrop.addEventListener('click', (event) => {
if (event.target !== backdrop) return
closeDialog()
})
const onKey = (event: KeyboardEvent): void => {
if (event.key === 'Escape') closeDialog()
}
document.addEventListener('keydown', onKey, true)
closeDialogOnKey = onKey
host.appendChild(backdrop)
backdrop.appendChild(card)
dialogHost = { backdrop, card }
}
/**
* The clones injected into host menus. They are plugin-owned nodes inside
* React-owned lists, so disposal must remove them explicitly — the host
* never unmounts them on our behalf, and the click listener rides the clone
* (issue #86: the old disposer left the node and its listener behind, and a
* later tap went through showError on a dead anchor).
*/
const injected = new Set()
/** Inject the delete item into one open session menu (idempotent). */
const injectInto = (menu: HTMLElement): void => {
if (menu.querySelector(`[${DELETE_ITEM_MARKER}]`) !== null) return
const template = menu.querySelector('[role="menuitem"]')
// `?? null` collapses the optional chain so the guard is exact: the old
// `wrap === undefined` arm was unreachable once `template === null` had
// returned (issue #86).
const wrap = template?.parentElement ?? null
const viewport = menu.querySelector('[class*="_viewport"]')
if (template === null || wrap === null || viewport === null) return
const clone = wrap.cloneNode(true) as HTMLElement
const button = clone.querySelector('[role="menuitem"]')
if (button === null) return
const icon = button.querySelector('[class*="_itemIcon"]')
if (icon !== null) {
icon.innerHTML = TRASH_SVG
icon.style.color = DANGER_COLOR
}
const label = button.querySelector('[class*="_itemLabel"]')
if (label !== null) {
label.textContent = navT('deleteSession')
label.style.color = DANGER_COLOR
} else if (button.firstElementChild === null) {
// 0.1.5 shape: the menuitem button carries its text directly (no
// `_itemLabel` span, no icon element). Replace the whole text and let
// the danger color ride the button itself. A button WITH element
// children but no label span is an unknown future shape — leave its
// text alone rather than guess.
button.textContent = navT('deleteSession')
button.style.color = DANGER_COLOR
}
button.setAttribute('data-mobile-nav', 'session-delete')
button.addEventListener('click', (event) => {
event.preventDefault()
event.stopPropagation()
const captured = anchor
// Close the host menu by toggling its anchor (React-owned state).
captured?.button.click()
try {
if (captured === null) {
showError(navT('deleteErrorResolve'))
return
}
// 用录制点击时从行上读到的会话 id(`data-row-key` → DSHA 戳 → fiber)。
// 读不到就报错,不再按标题/行序猜 —— 2026-10-07 实测那条路在 0.2.0-rc.2
// 上会静默删到**另一个同名会话**(宿主把行包进 HoverCard 后 `:scope >` 取不到行,
// rowIndex 变成 -1,于是永远取"第一个同名 id")。
const sessionId = captured.sessionId
if (sessionId === null) {
showError(navT('deleteErrorResolve'))
return
}
showDeleteDialog(sessionId, captured.title)
} catch (reason) {
// Never fail silently: surface internal resolution errors instead of
// leaving the tap with no visible result.
console.error('[dsh-web-mobile] session delete failed:', reason)
showError(navT('deleteErrorGeneric', {
message: reason instanceof Error ? reason.message : String(reason),
}))
}
})
viewport.appendChild(clone)
injected.add(clone)
}
/**
* Inject into every open session menu. Blank (new-session) rows are
* excluded: the host renders their title as the localized "New session"
* label (`t("session.new")`) while the summary's `displayTitle` stays
* empty, so the delete flow could never resolve them — the tap would
* only end in a deleteErrorResolve card. A menu without the delete item
* is the host's own look for those rows. Known ceiling: a normal session
* manually titled exactly the host's "New session" label is mistaken for
* a blank row and gets no delete item either (accepted trade-off; its
* resolution itself would still work).
*/
const injectAll = (): void => {
// Drop references to clones the host already unmounted with its menu.
for (const clone of injected) if (!clone.isConnected) injected.delete(clone)
const blankLabel = wsT('session.new')
for (const menu of document.querySelectorAll('[role="menu"]')) {
if (!isSessionMenu(menu)) continue
if (anchor !== null && anchor.title === blankLabel) continue
injectInto(menu)
}
}
const scheduleInject = (): void => {
if (injectRaf !== 0) return
injectRaf = requestAnimationFrame(() => {
injectRaf = 0
injectAll()
})
}
/** Whether an id is a session this client knows (the fiber walk's filter). */
const isKnownSessionId = (id: string): boolean => ctx.sessions.list.getSnapshot().byId[id] !== undefined
/**
* The row's ⋯ anchor button.
*
* 2026-10-07 (host 0.2.0-rc.2): a row now carries THREE buttons — the ⋯
* (`aria-label="Session actions for …"`), `Archive session` and
* `Pin session`. Reading `querySelector('button')` only worked because the ⋯
* happened to come first; match it by its label and exclude the two row
* actions instead of betting on document order.
* @param row - the session row.
* @returns the ⋯ button, or null when this row has none.
*/
const menuButtonOf = (row: HTMLElement): HTMLButtonElement | null => {
const buttons = [...row.querySelectorAll('button')]
const labelled = buttons.find((candidate) => {
const label = candidate.getAttribute('aria-label') ?? ''
return /session actions/i.test(label)
})
if (labelled !== undefined) return labelled
const fallback = buttons.find((candidate) => {
const label = candidate.getAttribute('aria-label') ?? ''
return !/archive session|pin session/i.test(label)
})
return fallback ?? null
}
// Capture the ⋯ button click before React handles it, so the row/title/session
// id are known when the portaled menu appears.
//
// The id comes off the row's React FIBER first (same proven path the
// long-press navigation uses in drawer-close.ts:249). The old title/group
// heuristic is kept only as a fallback: on 0.2.0-rc.2 the `_projectRow`
// group header no longer sits inside `_groupSection`, so duplicate titles
// (three rows all called 「你好」) could not be disambiguated and the delete
// tap died with 「无法确定要删除的会话」.
const onDocumentClick = (event: MouseEvent): void => {
const target = event.target as HTMLElement | null
if (target === null) return
const row = target.closest('[class*="_sessionRow"]')
if (row === null) return
const button = menuButtonOf(row)
if (button === null) return
const title = row.querySelector('[class*="_title"]')?.textContent?.trim() ?? ''
const sessionId = rowSessionId(row)
anchor = { button, title, sessionId }
scheduleInject()
}
document.addEventListener('click', onDocumentClick, true)
// Re-inject whenever a menu list mounts/updates (React recreates the list
// on every open, so the injected node must follow).
const observer = new MutationObserver((records) => {
for (const record of records) {
if (record.type !== 'childList') continue
const target = record.target
if (target === document.body) { scheduleInject(); break }
if (target instanceof HTMLElement
&& (target.matches('[role="menu"]') || target.closest('[role="menu"]') !== null)) {
scheduleInject()
break
}
}
})
observer.observe(document.body, { childList: true, subtree: true })
injectAll()
return () => {
document.removeEventListener('click', onDocumentClick, true)
observer.disconnect()
if (injectRaf !== 0) cancelAnimationFrame(injectRaf)
closeDialog()
// Remove the injected items (and with them their click listeners).
for (const clone of injected) clone.remove()
injected.clear()
anchor = null
}
}, TOUCH_QUERY)
}