@huaqiu/dsh-tool-schematic-gen 0.1.1

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.
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Browser half of the live-progress channel.
3
+ *
4
+ * The node half writes progress into a `ProgressStore` keyed by the tool call
5
+ * id and serves it from a same-origin route; this module polls that route and
6
+ * hands the result to the card. Same-origin, so no token and no CORS.
7
+ *
8
+ * Polling (rather than SSE) is deliberate: the route already exists for the
9
+ * artifact flow, the payload is tiny, and a poll survives a dropped
10
+ * connection without any reconnect logic. The interval is slow because a run
11
+ * lasts minutes, not seconds.
12
+ */
13
+ import { useEffect, useRef, useState } from 'react'
14
+ import type { ProgressDoc } from '../progress.js'
15
+
16
+ /** Must match `PROGRESS_ROUTE_PREFIX` in src/routes.ts. */
17
+ export const PROGRESS_ROUTE_PREFIX = '/api/v1/huaqiu/schematic-gen/progress'
18
+
19
+ /** Steady-state poll interval. A run takes minutes; sub-second polling is waste. */
20
+ export const POLL_INTERVAL_MS = 1500
21
+
22
+ /** Longest interval we will back off to after repeated failures. */
23
+ export const MAX_BACKOFF_MS = 15_000
24
+
25
+ /**
26
+ * How long to keep asking when the route answers 404.
27
+ *
28
+ * A 404 is normal for the first second or two (the tool body may still be
29
+ * resolving the eda.cn account before it registers), but if it never registers
30
+ * we must stop rather than poll the app for the next ten minutes.
31
+ */
32
+ export const MISSING_GIVE_UP_MS = 120_000
33
+
34
+ export type ProgressPhase = 'idle' | 'loading' | 'gave-up' | 'live' | 'missing' | 'error'
35
+
36
+ export interface ProgressState {
37
+ phase: ProgressPhase
38
+ doc: ProgressDoc | null
39
+ error: string | null
40
+ }
41
+
42
+ const IDLE: ProgressState = { phase: 'idle', doc: null, error: null }
43
+
44
+ /**
45
+ * Poll one run's progress while `active`.
46
+ *
47
+ * Stops as soon as the run reports a terminal status, and backs off on
48
+ * transport failure so a missing/unreachable route cannot spin the UI.
49
+ */
50
+ export function useProgress(callId: string | undefined, active: boolean): ProgressState {
51
+ const [state, setState] = useState<ProgressState>(IDLE)
52
+
53
+ useEffect(() => {
54
+ if (!active || !callId) {
55
+ setState(IDLE)
56
+ return
57
+ }
58
+
59
+ let cancelled = false
60
+ let timer: ReturnType<typeof setTimeout> | null = null
61
+ let delay = POLL_INTERVAL_MS
62
+ const controller = new AbortController()
63
+ const startedAt = Date.now()
64
+ let missingSince: number | null = null
65
+
66
+ const schedule = (): void => {
67
+ if (cancelled) return
68
+ timer = setTimeout(run, delay)
69
+ }
70
+
71
+ const run = async (): Promise<void> => {
72
+ if (cancelled) return
73
+ try {
74
+ const url = `${PROGRESS_ROUTE_PREFIX}/${encodeURIComponent(callId)}`
75
+ const res = await fetch(url, {
76
+ signal: controller.signal,
77
+ headers: { accept: 'application/json' },
78
+ credentials: 'same-origin',
79
+ })
80
+ if (cancelled) return
81
+
82
+ if (res.status === 404) {
83
+ // The run has not registered yet (the tool body may still be
84
+ // resolving the account) or has already been swept. Keep trying
85
+ // for a bounded window — just slower.
86
+ if (missingSince === null) missingSince = Date.now()
87
+ if (Date.now() - missingSince > MISSING_GIVE_UP_MS) {
88
+ setState({ phase: 'gave-up', doc: null, error: null })
89
+ return
90
+ }
91
+ delay = Math.min(Math.round(delay * 1.5), MAX_BACKOFF_MS)
92
+ setState({ phase: 'missing', doc: null, error: null })
93
+ schedule()
94
+ return
95
+ }
96
+ if (!res.ok) throw new Error(`progress route returned HTTP ${res.status}`)
97
+
98
+ const doc = (await res.json()) as ProgressDoc
99
+ if (cancelled) return
100
+ delay = POLL_INTERVAL_MS
101
+ missingSince = null
102
+ setState({ phase: 'live', doc, error: null })
103
+ // Terminal — the card has the real result now; stop polling.
104
+ if (doc.status !== 'running') return
105
+ schedule()
106
+ } catch (err) {
107
+ if (cancelled || controller.signal.aborted) return
108
+ // Same bounded window as the 404 path: a route that is simply absent
109
+ // must not spin the UI for the length of the run.
110
+ if (Date.now() - startedAt > MISSING_GIVE_UP_MS) {
111
+ setState({ phase: 'gave-up', doc: null, error: String((err as Error)?.message || err) })
112
+ return
113
+ }
114
+ delay = Math.min(Math.round(delay * 1.5), MAX_BACKOFF_MS)
115
+ setState({ phase: 'error', doc: null, error: String((err as Error)?.message || err) })
116
+ schedule()
117
+ }
118
+ }
119
+
120
+ setState({ phase: 'loading', doc: null, error: null })
121
+ void run()
122
+
123
+ return () => {
124
+ cancelled = true
125
+ if (timer) clearTimeout(timer)
126
+ controller.abort()
127
+ }
128
+ }, [callId, active])
129
+
130
+ return state
131
+ }
132
+
133
+ /**
134
+ * Shared ticker that re-renders on a fixed cadence while `active`.
135
+ *
136
+ * Lifted out of the stack renderer (a documented limitation of the
137
+ * hq-eda-ai original, which owned its `setInterval` inside the list
138
+ * component) so every frame at any depth can show a live duration without
139
+ * each one running its own timer.
140
+ */
141
+ export function useNow(active: boolean, intervalMs = 200): number {
142
+ const [now, setNow] = useState<number>(() => Date.now())
143
+ const intervalRef = useRef(intervalMs)
144
+ intervalRef.current = intervalMs
145
+
146
+ useEffect(() => {
147
+ if (!active) return
148
+ setNow(Date.now())
149
+ const id = setInterval(() => setNow(Date.now()), intervalRef.current)
150
+ return () => clearInterval(id)
151
+ }, [active])
152
+
153
+ return now
154
+ }
155
+
156
+ /** Wall-clock elapsed time since `startedAt`, ticking while `active`. */
157
+ export function useElapsed(startedAt: number | null | undefined, active: boolean): number {
158
+ const now = useNow(active, 500)
159
+ if (startedAt === null || startedAt === undefined) return 0
160
+ return Math.max(0, now - startedAt)
161
+ }
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Live call-stack renderer (browser half).
3
+ *
4
+ * Port of `hq-eda-ai`'s prototype
5
+ * (`docs/prototype/tool_call_stack_prototype.tsx`) onto the paired trace
6
+ * frames our node half produces, plus the coarse stage ladder that keeps the
7
+ * user informed even when the backend emits no trace events at all.
8
+ *
9
+ * Styling goes through the classes in `theme.js` — client bundles here have no
10
+ * CSS pipeline, so the stylesheet is injected once as a `<style>` tag and
11
+ * every visual decision lives in a DSH design token with a literal fallback.
12
+ */
13
+ import { memo, useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
14
+ import type { ProgressDoc, ProgressNote, RunKind, TodoItem } from '../progress.js'
15
+ import { buildTree, countStatus, formatDuration, formatElapsed, type StackNode } from './trace-tree.js'
16
+ import { useElapsed, useNow, useProgress } from './progress.js'
17
+ import { useTraceNames } from './trace-names.js'
18
+ import type { Translate } from './i18n.js'
19
+
20
+ // ── one frame ──────────────────────────────────────────────────────────────
21
+
22
+ function dotClass(status: string): string {
23
+ if (status === 'finished') {
24
+ return 'hq-sch__frame-dot hq-sch__frame-dot--finished'
25
+ }
26
+ if (status === 'failed') {
27
+ return 'hq-sch__frame-dot hq-sch__frame-dot--failed'
28
+ }
29
+ return 'hq-sch__frame-dot hq-sch__frame-dot--running'
30
+ }
31
+
32
+ interface FrameProps {
33
+ node: StackNode
34
+ now: number
35
+ running: boolean
36
+ /**
37
+ * Identifier → display name. The stack otherwise renders the raw backend
38
+ * identifiers (`node:schematicDesign`, `es_rag_search`) verbatim.
39
+ */
40
+ resolve: (name: string) => string
41
+ }
42
+
43
+ const StackFrame = memo(function StackFrame({ node, now, running, resolve }: FrameProps): ReactElement {
44
+ const [open, setOpen] = useState(true)
45
+ const hasChildren = node.children.length > 0
46
+ const counts = countStatus(node)
47
+ const label = resolve(node.name)
48
+
49
+ const isRunning = node.status === 'running'
50
+ const endedAt = node.finishedAt
51
+ const duration = formatDuration(
52
+ endedAt != null ? endedAt - node.startedAt : (running && isRunning ? now - node.startedAt : null),
53
+ )
54
+
55
+ const nameClass = isRunning
56
+ ? 'hq-sch__frame-name hq-sch__frame-name--running'
57
+ : 'hq-sch__frame-name hq-sch__frame-name--done'
58
+
59
+ const row = (
60
+ <div
61
+ className={hasChildren ? 'hq-sch__frame-row' : 'hq-sch__frame-row hq-sch__frame-row--leaf'}
62
+ onClick={hasChildren ? () => setOpen((v) => !v) : undefined}
63
+ role={hasChildren ? 'button' : undefined}
64
+ aria-expanded={hasChildren ? open : undefined}
65
+ >
66
+ <span className="hq-sch__frame-chev">{hasChildren ? (open ? '▾' : '▸') : ''}</span>
67
+ <span className={dotClass(node.status)} />
68
+ <span className={nameClass} title={label}>{label}</span>
69
+ <span className="hq-sch__frame-meta">
70
+ {node.repeat > 1 ? <span className="hq-sch__frame-count">×{node.repeat}</span> : null}
71
+ {hasChildren && counts.total > 0
72
+ ? <span className="hq-sch__frame-count">{counts.finished}/{counts.total}</span>
73
+ : null}
74
+ {duration ? <span>{duration}</span> : null}
75
+ </span>
76
+ </div>
77
+ )
78
+
79
+ if (!hasChildren || !open) {
80
+ return row
81
+ }
82
+
83
+ return (
84
+ <div>
85
+ {row}
86
+ <div className="hq-sch__frame-children">
87
+ {node.children.map((child) => (
88
+ <StackFrame key={child.id} node={child} now={now} running={running} resolve={resolve} />
89
+ ))}
90
+ </div>
91
+ </div>
92
+ )
93
+ })
94
+
95
+ // ── the stack ─────────────────────────────────────────────────────────────
96
+
97
+ /**
98
+ * The frame list, auto-scrolled so the deepest running span stays visible.
99
+ *
100
+ * A 10-minute run can produce hundreds of frames; without this the container
101
+ * (capped at 320px) would sit pinned at the top showing work finished minutes
102
+ * ago.
103
+ */
104
+ function StackList(
105
+ { nodes, now, running, resolve }: { nodes: StackNode[]; now: number; running: boolean; resolve: (name: string) => string },
106
+ ): ReactElement {
107
+ const ref = useRef<HTMLDivElement | null>(null)
108
+
109
+ useEffect(() => {
110
+ if (!running) {
111
+ return
112
+ }
113
+ const el = ref.current
114
+ if (!el) {
115
+ return
116
+ }
117
+ el.scrollTop = el.scrollHeight
118
+ }, [nodes, running])
119
+
120
+ return (
121
+ <div className="hq-sch__stack" ref={ref}>
122
+ {nodes.map((node) => (
123
+ <StackFrame key={node.id} node={node} now={now} running={running} resolve={resolve} />
124
+ ))}
125
+ </div>
126
+ )
127
+ }
128
+
129
+ // ── stage ladder ──────────────────────────────────────────────────────────
130
+
131
+ function stepClass(index: number, active: number, failed: boolean): string {
132
+ const base = 'hq-sch__ladder-step'
133
+ if (index < active) {
134
+ return `${base} hq-sch__ladder-step--done`
135
+ }
136
+ if (index > active) {
137
+ return base
138
+ }
139
+ return failed ? `${base} hq-sch__ladder-step--failed` : `${base} hq-sch__ladder-step--active`
140
+ }
141
+
142
+ function StageLadder({ stage, failed }: { stage: ProgressDoc['stage']; failed: boolean }): ReactElement | null {
143
+ if (!stage || stage.total <= 0) {
144
+ return null
145
+ }
146
+ const steps: ReactElement[] = []
147
+ for (let i = 0; i < stage.total; i++) {
148
+ steps.push(<span key={i} className={stepClass(i, stage.index, failed)} />)
149
+ }
150
+ return <div className="hq-sch__ladder">{steps}</div>
151
+ }
152
+
153
+ // ── system-design extras ──────────────────────────────────────────────────
154
+
155
+ /**
156
+ * The agent's own words about what it is doing.
157
+ *
158
+ * `modular_circuit` publishes these from its `emitWorkflowProgress`
159
+ * middleware — one `start` announcement when a stage opens and one `complete`
160
+ * when it closes. It is the only narrative signal the system agent gives us,
161
+ * and it is what makes a 10-minute wait feel attended to.
162
+ */
163
+ function NoteLine({ note }: { note: ProgressNote }): ReactElement | null {
164
+ if (!note.message) {
165
+ return null
166
+ }
167
+ const cls = note.phase === 'error'
168
+ ? 'hq-sch__note-line hq-sch__note-line--error'
169
+ : note.phase === 'complete'
170
+ ? 'hq-sch__note-line hq-sch__note-line--complete'
171
+ : 'hq-sch__note-line'
172
+ return (
173
+ <div className={cls}>
174
+ {note.phase === 'start' ? <span className="hq-sch__note-spin" /> : null}
175
+ <span>{note.message}</span>
176
+ </div>
177
+ )
178
+ }
179
+
180
+ /** Mark glyph per todo status — no icon dependency in a client bundle. */
181
+ function todoMark(status: TodoItem['status']): string {
182
+ if (status === 'completed') {
183
+ return '✓'
184
+ }
185
+ if (status === 'in_progress') {
186
+ return '▸'
187
+ }
188
+ return '·'
189
+ }
190
+
191
+ function TodoList({ todos }: { todos: TodoItem[] }): ReactElement | null {
192
+ if (todos.length === 0) {
193
+ return null
194
+ }
195
+ return (
196
+ <ul className="hq-sch__todos">
197
+ {todos.map((todo, i) => (
198
+ <li key={i} className={`hq-sch__todo hq-sch__todo--${todo.status}`}>
199
+ <span className="hq-sch__todo-mark">{todoMark(todo.status)}</span>
200
+ <span className="hq-sch__todo-text">{todo.content}</span>
201
+ </li>
202
+ ))}
203
+ </ul>
204
+ )
205
+ }
206
+
207
+ // ── the whole live block ──────────────────────────────────────────────────
208
+
209
+ export interface LiveProgressProps {
210
+ /**
211
+ * Tool call id. This is the SAME string `defineTool`'s `ToolRunContext`
212
+ * hands the node half, so it is what correlates node progress to this card.
213
+ */
214
+ callId?: string
215
+ kind: RunKind
216
+ t: Translate
217
+ }
218
+
219
+ /**
220
+ * Progress surface for a run that has not settled yet.
221
+ *
222
+ * Three layers, each independent, so the user always sees *something*:
223
+ * 1. A label naming the current stage, plus a live `m:ss` timer.
224
+ * 2. A coarse ladder derived from the agent's own state keys — works even
225
+ * when the backend emits no trace events at all.
226
+ * 3. The real call stack, when trace events do arrive.
227
+ */
228
+ export function LiveProgress({ callId, kind, t }: LiveProgressProps): ReactElement {
229
+ const { doc } = useProgress(callId, true)
230
+
231
+ const running = !doc || doc.status === 'running'
232
+ const now = useNow(running, 250)
233
+
234
+ // Before the first poll lands there is no server timestamp; fall back to the
235
+ // moment this card mounted so the timer is never stuck at 0:00.
236
+ const mountedAtRef = useRef<number>(Date.now())
237
+ const startedAt = doc?.startedAt ?? mountedAtRef.current
238
+ const elapsed = useElapsed(startedAt, running)
239
+
240
+ const tree = useMemo(() => {
241
+ // `doc.frames` is a fresh array on every poll, so depend on `doc` itself.
242
+ void kind
243
+ return buildTree(doc?.frames ?? [])
244
+ }, [doc, kind])
245
+
246
+ // The stack renders the backend's own identifiers (`node:schematicDesign`,
247
+ // `es_rag_search`). Resolve them to copy in the host UI's language. The
248
+ // document knows the real run kind; the prop is only the caller's guess.
249
+ const resolve = useTraceNames(doc?.kind ?? kind)
250
+
251
+ const label = doc?.stage
252
+ ? t(`card.stage.${doc.stage.key}`)
253
+ : t('card.progress.waiting')
254
+
255
+ return (
256
+ <div>
257
+ <div className="hq-sch__progress">
258
+ <span>{label}</span>
259
+ <span className="hq-sch__progress-timer">{formatElapsed(elapsed)}</span>
260
+ </div>
261
+ {doc?.note ? <NoteLine note={doc.note} /> : null}
262
+ <StageLadder stage={doc?.stage ?? null} failed={doc?.status === 'failed'} />
263
+ {doc?.todos && doc.todos.length > 0 ? <TodoList todos={doc.todos} /> : null}
264
+ {tree.length > 0
265
+ ? <StackList nodes={tree} now={now} running={running} resolve={resolve} />
266
+ : null}
267
+ </div>
268
+ )
269
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Host UI environment (theme + locale) store and the scoped stylesheet for
3
+ * the schematic/system HIT card.
4
+ *
5
+ * The DSH slot system injects React components with PROPS, not the cordis ctx,
6
+ * so the card cannot reach `ctx.theme` / `ctx.locale` the way a plugin body
7
+ * can. Both services do, however, publish their state into the DOM, and that
8
+ * is what this module reads:
9
+ *
10
+ * - THEME — `ui-layout`'s theme presenter toggles `body[data-ds-dark-theme]`
11
+ * from the resolved snapshot, so the attribute's presence IS the dark
12
+ * palette. `prefers-color-scheme` is deliberately NOT consulted: DSH
13
+ * resolves `system` itself, and an OS-dark / DSH-light combination would
14
+ * then be misdetected.
15
+ * - LOCALE — `dsh-client-locale` writes `<html lang>` on every locale change
16
+ * (`zh-CN` | `en`). `navigator.languages` is the fallback for hosts without
17
+ * that plugin; zh is the last resort because this is a Chinese-first app.
18
+ *
19
+ * Stylesheet: uniquely-prefixed classes only, token colors, no !important, no
20
+ * DSH-internal selectors. The style tag is removed on dispose.
21
+ */
22
+ import { useEffect, useState } from 'react'
23
+ import { LOGIN_IFRAME_HEIGHT, type AuthLocale } from './login-url.js'
24
+
25
+ export const PLUGIN_ID = '@huaqiu/dsh-tool-schematic-gen'
26
+ export const STYLE_ID = 'hq-schematic-genhit-styles'
27
+
28
+ const DARK_ATTRIBUTE = 'data-ds-dark-theme'
29
+
30
+ function isDark(): boolean {
31
+ return !!(typeof document !== 'undefined' && document.body && document.body.hasAttribute(DARK_ATTRIBUTE))
32
+ }
33
+
34
+ /** `zh-CN`, `zh-Hans`, `en-GB`, … → our locale id (`undefined` = unknown). */
35
+ function localeFromTag(tag: string | null | undefined): AuthLocale | undefined {
36
+ if (!tag) return undefined
37
+ const primary = tag.toLowerCase().split('-')[0]
38
+ return primary === 'zh' || primary === 'en' ? primary : undefined
39
+ }
40
+
41
+ function detectLocale(): AuthLocale {
42
+ if (typeof document !== 'undefined') {
43
+ const fromDocument = localeFromTag(document.documentElement?.getAttribute('lang'))
44
+ if (fromDocument) return fromDocument
45
+ }
46
+ if (typeof navigator !== 'undefined' && typeof window !== 'undefined') {
47
+ // `window` is the browser test: Node exposes a global `navigator`
48
+ // reporting the machine's own language, which would otherwise decide the
49
+ // locale for non-browser runs.
50
+ for (const tag of [...(navigator.languages ?? []), navigator.language]) {
51
+ const match = localeFromTag(tag)
52
+ if (match) return match
53
+ }
54
+ }
55
+ return 'zh'
56
+ }
57
+
58
+ const listeners = new Set<() => void>()
59
+ let themeState = { dark: isDark(), locale: detectLocale() }
60
+ let themeObserver: MutationObserver | null = null
61
+ let localeObserver: MutationObserver | null = null
62
+
63
+ function notify(): void {
64
+ for (const fn of [...listeners]) {
65
+ try { fn() } catch { /* one bad subscriber must not strand the rest */ }
66
+ }
67
+ }
68
+
69
+ /** Re-read the DOM and notify only when something actually changed. */
70
+ function sync(): void {
71
+ let changed = false
72
+ const dark = isDark()
73
+ if (dark !== themeState.dark) {
74
+ themeState.dark = dark
75
+ changed = true
76
+ }
77
+ const locale = detectLocale()
78
+ if (locale !== themeState.locale) {
79
+ themeState.locale = locale
80
+ changed = true
81
+ }
82
+ if (changed) notify()
83
+ }
84
+
85
+ function ensureThemeObserver(): void {
86
+ if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return
87
+ if (!themeObserver && document.body) {
88
+ themeObserver = new MutationObserver(sync)
89
+ themeObserver.observe(document.body, { attributes: true, attributeFilter: [DARK_ATTRIBUTE] })
90
+ }
91
+ if (!localeObserver && document.documentElement) {
92
+ localeObserver = new MutationObserver(sync)
93
+ localeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] })
94
+ }
95
+ }
96
+
97
+ export function useTheme(): boolean {
98
+ // Read fresh on every mount. With React 18's batched mount, multiple cards
99
+ // capture the module-level snapshot at the same moment; the *first* one's
100
+ // effect then flips `themeState` via `sync()`, but only that one card ever
101
+ // re-renders because the others' `useState` has already locked onto the
102
+ // initial value. Reading the DOM directly here costs one extra attribute
103
+ // lookup per mount and fixes the silent pinning.
104
+ const [dark, setDark] = useState(() => isDark())
105
+ useEffect(() => {
106
+ ensureThemeObserver()
107
+ setDark(isDark())
108
+ const onChange = () => setDark(themeState.dark)
109
+ listeners.add(onChange)
110
+ sync()
111
+ return () => { listeners.delete(onChange) }
112
+ }, [])
113
+ return dark
114
+ }
115
+
116
+ /** The host UI language (`zh` by default). */
117
+ export function useLocale(): AuthLocale {
118
+ // Same fix as `useTheme` — fresh DOM read on mount, otherwise the second
119
+ // and later cards in a chat render in the module-load locale even though
120
+ // DSH wrote `<html lang>` before any of them mounted.
121
+ const [locale, setLocale] = useState(() => detectLocale())
122
+ useEffect(() => {
123
+ ensureThemeObserver()
124
+ setLocale(detectLocale())
125
+ const onChange = () => setLocale(themeState.locale)
126
+ listeners.add(onChange)
127
+ sync()
128
+ return () => { listeners.delete(onChange) }
129
+ }, [])
130
+ return locale
131
+ }
132
+
133
+ /**
134
+ * Synchronous, always-fresh read of the host locale (for non-React callers).
135
+ * Re-reads the DOM rather than returning the cached snapshot, so it is safe
136
+ * to call before any component has subscribed.
137
+ */
138
+ export function getLocale(): AuthLocale {
139
+ return detectLocale()
140
+ }
141
+
142
+ export function disposeThemeObserver(): void {
143
+ if (themeObserver) {
144
+ themeObserver.disconnect()
145
+ themeObserver = null
146
+ }
147
+ if (localeObserver) {
148
+ localeObserver.disconnect()
149
+ localeObserver = null
150
+ }
151
+ listeners.clear()
152
+ }
153
+
154
+ const CSS = `
155
+ .hq-sch { width: 100%; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l1, rgba(127,127,127,0.25)); border-radius: 10px; background: var(--dsw-alias-bg-layer-1, transparent); overflow: hidden; }
156
+ .hq-sch__header { display: flex; align-items: center; gap: 8px; padding: 10px 12px; }
157
+ .hq-sch__icon { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 24px; height: 24px; border-radius: 6px; background: var(--dsw-alias-interactive-bg-hover, transparent); color: var(--dsw-alias-label-primary, currentColor); }
158
+ .hq-sch__title { font: var(--dsw-font-s-strong-14, 14px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-primary, currentColor); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
159
+ .hq-sch__status { display: inline-flex; align-items: center; gap: 6px; margin-left: auto; flex: none; font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-secondary, currentColor); }
160
+ .hq-sch__summary { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; padding: 0 12px 10px; }
161
+ .hq-sch__badge { display: inline-flex; align-items: center; max-width: 240px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l1, currentColor); font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-secondary, currentColor); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
162
+ .hq-sch__badge--mono { font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); }
163
+ .hq-sch__stage { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; box-sizing: border-box; height: 420px; background: var(--dsw-alias-markdown-code-block, #0a1929); overflow: hidden; }
164
+ .hq-sch__canvas { display: block; width: 100%; height: 100%; background: var(--dsw-alias-markdown-code-block, #0a1929); }
165
+ .hq-sch__stage-msg { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 10px 14px; box-sizing: border-box; font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-secondary, currentColor); text-align: center; }
166
+ .hq-sch__error { margin: 0 12px 10px; padding: 8px 10px; border-radius: 6px; border: 1px solid var(--dsw-alias-state-error-primary, rgba(220,50,50,0.3)); color: var(--dsw-alias-state-error-primary, currentColor); font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); white-space: pre-wrap; }
167
+ .hq-sch__note { margin: 0 12px 10px; font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-secondary, currentColor); white-space: pre-wrap; }
168
+ .hq-sch__actions { display: flex; flex-wrap: wrap; gap: 8px; padding: 0 12px 12px; }
169
+ .hq-sch__act { display: inline-flex; align-items: center; gap: 4px; border: 1px solid var(--dsw-alias-border-l1, currentColor); border-radius: 6px; padding: 5px 12px; background: var(--dsw-alias-bg-layer-1, transparent); color: var(--dsw-alias-label-primary, currentColor); font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); cursor: pointer; }
170
+ .hq-sch__act:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.08)); }
171
+ .hq-sch__act:disabled { opacity: 0.5; cursor: default; }
172
+ .hq-sch__login { padding: 0 12px 12px; }
173
+ .hq-sch__login-desc { margin: 0 0 10px; font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-secondary, currentColor); line-height: 1.5; }
174
+ .hq-sch__login-status { margin: 0 0 10px; font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); line-height: 1.5; }
175
+ .hq-sch__login-iframe { width: 100%; height: ${LOGIN_IFRAME_HEIGHT}px; border: 0; border-radius: 8px; display: block; }
176
+
177
+ /* ── live call stack (long-running generations) ───────────────────────────── */
178
+ .hq-sch__progress { display: flex; align-items: center; gap: 8px; padding: 0 12px 8px; font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-secondary, currentColor); }
179
+ .hq-sch__progress-timer { margin-left: auto; flex: none; font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); }
180
+ .hq-sch__ladder { display: flex; gap: 3px; padding: 0 12px 4px; }
181
+ .hq-sch__ladder-step { height: 3px; flex: 1 1 0; border-radius: 3px; background: var(--dsw-alias-border-l1, rgba(127,127,127,0.25)); }
182
+ .hq-sch__ladder-step--done { background: var(--dsw-alias-state-success-primary, #188038); }
183
+ .hq-sch__ladder-step--active { background: var(--dsw-alias-state-running-primary, #1a73e8); }
184
+ .hq-sch__ladder-step--failed { background: var(--dsw-alias-state-error-primary, #d93025); }
185
+ .hq-sch__stack { max-height: 320px; overflow-y: auto; padding: 2px 12px 12px; }
186
+ .hq-sch__frame-row { display: flex; align-items: center; gap: 6px; padding: 2px 6px; border-radius: 6px; cursor: pointer; }
187
+ .hq-sch__frame-row:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.08)); }
188
+ .hq-sch__frame-row--leaf { cursor: default; }
189
+ .hq-sch__frame-chev { flex: none; width: 12px; font-size: 9px; line-height: 1; color: var(--dsw-alias-label-tertiary, currentColor); }
190
+ .hq-sch__frame-dot { flex: none; width: 6px; height: 6px; border-radius: 6px; background: var(--dsw-alias-border-l1, rgba(127,127,127,0.35)); }
191
+ .hq-sch__frame-dot--running { background: var(--dsw-alias-state-running-primary, #1a73e8); animation: hq-sch-pulse 1.2s ease-in-out infinite; }
192
+ .hq-sch__frame-dot--finished { background: var(--dsw-alias-state-success-primary, #188038); }
193
+ .hq-sch__frame-dot--failed { background: var(--dsw-alias-state-error-primary, #d93025); }
194
+ .hq-sch__frame-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 11px; color: var(--dsw-alias-label-primary, currentColor); }
195
+ .hq-sch__frame-name--done { color: var(--dsw-alias-label-secondary, currentColor); }
196
+ .hq-sch__frame-name--running { font-weight: 600; }
197
+ .hq-sch__frame-meta { margin-left: auto; flex: none; display: flex; align-items: center; gap: 6px; font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 10px; color: var(--dsw-alias-label-tertiary, currentColor); }
198
+ .hq-sch__frame-count { padding: 0 4px; border-radius: 999px; background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.14)); }
199
+ .hq-sch__frame-children { border-left: 1px solid var(--dsw-alias-border-l1, rgba(127,127,127,0.25)); margin-left: 11px; }
200
+ .hq-sch__progress-empty { padding: 2px 12px 12px; font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-tertiary, currentColor); }
201
+
202
+ /* ── system-design: stage announcement + live todo list ──────────────────── */
203
+ .hq-sch__note-line { display: flex; align-items: flex-start; gap: 6px; padding: 0 12px 8px; font: var(--dsw-font-xxs-12, 12px/1.5 system-ui, sans-serif); color: var(--dsw-alias-label-primary, currentColor); }
204
+ .hq-sch__note-line--complete { color: var(--dsw-alias-label-secondary, currentColor); }
205
+ .hq-sch__note-line--error { color: var(--dsw-alias-state-error-primary, #d93025); }
206
+ .hq-sch__note-spin { flex: none; width: 8px; height: 8px; margin-top: 4px; border-radius: 8px; background: var(--dsw-alias-state-running-primary, #1a73e8); animation: hq-sch-pulse 1.2s ease-in-out infinite; }
207
+ .hq-sch__todos { list-style: none; margin: 0; padding: 4px 12px 10px; max-height: 168px; overflow-y: auto; }
208
+ .hq-sch__todo { display: flex; align-items: flex-start; gap: 6px; padding: 2px 0; font: var(--dsw-font-xxs-12, 12px/1.5 system-ui, sans-serif); color: var(--dsw-alias-label-tertiary, currentColor); }
209
+ .hq-sch__todo-mark { flex: none; width: 10px; text-align: center; line-height: 1.5; }
210
+ .hq-sch__todo-text { min-width: 0; overflow-wrap: anywhere; }
211
+ .hq-sch__todo--in_progress { color: var(--dsw-alias-label-primary, currentColor); font-weight: 600; }
212
+ .hq-sch__todo--completed { color: var(--dsw-alias-label-secondary, currentColor); text-decoration: line-through; }
213
+ @keyframes hq-sch-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
214
+ `
215
+
216
+ export function injectStyles(): void {
217
+ if (document.getElementById(STYLE_ID)) return
218
+ const style = document.createElement('style')
219
+ style.id = STYLE_ID
220
+ style.setAttribute('data-plugin', PLUGIN_ID)
221
+ style.textContent = CSS
222
+ document.head.appendChild(style)
223
+ }
224
+
225
+ export function removeStyles(): void {
226
+ const style = document.getElementById(STYLE_ID)
227
+ if (style && style.parentNode) style.parentNode.removeChild(style)
228
+ }