@shendeguize/dsh-agent-sidecar 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +78 -11
  2. package/lib/client.js +8659 -7589
  3. package/lib/client.js.map +1 -1
  4. package/package.json +3 -1
  5. package/src/client/analysis/AnalysisPanel.tsx +19 -27
  6. package/src/client/analysis/analysis.module.css +36 -97
  7. package/src/client/board/Board.tsx +115 -61
  8. package/src/client/board/board.module.css +72 -151
  9. package/src/client/board/project-view-logic.ts +21 -34
  10. package/src/client/board/project-view.module.css +41 -96
  11. package/src/client/board/project-view.tsx +29 -13
  12. package/src/client/board/strings.ts +68 -107
  13. package/src/client/commands.ts +30 -15
  14. package/src/client/detail/SessionDetail.tsx +92 -58
  15. package/src/client/detail/detail.module.css +62 -218
  16. package/src/client/detail/strings.ts +64 -88
  17. package/src/client/detail-view.module.css +30 -55
  18. package/src/client/detail-view.tsx +80 -108
  19. package/src/client/dsh-tools/LineageTree.tsx +22 -13
  20. package/src/client/dsh-tools/SearchPanel.tsx +18 -11
  21. package/src/client/dsh-tools/dsh-tools.module.css +50 -139
  22. package/src/client/dsh-tools/strings.ts +42 -77
  23. package/src/client/index.ts +285 -124
  24. package/src/client/inject/InjectPanel.tsx +31 -64
  25. package/src/client/inject/inject.module.css +97 -198
  26. package/src/client/lifecycle/handoff.ts +83 -0
  27. package/src/client/locales/en.ts +74 -2
  28. package/src/client/locales/host.ts +131 -0
  29. package/src/client/locales/index.ts +196 -8
  30. package/src/client/locales/react.ts +10 -0
  31. package/src/client/locales/view.ts +38 -0
  32. package/src/client/locales/zh.ts +194 -134
  33. package/src/client/mount.tsx +72 -38
  34. package/src/client/navigation/CenterOverlay.tsx +40 -0
  35. package/src/client/navigation/center-overlay.module.css +51 -0
  36. package/src/client/navigation/center.ts +45 -0
  37. package/src/client/navigation/modal-isolation.ts +152 -0
  38. package/src/client/navigation/modal-surface-anchor.ts +72 -0
  39. package/src/client/navigation/sidebar-entry.module.css +73 -0
  40. package/src/client/navigation/sidebar-entry.ts +309 -0
  41. package/src/client/primitives/StaticPill.tsx +21 -0
  42. package/src/client/settings-card.module.css +35 -119
  43. package/src/client/settings-card.tsx +31 -153
  44. package/src/client/settings-fields.tsx +131 -0
  45. package/src/client/sidebar/SidebarTab.tsx +156 -0
  46. package/src/client/sidebar/model.ts +65 -0
  47. package/src/client/sidebar/sidebar-tab.module.css +118 -0
  48. package/src/client/sidebar-tab.tsx +46 -320
  49. package/src/client/theme/agsc.module.css +31 -0
  50. package/src/client/theme/parts.ts +56 -0
  51. package/src/client/ui-integration.ts +86 -0
  52. package/src/client/widget.tsx +9 -8
  53. package/src/client/inject/overlay.module.css +0 -22
@@ -0,0 +1,51 @@
1
+ .dialog {
2
+ gap: 0;
3
+ width: min(1180px, calc(100vw - 48px));
4
+ height: min(860px, calc(100vh - 48px));
5
+ max-height: calc(100vh - 48px);
6
+ padding: 0;
7
+ border-color: var(--agsc-border-strong);
8
+ border-radius: var(--agsc-radius-card);
9
+ background: var(--agsc-bg);
10
+ box-shadow: var(--agsc-shadow-card);
11
+ }
12
+
13
+ .content {
14
+ min-height: 0;
15
+ height: 100%;
16
+ overflow: hidden;
17
+ }
18
+
19
+ .content > :last-child {
20
+ flex: 1;
21
+ min-height: 0;
22
+ margin-top: 0;
23
+ padding: 0;
24
+ overflow: hidden;
25
+ }
26
+
27
+ .surface {
28
+ display: flex;
29
+ flex-direction: column;
30
+ min-width: 0;
31
+ min-height: 0;
32
+ height: 100%;
33
+ color: var(--agsc-fg);
34
+ background: var(--agsc-bg);
35
+ overflow: hidden;
36
+ }
37
+
38
+ .surface > :last-child {
39
+ flex: 1;
40
+ min-height: 0;
41
+ height: auto;
42
+ }
43
+
44
+ @media (max-width: 720px) {
45
+ .dialog {
46
+ width: calc(100vw - 16px);
47
+ height: calc(100dvh - 16px);
48
+ max-height: calc(100dvh - 16px);
49
+ border-radius: var(--agsc-radius-control);
50
+ }
51
+ }
@@ -0,0 +1,45 @@
1
+ /** Callback port used by UI entry points that open Agent Center. */
2
+ export type CenterNavigation = () => boolean
3
+
4
+ /** Observable state port bound to the shell overlay at the composition root. */
5
+ export interface CenterNavigationStore {
6
+ readonly open: CenterNavigation
7
+ close(): void
8
+ subscribe(listener: () => void): () => void
9
+ getSnapshot(): boolean
10
+ }
11
+
12
+ /**
13
+ * Create one DOM-free navigation source for every Agent Center entry point.
14
+ * Opening is always accepted; a shell overlay that mounts later observes the
15
+ * retained snapshot instead of losing the request.
16
+ */
17
+ export function createCenterNavigation(): CenterNavigationStore {
18
+ let isOpen = false
19
+ const listeners = new Set<() => void>()
20
+ const notify = (): void => {
21
+ for (const listener of [...listeners]) listener()
22
+ }
23
+
24
+ const open: CenterNavigation = () => {
25
+ if (!isOpen) {
26
+ isOpen = true
27
+ notify()
28
+ }
29
+ return true
30
+ }
31
+
32
+ return {
33
+ open,
34
+ close: () => {
35
+ if (!isOpen) return
36
+ isOpen = false
37
+ notify()
38
+ },
39
+ subscribe: (listener) => {
40
+ listeners.add(listener)
41
+ return () => { listeners.delete(listener) }
42
+ },
43
+ getSnapshot: () => isOpen,
44
+ }
45
+ }
@@ -0,0 +1,152 @@
1
+ import { useEffect } from 'react'
2
+ import type { RefObject } from 'react'
3
+ const DIALOG_SELECTOR = '[role="dialog"][aria-modal="true"]'
4
+ const FOCUSABLE_SELECTOR = [
5
+ 'a[href],area[href],button:not([disabled])',
6
+ 'input:not([disabled]):not([type="hidden"]),select:not([disabled])',
7
+ 'textarea:not([disabled]),iframe,[contenteditable="true"],[tabindex]',
8
+ ].join(',')
9
+ function focusableElements(dialog: HTMLElement): HTMLElement[] {
10
+ return Array.from(dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
11
+ .filter((element) =>
12
+ element.tabIndex >= 0 &&
13
+ !element.hidden &&
14
+ element.closest('[inert],[aria-hidden="true"]') === null &&
15
+ element.getClientRects().length > 0)
16
+ }
17
+ function restorableIn(element: HTMLElement | null, dialog: HTMLElement): element is HTMLElement {
18
+ return element?.isConnected === true &&
19
+ dialog.contains(element) &&
20
+ !element.hidden &&
21
+ element.closest('[inert],[aria-hidden="true"]') === null &&
22
+ element.getClientRects().length > 0
23
+ }
24
+ type DialogFrame = { dialog: HTMLElement; opener: HTMLElement | null; focus: HTMLElement | null }
25
+ /** Isolate the active official Modal, including sibling-portaled nested dialogs. */
26
+ export function useModalIsolation(
27
+ open: boolean,
28
+ surfaceRef: RefObject<HTMLElement>,
29
+ ): void {
30
+ useEffect(() => {
31
+ if (!open || typeof document === 'undefined' || typeof window === 'undefined') return
32
+ const body = document.body
33
+ if (body === null || typeof window.MutationObserver === 'undefined') return
34
+ const previousFocus =
35
+ document.activeElement instanceof HTMLElement ? document.activeElement : null
36
+ const originalInert = new Map<HTMLElement, boolean>()
37
+ const dialogStack: DialogFrame[] = []
38
+ let topDialog: HTMLElement | null = null
39
+ let focusFrame: number | null = null
40
+ const getTopDialog = (): HTMLElement | null => {
41
+ const outer = surfaceRef.current?.closest<HTMLElement>(DIALOG_SELECTOR) ?? null
42
+ if (outer === null) return null
43
+ const dialogs = Array.from(body.querySelectorAll<HTMLElement>(DIALOG_SELECTOR))
44
+ const outerIndex = dialogs.indexOf(outer)
45
+ return outerIndex < 0 ? null : dialogs.at(-1) ?? null
46
+ }
47
+ const bodyBranch = (dialog: HTMLElement): HTMLElement | null => {
48
+ let branch: Node = dialog
49
+ while (branch.parentNode !== null && branch.parentNode !== body) {
50
+ branch = branch.parentNode
51
+ }
52
+ return branch instanceof HTMLElement ? branch : null
53
+ }
54
+ const isolateAround = (dialog: HTMLElement | null): void => {
55
+ const activeBranch = dialog === null ? null : bodyBranch(dialog)
56
+ const next = new Set(
57
+ Array.from(body.children)
58
+ .filter((element): element is HTMLElement =>
59
+ element instanceof HTMLElement && element !== activeBranch),
60
+ )
61
+ for (const [element, wasInert] of originalInert) {
62
+ if (next.has(element)) continue
63
+ element.inert = wasInert
64
+ originalInert.delete(element)
65
+ }
66
+ for (const element of next) {
67
+ if (!originalInert.has(element)) originalInert.set(element, element.inert)
68
+ element.inert = true
69
+ }
70
+ }
71
+ const queueFocus = (dialog: HTMLElement, preferred: HTMLElement | null = null): void => {
72
+ if (focusFrame !== null) window.cancelAnimationFrame(focusFrame)
73
+ focusFrame = window.requestAnimationFrame(() => {
74
+ focusFrame = null
75
+ if (getTopDialog() !== dialog) return
76
+ const target = restorableIn(preferred, dialog)
77
+ ? preferred
78
+ : focusableElements(dialog)[0]
79
+ target?.focus({ preventScroll: true })
80
+ })
81
+ }
82
+ const sync = (): void => {
83
+ const next = getTopDialog()
84
+ const changed = next !== topDialog
85
+ let closed: DialogFrame[] = []
86
+ if (changed && next !== null) {
87
+ const index = dialogStack.findIndex(frame => frame.dialog === next)
88
+ if (index < 0) {
89
+ const parent = dialogStack.at(-1)
90
+ const active =
91
+ document.activeElement instanceof HTMLElement ? document.activeElement : null
92
+ const opener = parent !== undefined && restorableIn(active, parent.dialog)
93
+ ? active
94
+ : parent?.focus ?? null
95
+ dialogStack.push({ dialog: next, opener, focus: null })
96
+ } else {
97
+ closed = dialogStack.splice(index + 1)
98
+ }
99
+ }
100
+ topDialog = next
101
+ isolateAround(next)
102
+ const opener = next === null
103
+ ? null
104
+ : closed.reverse().find(frame => restorableIn(frame.opener, next))?.opener ?? null
105
+ if (
106
+ next !== null &&
107
+ (changed || !next.contains(document.activeElement))
108
+ ) {
109
+ queueFocus(next, opener)
110
+ }
111
+ }
112
+ const onFocusIn = (event: FocusEvent): void => {
113
+ if (!(event.target instanceof HTMLElement)) return
114
+ const dialog = event.target.closest<HTMLElement>(DIALOG_SELECTOR)
115
+ const frame = dialogStack.find(item => item.dialog === dialog)
116
+ if (frame !== undefined) frame.focus = event.target
117
+ }
118
+ const onKeyDown = (event: KeyboardEvent): void => {
119
+ if (event.key !== 'Tab' || event.defaultPrevented) return
120
+ const dialog = getTopDialog()
121
+ if (dialog === null) return
122
+ if (dialog !== topDialog) sync()
123
+
124
+ const focusable = focusableElements(dialog)
125
+ if (focusable.length === 0) {
126
+ event.preventDefault()
127
+ return
128
+ }
129
+ const activeIndex = focusable.indexOf(document.activeElement as HTMLElement)
130
+ const wrapsBackward = event.shiftKey && activeIndex <= 0
131
+ const wrapsForward = !event.shiftKey && activeIndex === focusable.length - 1
132
+ if (activeIndex < 0 || wrapsBackward || wrapsForward) {
133
+ event.preventDefault()
134
+ const target = event.shiftKey ? focusable.at(-1) : focusable[0]
135
+ target?.focus({ preventScroll: true })
136
+ }
137
+ }
138
+ const observer = new window.MutationObserver(sync)
139
+ observer.observe(body, { childList: true, subtree: true })
140
+ document.addEventListener('focusin', onFocusIn)
141
+ document.addEventListener('keydown', onKeyDown, true)
142
+ sync()
143
+ return () => {
144
+ observer.disconnect()
145
+ document.removeEventListener('focusin', onFocusIn)
146
+ document.removeEventListener('keydown', onKeyDown, true)
147
+ if (focusFrame !== null) window.cancelAnimationFrame(focusFrame)
148
+ for (const [element, wasInert] of originalInert) element.inert = wasInert
149
+ if (previousFocus?.isConnected === true) previousFocus.focus({ preventScroll: true })
150
+ }
151
+ }, [open, surfaceRef])
152
+ }
@@ -0,0 +1,72 @@
1
+ import { useEffect, useLayoutEffect } from 'react'
2
+ import type { RefObject } from 'react'
3
+ import type { SurfaceProps } from '../theme/parts.ts'
4
+
5
+ const DIALOG_SELECTOR = '[role="dialog"][aria-modal="true"]'
6
+ const MODAL_SURFACE_OWNER = Symbol.for(
7
+ '@shendeguize/dsh-agent-sidecar/modal-surface-anchor-owner',
8
+ )
9
+
10
+ type ModalSurfaceAttributes = Pick<
11
+ SurfaceProps,
12
+ 'data-dsh-plugin' | 'data-dsh-part'
13
+ >
14
+
15
+ export interface ModalSurfaceAnchorTarget {
16
+ setAttribute(name: string, value: string): void
17
+ removeAttribute(name: string): void
18
+ }
19
+
20
+ /** Use a pre-paint effect in the browser without warning during SSR. */
21
+ export function selectModalSurfaceAnchorEffect(
22
+ hasDocument: boolean,
23
+ ): typeof useEffect {
24
+ return hasDocument ? useLayoutEffect : useEffect
25
+ }
26
+
27
+ const useIsomorphicLayoutEffect = selectModalSurfaceAnchorEffect(
28
+ typeof document !== 'undefined',
29
+ )
30
+
31
+ function ownerSlot(target: ModalSurfaceAnchorTarget): Record<PropertyKey, unknown> {
32
+ return target as unknown as Record<PropertyKey, unknown>
33
+ }
34
+
35
+ /** Attach the public surface attributes with latest-owner-safe cleanup. */
36
+ export function attachModalSurfaceAnchor(
37
+ target: ModalSurfaceAnchorTarget | null,
38
+ attributes: ModalSurfaceAttributes,
39
+ ): () => void {
40
+ if (target === null) return () => {}
41
+
42
+ const owner = Symbol('modal-surface-anchor')
43
+ const slot = ownerSlot(target)
44
+ slot[MODAL_SURFACE_OWNER] = owner
45
+ target.setAttribute('data-dsh-plugin', attributes['data-dsh-plugin'])
46
+ target.setAttribute('data-dsh-part', attributes['data-dsh-part'])
47
+
48
+ return () => {
49
+ if (slot[MODAL_SURFACE_OWNER] !== owner) return
50
+ target.removeAttribute('data-dsh-plugin')
51
+ target.removeAttribute('data-dsh-part')
52
+ delete slot[MODAL_SURFACE_OWNER]
53
+ }
54
+ }
55
+
56
+ /** Commit the public anchor onto the official Modal's real dialog element. */
57
+ export function useModalSurfaceAnchor(
58
+ open: boolean,
59
+ surfaceRef: RefObject<HTMLElement>,
60
+ attributes: ModalSurfaceAttributes,
61
+ ): void {
62
+ useIsomorphicLayoutEffect(() => {
63
+ if (!open || typeof document === 'undefined') return
64
+ const dialog = surfaceRef.current?.closest<HTMLElement>(DIALOG_SELECTOR) ?? null
65
+ return attachModalSurfaceAnchor(dialog, attributes)
66
+ }, [
67
+ open,
68
+ surfaceRef,
69
+ attributes['data-dsh-plugin'],
70
+ attributes['data-dsh-part'],
71
+ ])
72
+ }
@@ -0,0 +1,73 @@
1
+ .entry {
2
+ box-sizing: border-box;
3
+ display: flex;
4
+ align-items: center;
5
+ gap: 8px;
6
+ width: 100%;
7
+ height: 36px;
8
+ padding: 0 10px;
9
+ border: 0;
10
+ border-radius: var(--agsc-radius-control);
11
+ background: none;
12
+ color: var(--agsc-fg-secondary);
13
+ cursor: pointer;
14
+ font: inherit;
15
+ font-size: 13px;
16
+ text-align: left;
17
+ white-space: nowrap;
18
+ transition: background-color 120ms ease, color 120ms ease, transform 120ms ease;
19
+ }
20
+
21
+ .entry:hover {
22
+ background: var(--dsw-alias-interactive-bg-hover);
23
+ color: var(--agsc-fg);
24
+ }
25
+
26
+ .entry:active {
27
+ background: var(--dsw-alias-interactive-bg-active);
28
+ color: var(--agsc-fg);
29
+ transform: translateY(1px);
30
+ }
31
+
32
+ .entry:focus-visible {
33
+ outline: 2px solid var(--agsc-accent);
34
+ outline-offset: 2px;
35
+ }
36
+
37
+ .entryIcon {
38
+ display: inline-flex;
39
+ flex: none;
40
+ align-items: center;
41
+ justify-content: center;
42
+ width: 24px;
43
+ height: 24px;
44
+ }
45
+
46
+ .entryIcon svg {
47
+ display: block;
48
+ width: 16px;
49
+ height: 16px;
50
+ }
51
+
52
+ .entryLabel {
53
+ overflow: hidden;
54
+ text-overflow: ellipsis;
55
+ }
56
+
57
+ [data-dsh-frame][data-sidebar-collapsed] .entry {
58
+ justify-content: center;
59
+ width: 36px;
60
+ padding: 0;
61
+ margin: 0 auto 12px;
62
+ border-radius: 50%;
63
+ }
64
+
65
+ [data-dsh-frame][data-sidebar-collapsed] .entryLabel {
66
+ display: none;
67
+ }
68
+
69
+ @media (prefers-reduced-motion: reduce) {
70
+ .entry {
71
+ transition: none;
72
+ }
73
+ }
@@ -0,0 +1,309 @@
1
+ /** First-class Agent Center row inserted after the shell's New Session row. */
2
+ import type { CenterNavigation } from './center.ts'
3
+ import css from './sidebar-entry.module.css'
4
+ import { PLUGIN_DOM_ID, surfaceProps } from '../theme/parts.ts'
5
+
6
+ export const SIDEBAR_ENTRY_SELECTOR = '[data-agent-sidecar-sidebar-entry]'
7
+ const ENTRY_ATTRIBUTE = 'data-agent-sidecar-sidebar-entry'
8
+ const LABEL_ATTRIBUTE = 'data-agent-sidecar-sidebar-entry-label'
9
+ const SVG_NS = 'http://www.w3.org/2000/svg'
10
+ const ENTRY_BINDING = Symbol.for('@shendeguize/dsh-agent-sidecar/sidebar-entry-binding')
11
+ const ENTRY_BRAND = Symbol.for('@shendeguize/dsh-agent-sidecar/sidebar-entry-brand')
12
+ const ENTRY_DISPATCHER = Symbol.for('@shendeguize/dsh-agent-sidecar/sidebar-entry-dispatcher')
13
+ const warnedForeignEntries = new WeakSet<object>()
14
+
15
+ interface QueryScope { querySelector(selector: string): unknown | null }
16
+ export interface SidebarEntryCopyPort {
17
+ readonly label: string
18
+ readonly accessibilityLabel: string
19
+ subscribe(listener: () => void): () => void
20
+ }
21
+ export interface SidebarEntryCopyTarget {
22
+ readonly button: {
23
+ title: string
24
+ setAttribute(name: string, value: string): void
25
+ }
26
+ readonly label: { textContent: string | null }
27
+ }
28
+ interface SidebarEntryElements extends SidebarEntryCopyTarget { readonly button: HTMLButtonElement }
29
+ interface SidebarEntryObserver { disconnect(): void }
30
+ interface SidebarEntryBinding {
31
+ readonly owner: object
32
+ readonly openCenter: CenterNavigation
33
+ stopCopy: () => void
34
+ observer: SidebarEntryObserver | null
35
+ }
36
+ export interface SidebarEntryOwnerTarget extends SidebarEntryCopyTarget {
37
+ readonly button: SidebarEntryCopyTarget['button'] & { remove(): void }
38
+ }
39
+ /** Narrow, DOM-independent idempotency check used by mount and unit tests. */
40
+ export function hasSidebarEntry(scope: QueryScope): boolean {
41
+ return scope.querySelector(SIDEBAR_ENTRY_SELECTOR) !== null
42
+ }
43
+ export function applyCopy(target: SidebarEntryCopyTarget, copy: SidebarEntryCopyPort): void {
44
+ target.label.textContent = copy.label
45
+ target.button.setAttribute('aria-label', copy.accessibilityLabel)
46
+ target.button.title = copy.accessibilityLabel
47
+ }
48
+
49
+ export function bindSidebarEntryCopy(
50
+ target: SidebarEntryCopyTarget,
51
+ copy: SidebarEntryCopyPort,
52
+ ): () => void {
53
+ const update = (): void => { applyCopy(target, copy) }
54
+ update()
55
+ return copy.subscribe(update)
56
+ }
57
+
58
+ function bindingOf(button: object): SidebarEntryBinding | undefined {
59
+ return (button as Record<PropertyKey, unknown>)[ENTRY_BINDING] as SidebarEntryBinding | undefined
60
+ }
61
+ function hasCurrentDispatcher(button: object): boolean {
62
+ const record = button as Record<PropertyKey, unknown>
63
+ return (record[ENTRY_BRAND] === true || record[ENTRY_BINDING] !== undefined)
64
+ && record[ENTRY_DISPATCHER] === true
65
+ }
66
+ /** A pure, DOM-independent ownership check; the idempotency attribute alone is foreign. */
67
+ export function isOwnedSidebarEntry(candidate: unknown): boolean {
68
+ if (candidate === null
69
+ || (typeof candidate !== 'object' && typeof candidate !== 'function')) return false
70
+ const record = candidate as Record<PropertyKey, unknown>
71
+ if (record[ENTRY_BRAND] === true || record[ENTRY_BINDING] !== undefined) return true
72
+ const element = candidate as {
73
+ tagName?: unknown
74
+ getAttribute?: (name: string) => string | null
75
+ querySelector?: (selector: string) => unknown | null
76
+ }
77
+ return element.tagName === 'BUTTON'
78
+ && element.getAttribute?.('data-dsh-plugin') === PLUGIN_DOM_ID
79
+ && element.getAttribute?.('data-dsh-part') === 'sidebar-entry'
80
+ && element.querySelector?.(`[${LABEL_ATTRIBUTE}]`) != null
81
+ }
82
+
83
+ function brandSidebarEntry(button: object): void {
84
+ (button as Record<PropertyKey, unknown>)[ENTRY_BRAND] = true
85
+ }
86
+ function installClickDispatcher(button: HTMLButtonElement): void {
87
+ const record = button as unknown as Record<PropertyKey, unknown>
88
+ if (record[ENTRY_DISPATCHER] === true) return
89
+ button.addEventListener('click', () => { openBoundSidebarEntry(button) })
90
+ record[ENTRY_DISPATCHER] = true
91
+ }
92
+ function once(dispose: () => void): () => void {
93
+ let active = true
94
+ return () => {
95
+ if (!active) return
96
+ active = false
97
+ dispose()
98
+ }
99
+ }
100
+ /** Invoke the latest cross-bundle binding, never a captured HMR closure. */
101
+ export function openBoundSidebarEntry(button: object): void {
102
+ try {
103
+ bindingOf(button)?.openCenter()
104
+ } catch {
105
+ // Navigation is a best-effort bridge to host-owned tab DOM.
106
+ }
107
+ }
108
+ /** Latest-owner-wins binding over one shared DOM button. */
109
+ export function bindSidebarEntryOwner(
110
+ target: SidebarEntryOwnerTarget,
111
+ owner: object,
112
+ openCenter: CenterNavigation,
113
+ copy: SidebarEntryCopyPort,
114
+ startObserver: () => SidebarEntryObserver | null,
115
+ ): () => void {
116
+ const previous = bindingOf(target.button)
117
+ previous?.observer?.disconnect()
118
+ previous?.stopCopy()
119
+
120
+ const binding: SidebarEntryBinding = {
121
+ owner,
122
+ openCenter,
123
+ stopCopy: () => {},
124
+ observer: null,
125
+ }
126
+ const sharedButton = target.button as unknown as Record<PropertyKey, unknown>
127
+ sharedButton[ENTRY_BINDING] = binding
128
+ binding.stopCopy = once(bindSidebarEntryCopy(target, copy))
129
+ binding.observer = startObserver()
130
+
131
+ return () => {
132
+ binding.observer?.disconnect()
133
+ if (bindingOf(target.button)?.owner !== owner) return
134
+ binding.stopCopy()
135
+ delete sharedButton[ENTRY_BINDING]
136
+ target.button.remove()
137
+ }
138
+ }
139
+
140
+ function createIcon(): SVGSVGElement {
141
+ const icon = document.createElementNS(SVG_NS, 'svg')
142
+ for (const [name, value] of Object.entries({
143
+ viewBox: '0 0 16 16',
144
+ width: '16',
145
+ height: '16',
146
+ fill: 'none',
147
+ stroke: 'currentColor',
148
+ 'stroke-width': '1.4',
149
+ 'stroke-linecap': 'round',
150
+ 'stroke-linejoin': 'round',
151
+ 'aria-hidden': 'true',
152
+ focusable: 'false',
153
+ })) {
154
+ icon.setAttribute(name, value)
155
+ }
156
+ const orbit = document.createElementNS(SVG_NS, 'circle')
157
+ orbit.setAttribute('cx', '8')
158
+ orbit.setAttribute('cy', '8')
159
+ orbit.setAttribute('r', '5.5')
160
+ const center = document.createElementNS(SVG_NS, 'circle')
161
+ center.setAttribute('cx', '8')
162
+ center.setAttribute('cy', '8')
163
+ center.setAttribute('r', '1.75')
164
+ const path = document.createElementNS(SVG_NS, 'path')
165
+ path.setAttribute('d', 'M8 2.5v3.75M8 9.75v3.75M2.5 8h3.75M9.75 8h3.75')
166
+ icon.append(orbit, center, path)
167
+ return icon
168
+ }
169
+
170
+ function createEntry(): SidebarEntryElements {
171
+ const entry = document.createElement('button')
172
+ brandSidebarEntry(entry)
173
+ const surface = surfaceProps('sidebar-entry', css.entry)
174
+ entry.type = 'button'
175
+ entry.className = surface.className
176
+ entry.setAttribute(ENTRY_ATTRIBUTE, '')
177
+ entry.setAttribute('data-dsh-plugin', surface['data-dsh-plugin'])
178
+ entry.setAttribute('data-dsh-part', surface['data-dsh-part'])
179
+
180
+ const icon = document.createElement('span')
181
+ icon.className = css.entryIcon ?? ''
182
+ icon.setAttribute('aria-hidden', 'true')
183
+ icon.appendChild(createIcon())
184
+ const label = document.createElement('span')
185
+ label.className = css.entryLabel ?? ''
186
+ label.setAttribute(LABEL_ATTRIBUTE, '')
187
+ entry.append(icon, label)
188
+ installClickDispatcher(entry)
189
+ return { button: entry, label }
190
+ }
191
+
192
+ function existingEntry(button: HTMLButtonElement): SidebarEntryElements | null {
193
+ const label = button.querySelector<HTMLElement>(`[${LABEL_ATTRIBUTE}]`)
194
+ ?? (button.lastElementChild as HTMLElement | null)
195
+ return label === null ? null : { button, label }
196
+ }
197
+
198
+ function rejectLegacyEntry(): null {
199
+ console.warn('[agent-sidecar] Sidebar legacy entry cannot be safely replaced; leaving it untouched.')
200
+ return null
201
+ }
202
+
203
+ function replaceLegacyEntry(button: HTMLButtonElement): SidebarEntryElements | null {
204
+ if (typeof button.cloneNode !== 'function' || typeof button.replaceWith !== 'function') {
205
+ return rejectLegacyEntry()
206
+ }
207
+ let clone: HTMLButtonElement
208
+ try {
209
+ clone = button.cloneNode(true) as HTMLButtonElement
210
+ } catch {
211
+ return rejectLegacyEntry()
212
+ }
213
+ const elements = clone.tagName === 'BUTTON' && typeof clone.addEventListener === 'function'
214
+ ? existingEntry(clone)
215
+ : null
216
+ if (elements === null) return rejectLegacyEntry()
217
+ try {
218
+ button.replaceWith(clone)
219
+ } catch {
220
+ return rejectLegacyEntry()
221
+ }
222
+ brandSidebarEntry(clone)
223
+ installClickDispatcher(clone)
224
+ return elements
225
+ }
226
+
227
+ function sidebarRoot(): HTMLElement | null {
228
+ const column = document.querySelector<HTMLElement>(
229
+ '[data-pane="sidebar"], [class*="sidebarCol"]',
230
+ )
231
+ if (column === null) return null
232
+ const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
233
+ return logoOwner ?? (column.firstElementChild as HTMLElement | null)
234
+ }
235
+
236
+ function newSessionRow(root: HTMLElement): Element | null {
237
+ const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
238
+ const button = nested ?? Array.from(root.children).find((child) => child.tagName === 'BUTTON')
239
+ if (button === undefined) return null
240
+ const row = button.closest('[class*="logoRow"]')
241
+ if (row !== null && row.parentElement === root) return row
242
+ return button.parentElement === root ? button : null
243
+ }
244
+
245
+ function placeEntry(entry: HTMLButtonElement): boolean {
246
+ const root = sidebarRoot()
247
+ if (root === null) return false
248
+ const anchor = newSessionRow(root)
249
+ if (anchor === null) return false
250
+ if (entry.parentElement !== root || anchor.nextElementSibling !== entry) {
251
+ root.insertBefore(entry, anchor.nextElementSibling)
252
+ }
253
+ return true
254
+ }
255
+
256
+ /**
257
+ * Wait for the sidebar, restore the row after React rebuilds, and return full
258
+ * cleanup. Overlapping applies synchronously adopt the existing row.
259
+ */
260
+ export function mountSidebarEntry(
261
+ openCenter: CenterNavigation,
262
+ copy: SidebarEntryCopyPort,
263
+ ): () => void {
264
+ if (typeof document === 'undefined') return () => {}
265
+ const candidate = document.querySelector<HTMLButtonElement>(SIDEBAR_ENTRY_SELECTOR)
266
+ if (candidate !== null && !isOwnedSidebarEntry(candidate)) {
267
+ if (!warnedForeignEntries.has(candidate)) {
268
+ warnedForeignEntries.add(candidate)
269
+ console.warn('[agent-sidecar] Sidebar entry collision: refusing to modify a foreign '
270
+ + SIDEBAR_ENTRY_SELECTOR + ' node.')
271
+ }
272
+ return () => {}
273
+ }
274
+ const elements = candidate === null
275
+ ? createEntry()
276
+ : hasCurrentDispatcher(candidate)
277
+ ? existingEntry(candidate)
278
+ : replaceLegacyEntry(candidate)
279
+ if (elements === null) return () => {}
280
+ brandSidebarEntry(elements.button)
281
+ const entry = elements.button
282
+ const owner = {}
283
+ let disposed = false
284
+ const ensurePlaced = (): void => {
285
+ if (disposed) return
286
+ if (entry.isConnected) return
287
+ const existing = document.querySelector(SIDEBAR_ENTRY_SELECTOR)
288
+ if (existing !== null && existing !== entry) return
289
+ placeEntry(entry)
290
+ }
291
+ ensurePlaced()
292
+ const disposeBinding = bindSidebarEntryOwner(
293
+ elements,
294
+ owner,
295
+ openCenter,
296
+ copy,
297
+ () => {
298
+ if (typeof MutationObserver === 'undefined') return null
299
+ const observer = new MutationObserver(ensurePlaced)
300
+ observer.observe(document.body, { childList: true, subtree: true })
301
+ return observer
302
+ },
303
+ )
304
+ return () => {
305
+ if (disposed) return
306
+ disposed = true
307
+ disposeBinding()
308
+ }
309
+ }