@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.
- package/README.md +78 -11
- package/lib/client.js +8659 -7589
- package/lib/client.js.map +1 -1
- package/package.json +3 -1
- package/src/client/analysis/AnalysisPanel.tsx +19 -27
- package/src/client/analysis/analysis.module.css +36 -97
- package/src/client/board/Board.tsx +115 -61
- package/src/client/board/board.module.css +72 -151
- package/src/client/board/project-view-logic.ts +21 -34
- package/src/client/board/project-view.module.css +41 -96
- package/src/client/board/project-view.tsx +29 -13
- package/src/client/board/strings.ts +68 -107
- package/src/client/commands.ts +30 -15
- package/src/client/detail/SessionDetail.tsx +92 -58
- package/src/client/detail/detail.module.css +62 -218
- package/src/client/detail/strings.ts +64 -88
- package/src/client/detail-view.module.css +30 -55
- package/src/client/detail-view.tsx +80 -108
- package/src/client/dsh-tools/LineageTree.tsx +22 -13
- package/src/client/dsh-tools/SearchPanel.tsx +18 -11
- package/src/client/dsh-tools/dsh-tools.module.css +50 -139
- package/src/client/dsh-tools/strings.ts +42 -77
- package/src/client/index.ts +285 -124
- package/src/client/inject/InjectPanel.tsx +31 -64
- package/src/client/inject/inject.module.css +97 -198
- package/src/client/lifecycle/handoff.ts +83 -0
- package/src/client/locales/en.ts +74 -2
- package/src/client/locales/host.ts +131 -0
- package/src/client/locales/index.ts +196 -8
- package/src/client/locales/react.ts +10 -0
- package/src/client/locales/view.ts +38 -0
- package/src/client/locales/zh.ts +194 -134
- package/src/client/mount.tsx +72 -38
- package/src/client/navigation/CenterOverlay.tsx +40 -0
- package/src/client/navigation/center-overlay.module.css +51 -0
- package/src/client/navigation/center.ts +45 -0
- package/src/client/navigation/modal-isolation.ts +152 -0
- package/src/client/navigation/modal-surface-anchor.ts +72 -0
- package/src/client/navigation/sidebar-entry.module.css +73 -0
- package/src/client/navigation/sidebar-entry.ts +309 -0
- package/src/client/primitives/StaticPill.tsx +21 -0
- package/src/client/settings-card.module.css +35 -119
- package/src/client/settings-card.tsx +31 -153
- package/src/client/settings-fields.tsx +131 -0
- package/src/client/sidebar/SidebarTab.tsx +156 -0
- package/src/client/sidebar/model.ts +65 -0
- package/src/client/sidebar/sidebar-tab.module.css +118 -0
- package/src/client/sidebar-tab.tsx +46 -320
- package/src/client/theme/agsc.module.css +31 -0
- package/src/client/theme/parts.ts +56 -0
- package/src/client/ui-integration.ts +86 -0
- package/src/client/widget.tsx +9 -8
- 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
|
+
}
|