/**
* Add a two-tab switcher above the shell's own New Session affordance:
* Session (the conversation view) and Image Generation. The shell button stays
* visible underneath, so creating a new session keeps its old position and the
* switcher never doubles as the only way out of the studio. When image
* generation is active, the plugin also uses the shell's region area as a
* dedicated history surface; the original workspace/session tree remains
* underneath and is restored when the panel closes.
*/
import type { ImageGenController } from './controller.ts'
import css from './panel.module.css'
/** Stable selector for the injected two-tab host. */
export const ENTRY_SELECTOR = '[data-dsh-imagegen-session-tabs]'
/** Stable selector for the history surface in the shell region area. */
export const HISTORY_HOST_SELECTOR = '[data-dsh-imagegen-history-host]'
/** Inline picture glyph kept deliberately small for the sidebar rail. */
const IMAGE_ICON = ''
/** Inline conversation glyph for the session-view tab. */
const SESSION_ICON = ''
/**
* Sidebar column markers: the web shell names its grid column `sidebarCol`
* (AppFrame) or exposes `data-pane="sidebar"`; DSH Desktop renders the upstream
* sidebar inside a surface of its own instead (#20).
*/
const SIDEBAR_COLUMN_SELECTOR = [
'[data-pane="sidebar"]',
'[class*="sidebarCol"]',
'[class*="dshDesktopSidebarSurface"]',
'[class*="dshDesktopUpstreamSidebar"]',
].join(', ')
/** Accessible names the shell gives its New Session affordance. */
const NEW_SESSION_LABELS = ['new session', 'new chat', '新会话', '新建会话', '新对话', '新话题']
/** Loose New Session match for shells without a stable class or data hook. */
function looksLikeNewSession(button: HTMLButtonElement): boolean {
const name = `${button.getAttribute('aria-label') ?? ''} ${button.getAttribute('title') ?? ''} ${button.textContent ?? ''}`
.toLowerCase()
.replace(/\s+/g, ' ')
return NEW_SESSION_LABELS.some((label) => name.includes(label))
}
/** Find the sidebar shell root, or undefined while it is not mounted. */
function sidebarRoot(): HTMLElement | undefined {
const column = document.querySelector(SIDEBAR_COLUMN_SELECTOR)
const logoRow = (column ?? document).querySelector('[class*="logoRow"]')
if (logoRow !== null) {
// Walk out of the brand row until an ancestor also owns the shell button:
// desktop shells put a wrapper surface between the two, so the row parent is
// not always the root the tabs have to live in (#20).
for (let candidate = logoRow.parentElement; candidate !== null; candidate = candidate.parentElement) {
if (candidate === document.body) break
if (newSessionButton(candidate, false) !== undefined) return candidate
if (candidate === column) break
if (column !== null && !column.contains(candidate)) break
}
// Legacy shape: with no recognisable shell button anywhere above the brand
// row its parent stays the mount point, which is where the web shell has
// always kept the tabs (#20).
return logoRow.parentElement ?? column ?? undefined
}
if (column === null) return undefined
return (column.firstElementChild as HTMLElement | undefined) ?? column
}
/**
* The shell-owned New Session button across current and legacy shells. The
* loose fallback stays opt-in so the root search never settles on a wrapper
* whose first button happens to be a brand or collapse toggle (#20).
*/
function newSessionButton(root: HTMLElement, allowLooseFallback = true): HTMLButtonElement | undefined {
const hooked = root.querySelector(
'button[data-dsh-part="new-session"], button[class*="newSession"]',
)
if (hooked !== null) return hooked
const labelled = Array.from(root.querySelectorAll('button')).find(looksLikeNewSession)
if (labelled !== undefined) return labelled
if (!allowLooseFallback) return undefined
return Array.from(root.children).find(
(child): child is HTMLButtonElement => child instanceof HTMLElement && child.tagName === 'BUTTON',
)
}
/** Locate the shell region that normally contains workspaces and sessions. */
function regionArea(root: HTMLElement): HTMLElement | undefined {
return root.querySelector('[class*="regionArea"]') ?? undefined
}
function makeTab(
label: string,
tooltip: string,
icon: string,
onClick: () => void,
): HTMLButtonElement {
const tab = document.createElement('button')
tab.type = 'button'
tab.className = css.sessionTab
tab.setAttribute('aria-label', label)
tab.setAttribute('title', tooltip)
tab.innerHTML = `${icon}${label}`
tab.addEventListener('click', onClick)
return tab
}
/**
* Mount or repair the two-tab switcher directly above the shell's New Session
* button, which stays visible: the switcher only moves between the conversation
* and the studio, it never creates a session.
*/
function placeTabs(
root: HTMLElement,
controller: ImageGenController,
sessionLabel: string,
sessionTooltip: string,
imageLabel: string,
imageTooltip: string,
): HTMLDivElement | undefined {
const button = newSessionButton(root)
if (button === undefined) return undefined
const existing = root.querySelector(ENTRY_SELECTOR)
if (existing !== null && existing.parentElement === button.parentElement) return existing
existing?.remove()
const tabs = document.createElement('div')
tabs.dataset.dshImagegenSessionTabs = ''
tabs.className = css.sessionTabs
tabs.setAttribute('role', 'tablist')
tabs.setAttribute('aria-label', imageTooltip)
const sessionTab = makeTab(sessionLabel, sessionTooltip, SESSION_ICON, () => {
controller.close()
})
const imageTab = makeTab(imageLabel, imageTooltip, IMAGE_ICON, () => {
controller.open()
})
sessionTab.dataset.dshImagegenTab = 'session'
imageTab.dataset.dshImagegenTab = 'image'
tabs.append(sessionTab, imageTab)
// The shell button keeps its own slot below the switcher, untouched.
button.parentElement?.insertBefore(tabs, button)
return tabs
}
/** Mount an overlay host over the workspace/session tree for image history. */
function placeHistoryHost(root: HTMLElement): HTMLDivElement | undefined {
const region = regionArea(root)
if (region === undefined) return undefined
const existing = region.querySelector(HISTORY_HOST_SELECTOR)
if (existing !== null) return existing
const host = document.createElement('div')
host.dataset.dshImagegenHistoryHost = ''
host.className = css.sidebarHistoryHost
region.append(host)
return host
}
/**
* Mount the two tabs and self-heal after React rebuilds the sidebar. The
* shell-owned New Session button keeps its native behavior. A temporary marker
* preserves its visibility and identifies context clicks across shell layouts.
*/
export function mountSidebarEntry(
controller: ImageGenController,
sessionLabel: string,
sessionTooltip: string,
imageLabel: string,
imageTooltip: string,
): () => void {
let root: HTMLElement | undefined
let tabs: HTMLDivElement | undefined
let historyHost: HTMLDivElement | undefined
let markedButton: HTMLButtonElement | undefined
let warnedUnmounted = false
/**
* Compact shell fingerprint for the warning below: it never dumps the sidebar
* markup (which carries session titles), only the markers we look for.
*/
const describeShell = (): string => {
const markers = SIDEBAR_COLUMN_SELECTOR.split(', ')
.map((selector) => `${selector}=${document.querySelectorAll(selector).length}`)
.join(' ')
const outline = Array.from(document.body.children)
.slice(0, 6)
.map((element) => {
const name = typeof element.className === 'string' ? element.className.split(/\s+/)[0] : ''
return `${element.tagName.toLowerCase()}${name === '' ? '' : `.${name}`}`
})
.join(' > ')
return `${markers} body>${outline}`
}
// A shell we have never seen must not fail silently: the reporter of #20 had
// no console output at all to work from.
const warnUnmounted = (): void => {
if (warnedUnmounted || tabs !== undefined) return
warnedUnmounted = true
const reason = root === undefined ? 'sidebar root not found' : 'new-session button not found'
console.warn(`[dsh-imagegen] sidebar entry stayed unmounted (${reason}); shell probe: ${describeShell()}`)
}
const unmountedTimer = window.setTimeout(warnUnmounted, 5000)
const syncActive = (): void => {
if (tabs === undefined) return
const sessionTab = tabs.querySelector('[data-dsh-imagegen-tab="session"]')
const imageTab = tabs.querySelector('[data-dsh-imagegen-tab="image"]')
if (controller.getSnapshot().panelOpen) {
if (sessionTab !== null) delete sessionTab.dataset.active
if (imageTab !== null) imageTab.dataset.active = ''
} else {
if (sessionTab !== null) sessionTab.dataset.active = ''
if (imageTab !== null) delete imageTab.dataset.active
}
}
const ensure = (): void => {
if (root !== undefined && !root.isConnected) {
markedButton?.removeAttribute('data-dsh-imagegen-new-session')
markedButton = undefined
root = undefined
tabs = undefined
historyHost = undefined
}
root ??= sidebarRoot()
if (root === undefined) return
root.dataset.dshImagegenSidebarRoot = ''
const button = newSessionButton(root)
if (button === undefined) return
if (markedButton !== button) markedButton?.removeAttribute('data-dsh-imagegen-new-session')
markedButton = button
if (!button.hasAttribute('data-dsh-imagegen-new-session')) {
button.setAttribute('data-dsh-imagegen-new-session', '')
}
tabs = placeTabs(root, controller, sessionLabel, sessionTooltip, imageLabel, imageTooltip)
historyHost = placeHistoryHost(root)
syncActive()
}
const bodyObserver = new MutationObserver(ensure)
bodyObserver.observe(document.body, { childList: true, subtree: true })
const unsubscribe = controller.subscribe(syncActive)
ensure()
return () => {
window.clearTimeout(unmountedTimer)
bodyObserver.disconnect()
unsubscribe()
tabs?.remove()
historyHost?.remove()
markedButton?.removeAttribute('data-dsh-imagegen-new-session')
if (root !== undefined) delete root.dataset.dshImagegenSidebarRoot
}
}
/**
* Internals re-exported for the standalone smoke test: the browser bundle is
* the only consumer of the entry, so the check has to drive these directly to
* cover a shell layout the jsdom studio fixture does not render (#20).
*/
export const sidebarEntryTestHooks = {
findSidebarRoot: sidebarRoot,
findNewSessionButton: newSessionButton,
mount: mountSidebarEntry,
}