/** * 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) }