dsh-side-chat-plus 0.4.0-alpha.2 → 0.4.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.
@@ -1,2283 +1,2280 @@
1
- /**
2
- * Client half of dsh-side-chat: a text-selection floating menu, a right-side
3
- * side-chat panel (drag-resizable + collapsible), the main-conversation-style
4
- * model/permission selectors and send/stop buttons, and a "Side chat" settings
5
- * section. The panel is isolated per current conversation and talks to the
6
- * host /sidechat API.
7
- */
8
- import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent } from 'react'
9
- import { useSyncExternalStore } from 'react'
10
- import { createRoot, type Root } from 'react-dom/client'
11
- import {
12
- DisclosureRow,
13
- IconCheckOutline16,
14
- IconChevronDownOutline14,
15
- IconChevronRightOutline14,
16
- IconPanelLeftOutline16,
17
- IconSendOutline14,
18
- IconStopFill16,
19
- IconThinkOutline14,
20
- MarkdownText,
21
- Menu,
22
- Tooltip,
23
- } from '@deepseek-ai/dsh-client-ui-primitives'
24
- import {
25
- AttachmentRail,
26
- DropOverlay,
27
- ImageGallery,
28
- ImageLightbox,
29
- type ImageLoader,
30
- } from './attachments/index.ts'
31
- import type { Context, SidePendingInteraction, SideQuestionItem, SideQuestionOption, SideSidebarRight, SideSidebarRightTabs } from '../context-types.ts'
32
- import {
33
- api,
34
- type PromptContentPart,
35
- type SidechatDirectory,
36
- type SidechatImageRef,
37
- type SidechatListItem,
38
- type SidechatMessage,
39
- type SidechatPermissions,
40
- } from './api.ts'
41
- import { en, LOCALE_NS, zh, type SidechatLocaleKey } from './locales.ts'
42
- import { SUBCHAT_PREFS_DEFAULTS, type SubchatPrefs } from '../settings-shared.ts'
43
- import css from './client.module.css'
44
- import './layout.css'
45
-
46
- /** Services required before mounting. */
47
- export const inject = ['sessions', 'locale', 'slots', 'conversation', 'uiSession']
48
-
49
- /** A text selection the floating menu anchors to. */
50
- interface SelectionAnchor {
51
- text: string
52
- x: number
53
- y: number
54
- }
55
-
56
- /** The panel UI state, per current parent conversation. */
57
- interface PanelState {
58
- open: boolean
59
- parentSessionId: string
60
- activeChildId: string | null
61
- items: SidechatListItem[]
62
- messages: SidechatMessage[]
63
- draft: string
64
- /** Staged selection shown as an attachment while "send immediately" is off. */
65
- attachment: string | null
66
- /** Browser-owned draft images (object URLs); serialized on send. */
67
- attachments: ComposerAttachment[]
68
- lookup: boolean
69
- directory: SidechatDirectory | null
70
- permissions: SidechatPermissions | null
71
- provider: string
72
- model: string
73
- effort: string
74
- preset: string
75
- /** Which selector the command menu asked to open (consumed once). */
76
- commandOpen: 'model' | 'permission' | null
77
- planActive: boolean
78
- planPending: boolean
79
- goalObjective: string | null
80
- error: string | null
81
- }
82
-
83
- /** The whole browser-side snapshot. */
84
- interface SidechatSnapshot {
85
- current: string | undefined
86
- panel: PanelState
87
- anchor: SelectionAnchor | null
88
- prefs: SubchatPrefs
89
- /** The current main conversation's pending user-question dialog (null = none). */
90
- mainQuestion: SideQuestionItem[] | null
91
- /** Question ids the user deleted from the panel list. */
92
- dismissedQuestionIds: string[]
93
- }
94
-
95
- /** The whole browser-side store (one per activation). */
96
- interface SidechatStore {
97
- getSnapshot(): SidechatSnapshot
98
- subscribe(fn: () => void): () => void
99
- setCurrent(current: string | undefined): void
100
- setAnchor(anchor: SelectionAnchor | null): void
101
- setPrefs(prefs: SubchatPrefs): void
102
- setMainQuestion(questions: SideQuestionItem[] | null): void
103
- dismissQuestion(id: string): void
104
- dismissAllQuestions(ids: string[]): void
105
- openPanel(parentSessionId: string): void
106
- closePanel(): void
107
- /** Install (or clear) the dock-mode launcher; openPanel() fires it after opening. */
108
- setDockLaunch(fn: (() => void) | null): void
109
- setActive(childId: string): void
110
- patch(partial: Partial<PanelState>): void
111
- }
112
-
113
- function emptyPanel(): PanelState {
114
- return {
115
- open: false,
116
- parentSessionId: '',
117
- activeChildId: null,
118
- items: [],
119
- messages: [],
120
- draft: '',
121
- attachment: null,
122
- attachments: [],
123
- lookup: false,
124
- directory: null,
125
- permissions: null,
126
- provider: '',
127
- model: '',
128
- effort: '',
129
- preset: '',
130
- commandOpen: null,
131
- planActive: false,
132
- planPending: false,
133
- goalObjective: null,
134
- error: null,
135
- }
136
- }
137
-
138
- /** Create the browser store (one instance per activation, per the factory rule). */
139
- function createStore(): SidechatStore {
140
- let current: string | undefined
141
- let panel: PanelState = emptyPanel()
142
- let anchor: SelectionAnchor | null = null
143
- let prefs: SubchatPrefs = { ...SUBCHAT_PREFS_DEFAULTS }
144
- let mainQuestion: SideQuestionItem[] | null = null
145
- let dismissedQuestionIds: string[] = []
146
- // Per-conversation panel state so switching away and back restores the side
147
- // chats instead of resetting them. The side chats stay live on the host, so
148
- // the client must remember each conversation's open panel + active child.
149
- const bySession = new Map<string, PanelState>()
150
- const listeners = new Set<() => void>()
151
- // Dock mode: while the panel lives in the built-in right sidebar, opening the
152
- // panel also reveals the tab (registered by the dock effect; null otherwise).
153
- let dockLaunch: (() => void) | null = null
154
- // Cached snapshot: useSyncExternalStore compares identity, so the object is
155
- // only rebuilt on a mutation — never inside getSnapshot itself.
156
- let snapshot: SidechatSnapshot = { current, panel, anchor, prefs, mainQuestion, dismissedQuestionIds }
157
-
158
- const notify = (): void => {
159
- snapshot = { current, panel, anchor, prefs, mainQuestion, dismissedQuestionIds }
160
- for (const fn of [...listeners]) fn()
161
- }
162
-
163
- return {
164
- getSnapshot: () => snapshot,
165
- subscribe: (fn) => {
166
- listeners.add(fn)
167
- return () => { listeners.delete(fn) }
168
- },
169
- setCurrent(next) {
170
- if (next === current) return
171
- if (current !== undefined) bySession.set(current, panel)
172
- current = next
173
- panel = next === undefined
174
- ? emptyPanel()
175
- : (bySession.get(next) ?? { ...emptyPanel(), parentSessionId: next, lookup: prefs.lookupDefault })
176
- anchor = null
177
- mainQuestion = null
178
- dismissedQuestionIds = []
179
- notify()
180
- },
181
- setAnchor(next) {
182
- anchor = next
183
- notify()
184
- },
185
- setPrefs(next) {
186
- prefs = next
187
- notify()
188
- },
189
- setMainQuestion(questions) {
190
- mainQuestion = questions
191
- // Keep dismissal state: a dismissed question must not reappear just
192
- // because the pending snapshot re-publishes while the dialog is still open.
193
- notify()
194
- },
195
- dismissQuestion(id) {
196
- if (!dismissedQuestionIds.includes(id)) {
197
- dismissedQuestionIds = [...dismissedQuestionIds, id]
198
- notify()
199
- }
200
- },
201
- dismissAllQuestions(ids) {
202
- dismissedQuestionIds = [...new Set([...dismissedQuestionIds, ...ids])]
203
- notify()
204
- },
205
- openPanel(parentSessionId) {
206
- panel = { ...panel, open: true, parentSessionId }
207
- dockLaunch?.()
208
- notify()
209
- },
210
- closePanel() {
211
- panel = { ...panel, open: false }
212
- notify()
213
- },
214
- setDockLaunch(fn) {
215
- dockLaunch = fn
216
- },
217
- setActive(childId) {
218
- panel = { ...panel, activeChildId: childId, messages: [], error: null }
219
- notify()
220
- },
221
- patch(partial) {
222
- panel = { ...panel, ...partial }
223
- notify()
224
- },
225
- }
226
- }
227
-
228
- /** Resolve the localized label for one locale key (module-level active locale). */
229
- function translate(activeLocale: string, key: SidechatLocaleKey): string {
230
- const dict = activeLocale === 'en' ? en : zh
231
- return dict[key] ?? key
232
- }
233
-
234
- /** Format a run duration like the main conversation: "Xs" / "Xm SSs" (or Chinese). */
235
- function formatRunDuration(ms: number, activeLocale: string): string {
236
- const total = Math.max(0, Math.floor(ms / 1000))
237
- const minutes = Math.floor(total / 60)
238
- const seconds = total % 60
239
- if (minutes > 0) {
240
- return activeLocale === 'en'
241
- ? `${minutes}m ${String(seconds).padStart(2, '0')}s`
242
- : `${minutes}分${String(seconds).padStart(2, '0')}秒`
243
- }
244
- return activeLocale === 'en' ? `${seconds}s` : `${seconds}秒`
245
- }
246
-
247
- /** Browser-owned draft image (object URL preview). */
248
- interface ComposerAttachment {
249
- id: string
250
- file: File
251
- previewUrl: string
252
- }
253
-
254
- /** Accepted image media types (mirror of dsh-attachment's ImageMediaType). */
255
- const IMAGE_MEDIA_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif']
256
-
257
- /** Create runtime draft images with object URLs (validates media type). */
258
- function createDraftImages(files: readonly File[]): ComposerAttachment[] {
259
- return files.map((file) => {
260
- if (!IMAGE_MEDIA_TYPES.includes(file.type)) {
261
- throw new Error(`unsupported image type: ${file.type || 'unknown'}`)
262
- }
263
- return { id: crypto.randomUUID(), file, previewUrl: URL.createObjectURL(file) }
264
- })
265
- }
266
-
267
- /**
268
- * Read one image file's intrinsic pixel size, releasing the decoded bitmap.
269
- * Returns null when the browser cannot decode it: the host's `validateImage`
270
- * stays the authority either way.
271
- */
272
- async function imageSize(file: File): Promise<{ width: number; height: number } | null> {
273
- try {
274
- const bitmap = await createImageBitmap(file)
275
- const size = { width: bitmap.width, height: bitmap.height }
276
- bitmap.close()
277
- return size
278
- } catch {
279
- return null
280
- }
281
- }
282
-
283
- /** Revoke one draft image's preview URL. */
284
- function releaseDraftImage(attachment: ComposerAttachment): void {
285
- URL.revokeObjectURL(attachment.previewUrl)
286
- }
287
-
288
- /** Serialize draft images to base64 prompt parts (mirror of main sendSession). */
289
- async function serializeImages(attachments: readonly ComposerAttachment[]): Promise<PromptContentPart[]> {
290
- return Promise.all(attachments.map(async (a) => {
291
- const bytes = new Uint8Array(await a.file.arrayBuffer())
292
- let binary = ''
293
- const chunk = 32768
294
- for (let offset = 0; offset < bytes.length; offset += chunk) {
295
- binary += String.fromCharCode(...bytes.subarray(offset, offset + chunk))
296
- }
297
- return {
298
- type: 'image',
299
- mediaType: a.file.type,
300
- data: btoa(binary),
301
- ...(a.file.name === '' ? {} : { name: a.file.name }),
302
- }
303
- }))
304
- }
305
-
306
- /** Convert a base64 string to an object URL for transcript image rendering. */
307
- function base64ObjectUrl(mediaType: string, data: string): string {
308
- const binary = atob(data)
309
- const bytes = new Uint8Array(binary.length)
310
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
311
- return URL.createObjectURL(new Blob([bytes], { type: mediaType }))
312
- }
313
-
314
- /** Pull the side-chat list for one parent conversation. */
315
- async function refreshList(store: SidechatStore, parentSessionId: string): Promise<void> {
316
- const result = await api.list({ parentSessionId })
317
- if (result.ok) store.patch({ items: result.value.items })
318
- }
319
-
320
- /** Optimistically flip one side-chat's running flag before the list round-trip. */
321
- function setItemRunning(store: SidechatStore, childId: string, running: boolean): void {
322
- const items = store.getSnapshot().panel.items
323
- store.patch({ items: items.map((i) => (i.childId === childId ? { ...i, running } : i)) })
324
- }
325
-
326
- /** Pull the active side chat's transcript. */
327
- async function refreshHistory(store: SidechatStore, childId: string): Promise<void> {
328
- const result = await api.history({ childId })
329
- if (result.ok) store.patch({ messages: result.value.messages })
330
- }
331
-
332
- /** Pull the model directory + permission catalog once. */
333
- async function refreshDirectory(store: SidechatStore): Promise<void> {
334
- const [directoryResult, permissionsResult] = await Promise.all([
335
- api.directory(),
336
- api.permissions(),
337
- ])
338
- const patch: Partial<PanelState> = {}
339
- if (directoryResult.ok) patch.directory = directoryResult.value
340
- if (permissionsResult.ok) {
341
- patch.permissions = permissionsResult.value
342
- // Only seed the preset on first load; never clobber an explicit user pick.
343
- if (store.getSnapshot().panel.preset === '') {
344
- patch.preset = permissionsResult.value.current
345
- }
346
- }
347
- store.patch(patch)
348
- }
349
-
350
- /** The side-chat's current model selection (provider + model + effort). */
351
- interface ModelSelection {
352
- provider: string
353
- model: string
354
- effort: string
355
- }
356
-
357
- /** One directory model's reasoning slice. */
358
- type DirectoryReasoning = SidechatDirectory['groups'][number]['models'][number]['reasoning']
359
-
360
- /** The first non-empty line of a reasoning block (collapsed summary). */
361
- function firstLine(text: string): string {
362
- const end = text.indexOf('\n')
363
- return end === -1 ? text : text.slice(0, end)
364
- }
365
-
366
- /** Main-conversation-style reasoning disclosure row (Think). */
367
- function ReasoningRow(props: { text: string; t: (key: SidechatLocaleKey) => string }) {
368
- const [expanded, setExpanded] = useState(false)
369
- const summary = firstLine(props.text)
370
- return (
371
- <DisclosureRow
372
- icon={<IconThinkOutline14 size={14} />}
373
- title={props.t('panel.think')}
374
- open={expanded}
375
- expandable={true}
376
- expandOnRowClick={true}
377
- onToggle={() => { setExpanded((value) => !value) }}
378
- collapsedContent={<span className={css.reasoningSummary}>{summary}</span>}
379
- >
380
- <div className={css.reasoningBody}>{props.text}</div>
381
- </DisclosureRow>
382
- )
383
- }
384
-
385
- /**
386
- * Main-conversation-style model selector: a compact trigger showing
387
- * `model · effort`, opening a two-level menu (provider groups → models, then
388
- * effort levels). UI mirrors dsh-client-ui-model-selection's ModelSelect.
389
- */
390
- function ModelSelect(props: {
391
- directory: SidechatDirectory | null
392
- selection: ModelSelection
393
- onSelect: (provider: string, model: string, effort?: string) => void
394
- t: (key: SidechatLocaleKey) => string
395
- openSignal?: boolean
396
- onOpenConsumed?: () => void
397
- }) {
398
- const { directory, selection, onSelect, t, openSignal, onOpenConsumed } = props
399
- const [open, setOpen] = useState(false)
400
- const [pane, setPane] = useState<'root' | 'model' | 'effort'>('root')
401
- const rootRef = useRef<HTMLDivElement | null>(null)
402
-
403
- // External open signal (the + command menu asks the selector to open).
404
- useEffect(() => {
405
- if (openSignal === true) {
406
- setPane('root')
407
- setOpen(true)
408
- onOpenConsumed?.()
409
- }
410
- }, [openSignal, onOpenConsumed])
411
-
412
- // Current model entry across all provider groups.
413
- let currentChoice: { name: string; reasoning?: DirectoryReasoning } | undefined
414
- for (const group of directory?.groups ?? []) {
415
- const model = group.models.find((m) => m.id === selection.model && group.id === selection.provider)
416
- if (model !== undefined) {
417
- currentChoice = { name: model.name, reasoning: model.reasoning }
418
- break
419
- }
420
- }
421
- const reasoning = currentChoice?.reasoning
422
- const effectiveEffort = selection.effort !== '' ? selection.effort : reasoning?.defaultEffort
423
- const effortLabel = reasoning === undefined
424
- ? undefined
425
- : effectiveEffort === undefined
426
- ? t('panel.effortDefault')
427
- : reasoning.efforts.find((e) => e.id === effectiveEffort)?.name ?? effectiveEffort
428
- const modelLabel = currentChoice?.name ?? t('panel.noModel')
429
- const effortChoices = reasoning === undefined
430
- ? []
431
- : [
432
- ...(reasoning.defaultEffort === undefined ? [{ key: 'default', effort: undefined as string | undefined, label: t('panel.effortDefault') }] : []),
433
- ...reasoning.efforts.map((e) => ({ key: e.id, effort: e.id, label: e.name })),
434
- ]
435
-
436
- // Close on outside pointer-down / Escape.
437
- useEffect(() => {
438
- if (!open) return
439
- const onDown = (e: globalThis.MouseEvent): void => {
440
- if (rootRef.current !== null && !rootRef.current.contains(e.target as Node)) setOpen(false)
441
- }
442
- const onKey = (e: KeyboardEvent): void => {
443
- if (e.key === 'Escape') setOpen(false)
444
- }
445
- document.addEventListener('mousedown', onDown)
446
- document.addEventListener('keydown', onKey)
447
- return () => {
448
- document.removeEventListener('mousedown', onDown)
449
- document.removeEventListener('keydown', onKey)
450
- }
451
- }, [open])
452
-
453
- const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
454
-
455
- return (
456
- <div ref={rootRef} className={css.modelSelect}>
457
- <button
458
- type="button"
459
- className={css.modelSelectTrigger}
460
- aria-haspopup="menu"
461
- aria-expanded={open}
462
- title={triggerLabel}
463
- onClick={() => {
464
- if (open) setOpen(false)
465
- else { setPane('root'); setOpen(true) }
466
- }}
467
- >
468
- <span className={css.modelSelectLabel}>{modelLabel}</span>
469
- {effortLabel !== undefined && <span className={css.modelSelectEffort}>{effortLabel}</span>}
470
- <IconChevronDownOutline14 className={open ? css.chevronOpen : undefined} />
471
- </button>
472
-
473
- {open && (
474
- <div className={css.modelSelectMenu} role="menu">
475
- {pane === 'root' && (
476
- <>
477
- <button type="button" role="menuitem" className={css.modelCell} onClick={() => { setPane('model') }}>
478
- <span className={css.modelCellLabel}>{t('panel.model')}</span>
479
- <span className={css.modelCellValue}>{modelLabel}</span>
480
- <IconChevronRightOutline14 className={css.modelCellChevron} />
481
- </button>
482
- {reasoning !== undefined && (
483
- <button type="button" role="menuitem" className={css.modelCell} onClick={() => { setPane('effort') }}>
484
- <span className={css.modelCellLabel}>{t('panel.effort')}</span>
485
- <span className={css.modelCellValue}>{effortLabel}</span>
486
- <IconChevronRightOutline14 className={css.modelCellChevron} />
487
- </button>
488
- )}
489
- </>
490
- )}
491
-
492
- {pane === 'model' && (
493
- <div className={css.modelGroups}>
494
- {(directory?.groups ?? []).map((group) => (
495
- <section key={group.id} role="group" aria-label={group.name} className={css.modelGroup}>
496
- <div className={css.modelGroupTitle}>{group.name}</div>
497
- {group.models.map((model) => {
498
- const selected = selection.provider === group.id && selection.model === model.id
499
- return (
500
- <button
501
- key={model.id}
502
- type="button"
503
- role="menuitemradio"
504
- aria-checked={selected}
505
- className={selected ? `${css.modelOption} ${css.modelOptionSelected}` : css.modelOption}
506
- title={model.name}
507
- onClick={() => {
508
- onSelect(group.id, model.id)
509
- setOpen(false)
510
- }}
511
- >
512
- <span className={css.modelOptionCopy}>
513
- <span className={css.modelName}>{model.name}</span>
514
- {model.description !== undefined && <span className={css.modelDescription}>{model.description}</span>}
515
- </span>
516
- <span className={css.modelCheck}>{selected ? <IconCheckOutline16 /> : null}</span>
517
- </button>
518
- )
519
- })}
520
- </section>
521
- ))}
522
- {(directory?.groups ?? []).length === 0 && <div className={css.modelEmpty}>{t('panel.noModel')}</div>}
523
- </div>
524
- )}
525
-
526
- {pane === 'effort' && (
527
- <>
528
- {effortChoices.length === 0
529
- ? <div className={css.modelEmpty}>{t('panel.effort')}</div>
530
- : effortChoices.map((level) => {
531
- const selected = effectiveEffort === level.effort
532
- return (
533
- <button
534
- key={level.key}
535
- type="button"
536
- role="menuitemradio"
537
- aria-checked={selected}
538
- className={selected ? `${css.modelOption} ${css.modelOptionSelected}` : css.modelOption}
539
- onClick={() => {
540
- onSelect(selection.provider, selection.model, level.effort)
541
- setOpen(false)
542
- }}
543
- >
544
- <span className={css.modelOptionCopy}>
545
- <span className={css.modelName}>{level.label}</span>
546
- </span>
547
- <span className={css.modelCheck}>{selected ? <IconCheckOutline16 /> : null}</span>
548
- </button>
549
- )
550
- })}
551
- </>
552
- )}
553
- </div>
554
- )}
555
- </div>
556
- )
557
- }
558
-
559
- /** Main-conversation-style permission selector (Menu + compact trigger). */
560
- function PermissionSelect(props: {
561
- permissions: SidechatPermissions | null
562
- preset: string
563
- onSelect: (preset: string) => void
564
- t: (key: SidechatLocaleKey) => string
565
- openSignal?: boolean
566
- onOpenConsumed?: () => void
567
- }) {
568
- const { permissions, preset, onSelect, openSignal, onOpenConsumed } = props
569
- const [open, setOpen] = useState(false)
570
- const options = (permissions?.options ?? []).filter((o) => o.value !== 'custom')
571
- const current = options.find((o) => o.value === preset)
572
- const items = options.map((o) => ({ id: o.value, label: o.name }))
573
-
574
- // External open signal (the + command menu asks the selector to open).
575
- useEffect(() => {
576
- if (openSignal === true) {
577
- setOpen(true)
578
- onOpenConsumed?.()
579
- }
580
- }, [openSignal, onOpenConsumed])
581
-
582
- return (
583
- <Menu
584
- open={open}
585
- side="top"
586
- align="end"
587
- items={items}
588
- selectedId={preset}
589
- onSelect={(id) => { setOpen(false); onSelect(id) }}
590
- onClose={() => { setOpen(false) }}
591
- anchor={(
592
- <button
593
- type="button"
594
- className={css.modelSelectTrigger}
595
- aria-haspopup="menu"
596
- aria-expanded={open}
597
- title={current?.description}
598
- onClick={() => { setOpen(!open) }}
599
- >
600
- <span className={css.modelSelectLabel}>{current?.name ?? preset}</span>
601
- <IconChevronDownOutline14 />
602
- </button>
603
- )}
604
- />
605
- )
606
- }
607
-
608
- /**
609
- * The floating selection menu: listens to the document selection and shows
610
- * one or two buttons (start / continue), dispatching to the host API.
611
- */
612
- function SelectionMenu(props: { store: SidechatStore; t: (key: SidechatLocaleKey) => string }) {
613
- const { anchor, current, panel, prefs } = useSyncExternalStore(props.store.subscribe, props.store.getSnapshot)
614
- const [local, setLocal] = useState<SelectionAnchor | null>(null)
615
-
616
- useEffect(() => {
617
- const compute = (): void => {
618
- const selection = window.getSelection()
619
- if (selection === null || selection.isCollapsed) {
620
- setLocal(null)
621
- return
622
- }
623
- const text = selection.toString().trim()
624
- if (text === '') {
625
- setLocal(null)
626
- return
627
- }
628
- const range = selection.getRangeAt(0)
629
- const node = range.startContainer
630
- const element = node.nodeType === 1 ? (node as Element) : node.parentElement
631
- if (element !== null && element.closest('input, textarea, [contenteditable="true"]') !== null) {
632
- setLocal(null)
633
- return
634
- }
635
- // Never offer "ask in side chat" for selections inside the side-chat panel
636
- // (those belong to the bring-back-to-main menu instead).
637
- if (element !== null && element.closest('[data-dsh-side-chat]') !== null) {
638
- setLocal(null)
639
- return
640
- }
641
- const rect = range.getBoundingClientRect()
642
- if (rect.width === 0 && rect.height === 0) {
643
- setLocal(null)
644
- return
645
- }
646
- setLocal({ text, x: rect.left + rect.width / 2, y: rect.top })
647
- }
648
- const onMouseUp = (): void => { window.setTimeout(compute, 0) }
649
- document.addEventListener('mouseup', onMouseUp)
650
- document.addEventListener('selectionchange', compute)
651
- return () => {
652
- document.removeEventListener('mouseup', onMouseUp)
653
- document.removeEventListener('selectionchange', compute)
654
- }
655
- }, [])
656
-
657
- const start = useCallback(() => {
658
- if (local === null || current === undefined) return
659
- const parentSessionId = current
660
- const text = local.text
661
- const snap = props.store.getSnapshot().panel
662
- if (prefs.sendImmediately) {
663
- const content: PromptContentPart[] = [
664
- { type: 'text', text },
665
- ...(prefs.defaultPrompt.trim() !== '' ? [{ type: 'text' as const, text: prefs.defaultPrompt.trim() }] : []),
666
- ]
667
- void api.start({
668
- parentSessionId,
669
- content,
670
- lookupEnabled: prefs.lookupDefault,
671
- ...(snap.provider !== '' ? { provider: snap.provider } : {}),
672
- ...(snap.model !== '' ? { model: snap.model } : {}),
673
- ...(snap.effort !== '' ? { reasoningEffort: snap.effort } : {}),
674
- }).then((result) => {
675
- if (result.ok) {
676
- props.store.openPanel(parentSessionId)
677
- props.store.setActive(result.value.childId)
678
- props.store.patch({
679
- provider: result.value.provider,
680
- model: result.value.model,
681
- effort: result.value.reasoningEffort ?? '',
682
- })
683
- void refreshList(props.store, parentSessionId)
684
- void refreshDirectory(props.store)
685
- }
686
- })
687
- } else {
688
- // Stage the selection as an attachment; a new side chat is created on
689
- // send. Detach from any previously active child, but show the parent's
690
- // inherited model until the user picks one.
691
- props.store.openPanel(parentSessionId)
692
- props.store.patch({ attachment: text, activeChildId: null, messages: [], draft: '', provider: '', model: '', effort: '' })
693
- void api.inherit({ parentSessionId }).then((result) => {
694
- if (result.ok) {
695
- props.store.patch({
696
- provider: result.value.provider,
697
- model: result.value.model,
698
- effort: result.value.reasoningEffort ?? '',
699
- })
700
- }
701
- })
702
- void refreshDirectory(props.store)
703
- }
704
- setLocal(null)
705
- }, [local, current, prefs, props.store])
706
-
707
- const continueChat = useCallback(() => {
708
- if (local === null || current === undefined) return
709
- const parentSessionId = current
710
- const text = local.text
711
- const active = props.store.getSnapshot().panel.activeChildId
712
- if (active === null) return
713
- props.store.openPanel(parentSessionId)
714
- if (prefs.sendImmediately) {
715
- const content: PromptContentPart[] = [
716
- { type: 'text', text },
717
- ...(prefs.defaultPrompt.trim() !== '' ? [{ type: 'text' as const, text: prefs.defaultPrompt.trim() }] : []),
718
- ]
719
- setItemRunning(props.store, active, true)
720
- void api.followup({ childId: active, content, lookupEnabled: prefs.lookupDefault }).then((result) => {
721
- if (!result.ok) props.store.patch({ error: result.error.message })
722
- void refreshList(props.store, parentSessionId)
723
- void refreshHistory(props.store, active)
724
- })
725
- } else {
726
- props.store.patch({ attachment: text })
727
- }
728
- setLocal(null)
729
- }, [local, current, prefs, props.store])
730
-
731
- if (local === null || current === undefined) return null
732
- const hasActive = panel.activeChildId !== null
733
- return (
734
- <div className={css.selectionMenu} style={{ left: local.x, top: local.y - 46 }}>
735
- <button type="button" className={css.selectionButton} onClick={start}>{props.t('ask.new')}</button>
736
- {hasActive && <button type="button" className={css.selectionButton} onClick={continueChat}>{props.t('ask.continue')}</button>}
737
- </div>
738
- )
739
- }
740
-
741
- /**
742
- * The floating bring-back-to-main menu: listens to the document selection and,
743
- * when the selection is inside an assistant reply in the side-chat panel, shows
744
- * two actions — "insert directly" and "summarize then insert" — both appending
745
- * into the main composer without sending.
746
- */
747
- function BringBackMenu(props: {
748
- store: SidechatStore
749
- t: (key: SidechatLocaleKey) => string
750
- bringToMain: (text: string) => Promise<boolean>
751
- summarizeBring: (text: string) => Promise<boolean>
752
- }) {
753
- const [local, setLocal] = useState<SelectionAnchor | null>(null)
754
- const [summarizing, setSummarizing] = useState(false)
755
-
756
- useEffect(() => {
757
- const compute = (): void => {
758
- const selection = window.getSelection()
759
- if (selection === null || selection.isCollapsed) {
760
- setLocal(null)
761
- return
762
- }
763
- const text = selection.toString().trim()
764
- if (text === '') {
765
- setLocal(null)
766
- return
767
- }
768
- const range = selection.getRangeAt(0)
769
- const node = range.startContainer
770
- const element = node.nodeType === 1 ? (node as Element) : node.parentElement
771
- if (element !== null && element.closest('input, textarea, [contenteditable="true"]') !== null) {
772
- setLocal(null)
773
- return
774
- }
775
- if (element === null || element.closest('[data-sidechat-role="assistant"]') === null) {
776
- setLocal(null)
777
- return
778
- }
779
- const rect = range.getBoundingClientRect()
780
- if (rect.width === 0 && rect.height === 0) {
781
- setLocal(null)
782
- return
783
- }
784
- setLocal({ text, x: rect.left + rect.width / 2, y: rect.top })
785
- }
786
- const onMouseUp = (): void => { window.setTimeout(compute, 0) }
787
- document.addEventListener('mouseup', onMouseUp)
788
- document.addEventListener('selectionchange', compute)
789
- return () => {
790
- document.removeEventListener('mouseup', onMouseUp)
791
- document.removeEventListener('selectionchange', compute)
792
- }
793
- }, [])
794
-
795
- if (local === null) return null
796
-
797
- const summarize = async (): Promise<void> => {
798
- setSummarizing(true)
799
- const ok = await props.summarizeBring(local.text)
800
- setSummarizing(false)
801
- if (!ok) props.store.patch({ error: props.t('insert.summarizeFailed') })
802
- else setLocal(null)
803
- }
804
-
805
- return (
806
- <div className={css.selectionMenu} style={{ left: local.x, top: local.y - 46 }}>
807
- <button
808
- type="button"
809
- className={css.selectionButton}
810
- onClick={() => {
811
- void props.bringToMain(local.text).then((ok) => {
812
- if (!ok) props.store.patch({ error: props.t('insert.failed') })
813
- else setLocal(null)
814
- })
815
- }}
816
- >
817
- {props.t('insert.direct')}
818
- </button>
819
- <button type="button" className={css.selectionButton} disabled={summarizing} onClick={() => { void summarize() }}>
820
- {summarizing ? props.t('insert.summarizing') : props.t('insert.summarize')}
821
- </button>
822
- </div>
823
- )
824
- }
825
-
826
- /**
827
- * Floating entry shown while the panel is closed and the main conversation has
828
- * a pending question dialog. It anchors beside the dialog's header (without
829
- * covering its text) and disappears once clicked (the panel opens instead).
830
- */
831
- function QuestionFab(props: {
832
- store: SidechatStore
833
- t: (key: SidechatLocaleKey) => string
834
- onOpen: () => void
835
- }) {
836
- const [pos, setPos] = useState<{ left: number; top: number } | null>(null)
837
-
838
- useEffect(() => {
839
- let raf = 0
840
- let missing = 0
841
- const tick = (): void => {
842
- const el = document.querySelector<HTMLElement>('[data-question-key], [data-approval-key]')
843
- if (el === null) {
844
- missing += 1
845
- // A brief grace period covers the initial render; if the dialog stays
846
- // absent, clear the tracked question so this entry disappears too.
847
- if (missing > 30) {
848
- props.store.setMainQuestion(null)
849
- return
850
- }
851
- setPos(null)
852
- raf = requestAnimationFrame(tick)
853
- return
854
- }
855
- missing = 0
856
- // The dialog's header (its title/eyebrow block) is the anchor. Newer DSH
857
- // wraps it as `section > header` inside the data-question frame, so locate
858
- // the `header` tag generically (older builds had it as the first child).
859
- const header = el.querySelector<HTMLElement>('header') ?? (el.firstElementChild as HTMLElement | null) ?? el
860
- const rect = header.getBoundingClientRect()
861
- const size = 32
862
- const left = Math.min(rect.right + 8, window.innerWidth - size - 8)
863
- const top = rect.top + rect.height / 2
864
- setPos({ left, top })
865
- raf = requestAnimationFrame(tick)
866
- }
867
- tick()
868
- return () => { cancelAnimationFrame(raf) }
869
- }, [props.store])
870
-
871
- const style: CSSProperties = pos !== null
872
- ? { left: pos.left, top: pos.top, transform: 'translateY(-50%)' }
873
- : { left: '50%', bottom: 160, transform: 'translateX(-50%)' }
874
-
875
- return (
876
- <Tooltip label={props.t('question.openHint')} side="top">
877
- <button
878
- type="button"
879
- className={css.questionFab}
880
- style={style}
881
- aria-label={props.t('question.openHint')}
882
- onClick={props.onOpen}
883
- >
884
- <IconPanelLeftOutline16 size={16} />
885
- <span className={css.questionFabDot} />
886
- </button>
887
- </Tooltip>
888
- )
889
- }
890
-
891
- /** Panel width bounds. The panel never takes more than ~40% of the window and
892
- * never squeezes the main chat below a usable minimum — so the panel adapts to
893
- * whatever resolution / zoom the browser window is at. */
894
- const PANEL_MIN_WIDTH = 280
895
- const PANEL_MAX_WIDTH = 720
896
- const PANEL_DEFAULT_WIDTH = 360
897
- const MAIN_CHAT_MIN_WIDTH = 480
898
- /** localStorage key remembering the last panel width across reloads. */
899
- const PANEL_WIDTH_KEY = 'dsh-side-chat.panelWidth'
900
-
901
- /** Right-sidebar dock identity (kind is the `openTab` discriminator; id keys the body seat). */
902
- const SIDEBAR_TAB_ID = 'dsh-side-chat-plus/side-chat'
903
- const SIDEBAR_TAB_KIND = 'side-chat'
904
-
905
- /** The viewport-aware maximum panel width for the current window. */
906
- function panelCap(): number {
907
- const vw = window.innerWidth
908
- return Math.max(PANEL_MIN_WIDTH, Math.min(PANEL_MAX_WIDTH, vw * 0.4, vw - MAIN_CHAT_MIN_WIDTH))
909
- }
910
-
911
- /** The last user-chosen width, if any (re-clamped to the viewport on load). */
912
- function savedPanelWidth(): number | null {
913
- try {
914
- const raw = window.localStorage.getItem(PANEL_WIDTH_KEY)
915
- if (raw === null) return null
916
- const n = Number(raw)
917
- return Number.isFinite(n) && n > 0 ? n : null
918
- } catch {
919
- return null
920
- }
921
- }
922
-
923
- /** The side-chat panel body. */
924
- function SidechatPanel(props: {
925
- store: SidechatStore
926
- t: (key: SidechatLocaleKey) => string
927
- formatDuration: (ms: number) => string
928
- bringToMain: (text: string) => Promise<boolean>
929
- summarizeBring: (text: string) => Promise<boolean>
930
- askSidechat: (text: string) => Promise<boolean>
931
- askSidechatNew: (text: string) => Promise<boolean>
932
- /** Render inside the built-in right sidebar's tab pane (no floating chrome). */
933
- embedded?: boolean
934
- /** A built-in sidebar overlay (fullscreen preview / floating panel) owns the viewport: hide this panel. */
935
- conflictHidden?: boolean
936
- }) {
937
- const { panel, mainQuestion, dismissedQuestionIds } = useSyncExternalStore(props.store.subscribe, props.store.getSnapshot)
938
- const scrollRef = useRef<HTMLDivElement | null>(null)
939
- const markdownLabels = useMemo(() => ({
940
- code: { copyLabel: props.t('panel.copy'), copiedLabel: props.t('panel.copied') },
941
- footnotes: props.t('panel.footnotes'),
942
- }), [props.t])
943
- const attachmentRailLabels = useMemo(() => ({
944
- group: props.t('image.railGroup'),
945
- open: props.t('image.railOpen'),
946
- scrollLeft: props.t('image.railScrollLeft'),
947
- scrollRight: props.t('image.railScrollRight'),
948
- }), [props.t])
949
- const messageImageLabels = useMemo(() => ({
950
- image: props.t('image.label'),
951
- open: props.t('image.open'),
952
- openNamed: (name: string): string => name,
953
- loading: props.t('image.loading'),
954
- loadFailed: props.t('image.loadFailed'),
955
- lightbox: { dialog: props.t('image.lightboxDialog'), close: props.t('image.close') },
956
- }), [props.t])
957
- const dropOverlayLabels = useMemo(() => ({
958
- title: props.t('image.dropTitle'),
959
- desc: props.t('image.dropDesc'),
960
- }), [props.t])
961
- // Width starts at the user's last choice when it fits the current window,
962
- // otherwise adapts to the viewport (small screens get a smaller default).
963
- const [width, setWidth] = useState(() => {
964
- const base = savedPanelWidth() ?? PANEL_DEFAULT_WIDTH
965
- return Math.max(PANEL_MIN_WIDTH, Math.min(panelCap(), base))
966
- })
967
- const [collapsed, setCollapsed] = useState(false)
968
- const [now, setNow] = useState(() => Date.now())
969
- const [dragActive, setDragActive] = useState(false)
970
- const [lightbox, setLightbox] = useState<ComposerAttachment | null>(null)
971
- const [limits, setLimits] = useState<{ mediaTypes: string[]; maxImageBytes: number; maxImagesPerMessage: number; maxMessageImageBytes: number; maxImagePixels: number; maxImageDimension?: number } | null>(null)
972
- /** Index of the assistant message whose "summarize then insert" is in flight. */
973
- const [summarizingIndex, setSummarizingIndex] = useState<number | null>(null)
974
- /** Which question-dialog item is being brought into the side chat ('all' or an option label). */
975
- const [bringingKey, setBringingKey] = useState<string | null>(null)
976
- /** Whether the question-dialog list is collapsed (headers only). */
977
- const [questionCollapsed, setQuestionCollapsed] = useState(false)
978
-
979
- // Auto-expand the panel whenever a side chat is started or activated, so
980
- // starting from a collapsed panel still reveals the conversation.
981
- useEffect(() => {
982
- if (panel.open && panel.activeChildId !== null) setCollapsed(false)
983
- }, [panel.open, panel.activeChildId])
984
-
985
- // Open (and expand) the panel to show the pending question dialog.
986
- const openQuestionPanel = useCallback(() => {
987
- props.store.openPanel(panel.parentSessionId)
988
- setCollapsed(false)
989
- }, [props.store, panel.parentSessionId])
990
-
991
- /** Assemble one question + all its options into a prompt. */
992
- const buildAllText = (q: SideQuestionItem): string => {
993
- const lines: string[] = []
994
- if (q.header !== undefined && q.header !== '') lines.push(`【${q.header}】`)
995
- lines.push(q.question)
996
- if (q.detail !== undefined && q.detail !== '') lines.push(q.detail)
997
- const options = q.options ?? []
998
- if (options.length > 0) {
999
- lines.push(props.t('question.options'))
1000
- for (const o of options) {
1001
- lines.push(`- ${o.label}${o.description !== undefined && o.description !== '' ? ` — ${o.description}` : ''}`)
1002
- }
1003
- }
1004
- lines.push(props.t('question.allPrompt'))
1005
- return lines.join('\n')
1006
- }
1007
-
1008
- /** Assemble one question + one specific option into a prompt. */
1009
- const buildOneText = (q: SideQuestionItem, o: SideQuestionOption): string => {
1010
- const lines: string[] = []
1011
- if (q.header !== undefined && q.header !== '') lines.push(`【${q.header}】`)
1012
- lines.push(q.question)
1013
- if (q.detail !== undefined && q.detail !== '') lines.push(q.detail)
1014
- lines.push(`${props.t('question.option')}:${o.label}${o.description !== undefined && o.description !== '' ? ` — ${o.description}` : ''}`)
1015
- lines.push(props.t('question.onePrompt'))
1016
- return lines.join('\n')
1017
- }
1018
-
1019
- const bringQuestionText = (text: string, key: string, useNew: boolean): void => {
1020
- setBringingKey(key)
1021
- const fn = useNew ? props.askSidechatNew : props.askSidechat
1022
- void fn(text).then((ok) => {
1023
- setBringingKey(null)
1024
- if (!ok) props.store.patch({ error: props.t('question.failed') })
1025
- })
1026
- }
1027
-
1028
- const activeItem = panel.items.find((i) => i.childId === panel.activeChildId)
1029
- const activeRunning = activeItem?.running ?? false
1030
- const [anchor, setAnchor] = useState<number | null>(null)
1031
-
1032
- useEffect(() => {
1033
- if (props.embedded) return
1034
- const w = panel.open && !collapsed && !props.conflictHidden ? `${width}px` : '0px'
1035
- document.documentElement.style.setProperty('--dsh-subchat-width', w)
1036
- return () => { document.documentElement.style.setProperty('--dsh-subchat-width', '0px') }
1037
- }, [props.embedded, panel.open, collapsed, width, props.conflictHidden])
1038
-
1039
- // Re-adapt the panel width when the window is resized: if the viewport
1040
- // shrinks (smaller window, different monitor, higher zoom), the panel is
1041
- // clamped to the new cap and the layout margin follows via the effect above.
1042
- useEffect(() => {
1043
- const onResize = (): void => {
1044
- setWidth((w) => Math.min(w, panelCap()))
1045
- }
1046
- window.addEventListener('resize', onResize)
1047
- return () => { window.removeEventListener('resize', onResize) }
1048
- }, [])
1049
-
1050
- // Remember the width across reloads; on the next load it is re-clamped to
1051
- // whatever window is present then.
1052
- useEffect(() => {
1053
- try {
1054
- window.localStorage.setItem(PANEL_WIDTH_KEY, String(width))
1055
- } catch {
1056
- // Storage unavailable (private mode etc.) — the width just won't persist.
1057
- }
1058
- }, [width])
1059
-
1060
- // Lazy-load the model/permission directory whenever the panel is open but the
1061
- // directory has not hydrated yet (covers page reload + continue + direct open).
1062
- useEffect(() => {
1063
- if (panel.open && panel.directory === null) {
1064
- void refreshDirectory(props.store)
1065
- }
1066
- }, [panel.open, panel.directory, props.store])
1067
-
1068
- useEffect(() => {
1069
- if (activeRunning) {
1070
- if (anchor === null) setAnchor(activeItem?.runningSince ?? Date.now())
1071
- } else if (anchor !== null) {
1072
- setAnchor(null)
1073
- }
1074
- }, [activeRunning, activeItem?.runningSince, anchor])
1075
-
1076
- useEffect(() => {
1077
- if (!activeRunning) return
1078
- const id = window.setInterval(() => { setNow(Date.now()) }, 1000)
1079
- return () => { window.clearInterval(id) }
1080
- }, [activeRunning])
1081
-
1082
- useEffect(() => {
1083
- const el = scrollRef.current
1084
- if (el !== null) el.scrollTop = el.scrollHeight
1085
- }, [panel.messages.length, panel.activeChildId])
1086
-
1087
- useEffect(() => {
1088
- if (!panel.open || panel.activeChildId === null) return
1089
- const tick = (): void => {
1090
- const snap = props.store.getSnapshot().panel
1091
- const childId = snap.activeChildId
1092
- if (childId === null) return
1093
- void refreshList(props.store, snap.parentSessionId)
1094
- void refreshHistory(props.store, childId)
1095
- }
1096
- tick()
1097
- const id = window.setInterval(tick, 1200)
1098
- return () => { window.clearInterval(id) }
1099
- }, [panel.open, panel.activeChildId, props.store])
1100
-
1101
- const send = useCallback(() => {
1102
- const draft = panel.draft.trim()
1103
- const attachment = panel.attachment === null ? '' : panel.attachment
1104
- const text = attachment === '' ? draft : (draft === '' ? attachment : `${attachment}\n\n${draft}`)
1105
- void serializeImages(panel.attachments).then((imageParts) => {
1106
- const content: PromptContentPart[] = [...imageParts, ...(text === '' ? [] : [{ type: 'text', text }] as PromptContentPart[])]
1107
- if (content.length === 0) return
1108
- const toRelease = panel.attachments
1109
- props.store.patch({ draft: '', attachment: null, attachments: [] })
1110
- toRelease.forEach(releaseDraftImage)
1111
-
1112
- if (panel.activeChildId === null) {
1113
- void api.start({
1114
- parentSessionId: panel.parentSessionId,
1115
- content,
1116
- lookupEnabled: panel.lookup,
1117
- ...(panel.provider !== '' ? { provider: panel.provider } : {}),
1118
- ...(panel.model !== '' ? { model: panel.model } : {}),
1119
- ...(panel.effort !== '' ? { reasoningEffort: panel.effort } : {}),
1120
- ...(panel.preset !== '' ? { preset: panel.preset } : {}),
1121
- }).then((result) => {
1122
- if (result.ok) {
1123
- props.store.setActive(result.value.childId)
1124
- props.store.patch({
1125
- provider: result.value.provider,
1126
- model: result.value.model,
1127
- effort: result.value.reasoningEffort ?? '',
1128
- })
1129
- void refreshList(props.store, panel.parentSessionId)
1130
- void refreshDirectory(props.store)
1131
- } else {
1132
- props.store.patch({ error: result.error.message })
1133
- }
1134
- })
1135
- return
1136
- }
1137
-
1138
- const childId = panel.activeChildId
1139
- setItemRunning(props.store, childId, true)
1140
- void api.followup({ childId, content, lookupEnabled: panel.lookup }).then((result) => {
1141
- if (!result.ok) {
1142
- setItemRunning(props.store, childId, false)
1143
- props.store.patch({ error: result.error.message })
1144
- }
1145
- void refreshList(props.store, panel.parentSessionId)
1146
- void refreshHistory(props.store, childId)
1147
- })
1148
- }).catch((error: unknown) => {
1149
- props.store.patch({ error: error instanceof Error ? error.message : String(error) })
1150
- })
1151
- }, [panel, props.store])
1152
-
1153
- const stop = useCallback(() => {
1154
- if (panel.activeChildId === null) return
1155
- const childId = panel.activeChildId
1156
- setItemRunning(props.store, childId, false)
1157
- void api.stop({ childId }).then(() => {
1158
- void refreshList(props.store, panel.parentSessionId)
1159
- void refreshHistory(props.store, childId)
1160
- })
1161
- }, [panel, props.store])
1162
-
1163
- const onModelSelect = useCallback((provider: string, model: string, effort?: string) => {
1164
- // Always update the panel selection; only a live child can receive the
1165
- // selection immediately (staged mode applies it at creation instead).
1166
- props.store.patch({ provider, model, effort: effort ?? '' })
1167
- if (panel.activeChildId !== null) {
1168
- void api.selectModel({ childId: panel.activeChildId, provider, model, ...(effort === undefined ? {} : { reasoningEffort: effort }) })
1169
- }
1170
- }, [panel.activeChildId, props.store])
1171
-
1172
- const onPresetChange = useCallback((value: string) => {
1173
- if (value === 'custom' || value === '') return
1174
- props.store.patch({ preset: value })
1175
- if (panel.activeChildId !== null) {
1176
- void api.selectPermission({ childId: panel.activeChildId, presetName: value }).then((result) => {
1177
- if (!result.ok) props.store.patch({ error: result.error.message })
1178
- })
1179
- }
1180
- }, [panel.activeChildId, props.store])
1181
-
1182
- const dispose = useCallback(() => {
1183
- if (panel.activeChildId === null) return
1184
- const childId = panel.activeChildId
1185
- void api.dispose({ childId }).then(() => {
1186
- void refreshList(props.store, panel.parentSessionId)
1187
- props.store.patch({ activeChildId: null, messages: [] })
1188
- })
1189
- }, [panel, props.store])
1190
-
1191
- /** Delete one side chat from the list. */
1192
- const disposeItem = useCallback((childId: string) => {
1193
- void api.dispose({ childId }).then(() => {
1194
- void refreshList(props.store, panel.parentSessionId)
1195
- if (panel.activeChildId === childId) {
1196
- props.store.patch({ activeChildId: null, messages: [] })
1197
- }
1198
- })
1199
- }, [panel.activeChildId, panel.parentSessionId, props.store])
1200
-
1201
- /** Delete every side chat of this conversation at once. */
1202
- const disposeAll = useCallback(() => {
1203
- const ids = panel.items.map((i) => i.childId)
1204
- if (ids.length === 0) return
1205
- void Promise.all(ids.map((id) => api.dispose({ childId: id }))).then(() => {
1206
- void refreshList(props.store, panel.parentSessionId)
1207
- props.store.patch({ activeChildId: null, messages: [] })
1208
- })
1209
- }, [panel.items, panel.parentSessionId, props.store])
1210
-
1211
- const startResize = useCallback((e: ReactMouseEvent<HTMLDivElement>) => {
1212
- e.preventDefault()
1213
- const startX = e.clientX
1214
- const startWidth = width
1215
- const onMove = (ev: globalThis.MouseEvent): void => {
1216
- setWidth(Math.max(PANEL_MIN_WIDTH, Math.min(panelCap(), startWidth + (startX - ev.clientX))))
1217
- }
1218
- const onUp = (): void => {
1219
- window.removeEventListener('mousemove', onMove)
1220
- window.removeEventListener('mouseup', onUp)
1221
- }
1222
- window.addEventListener('mousemove', onMove)
1223
- window.addEventListener('mouseup', onUp)
1224
- }, [width])
1225
-
1226
- const intakeImages = useCallback((files: File[]) => {
1227
- if (files.length === 0) return
1228
- const images = files.filter((f) => f.type.startsWith('image/'))
1229
- if (images.length === 0) {
1230
- props.store.patch({ error: props.t('image.unsupported') })
1231
- return
1232
- }
1233
- void (async () => {
1234
- try {
1235
- if (limits !== null) {
1236
- if (images.some((f) => !limits.mediaTypes.includes(f.type))) {
1237
- props.store.patch({ error: props.t('image.unsupported') })
1238
- return
1239
- }
1240
- if (panel.attachments.length + images.length > limits.maxImagesPerMessage) {
1241
- props.store.patch({ error: props.t('image.tooMany') })
1242
- return
1243
- }
1244
- if (images.some((f) => f.size > limits.maxImageBytes)) {
1245
- props.store.patch({ error: props.t('image.fileTooLarge') })
1246
- return
1247
- }
1248
- // DSH 0.1.6 added a per-image intrinsic width/height ceiling; older
1249
- // lines omit it. Decoding is async, so this check is awaited rather
1250
- // than folded into the size-type ones above.
1251
- const maxDimension = limits.maxImageDimension
1252
- if (typeof maxDimension === 'number' && maxDimension > 0) {
1253
- for (const file of images) {
1254
- const size = await imageSize(file)
1255
- if (size !== null && (size.width > maxDimension || size.height > maxDimension)) {
1256
- props.store.patch({ error: props.t('image.dimensionTooLarge') })
1257
- return
1258
- }
1259
- }
1260
- }
1261
- }
1262
- const created = createDraftImages(images)
1263
- props.store.patch({ attachments: [...panel.attachments, ...created], error: null })
1264
- } catch (error) {
1265
- props.store.patch({ error: error instanceof Error ? error.message : String(error) })
1266
- }
1267
- })()
1268
- }, [limits, panel.attachments, props.store, props.t])
1269
-
1270
- // Load the deployment image policy once (fast-path checks mirror the host).
1271
- useEffect(() => {
1272
- void api.limits().then((result) => {
1273
- if (result.ok) setLimits(result.value)
1274
- })
1275
- }, [])
1276
-
1277
- // Full-page file drag: track enter/leave depth and accept image drops.
1278
- useEffect(() => {
1279
- if (!panel.open || collapsed) return
1280
- const dragDepth = { value: 0 }
1281
- const hasFiles = (event: DragEvent): boolean => event.dataTransfer?.types.includes('Files') ?? false
1282
- const reset = (): void => { dragDepth.value = 0; setDragActive(false) }
1283
- const onDragEnter = (event: DragEvent): void => {
1284
- if (!hasFiles(event)) return
1285
- event.preventDefault()
1286
- dragDepth.value += 1
1287
- setDragActive(true)
1288
- }
1289
- const onDragOver = (event: DragEvent): void => {
1290
- if (!hasFiles(event) || event.dataTransfer === null) return
1291
- event.preventDefault()
1292
- event.dataTransfer.dropEffect = 'copy'
1293
- }
1294
- const onDragLeave = (event: DragEvent): void => {
1295
- if (!hasFiles(event)) return
1296
- dragDepth.value = Math.max(0, dragDepth.value - 1)
1297
- if (dragDepth.value === 0) setDragActive(false)
1298
- }
1299
- const onDrop = (event: DragEvent): void => {
1300
- if (!hasFiles(event)) return
1301
- event.preventDefault()
1302
- reset()
1303
- intakeImages([...event.dataTransfer?.files ?? []])
1304
- }
1305
- document.addEventListener('dragenter', onDragEnter)
1306
- document.addEventListener('dragover', onDragOver)
1307
- document.addEventListener('dragleave', onDragLeave)
1308
- document.addEventListener('drop', onDrop)
1309
- window.addEventListener('dragend', reset)
1310
- return () => {
1311
- document.removeEventListener('dragenter', onDragEnter)
1312
- document.removeEventListener('dragover', onDragOver)
1313
- document.removeEventListener('dragleave', onDragLeave)
1314
- document.removeEventListener('drop', onDrop)
1315
- window.removeEventListener('dragend', reset)
1316
- }
1317
- }, [panel.open, collapsed, intakeImages])
1318
-
1319
- const onPaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
1320
- const files = Array.from(e.clipboardData.items).filter((item) => item.kind === 'file').map((item) => item.getAsFile()).filter((f): f is File => f !== null)
1321
- if (files.length === 0) return
1322
- e.preventDefault()
1323
- intakeImages(files)
1324
- }, [intakeImages])
1325
-
1326
- const removeImage = useCallback((id: string) => {
1327
- const target = panel.attachments.find((a) => a.id === id)
1328
- if (target !== undefined) releaseDraftImage(target)
1329
- props.store.patch({ attachments: panel.attachments.filter((a) => a.id !== id) })
1330
- }, [panel.attachments, props.store])
1331
-
1332
- // Load one durable image's bytes → object URL for history rendering.
1333
- const imageLoader: ImageLoader = useCallback(async (ref) => {
1334
- const result = await api.attachment({ childId: panel.activeChildId ?? '', attachmentId: ref.attachmentId })
1335
- if (!result.ok) throw new Error(result.error.message)
1336
- return base64ObjectUrl(result.value.mediaType, result.value.data)
1337
- }, [panel.activeChildId])
1338
-
1339
- // The pending-question entry floats in the portal (SideChatShell), where it is
1340
- // mounted in both modes; only the floating-mode collapsed handle still lives
1341
- // here, next to the collapsed state it belongs to.
1342
- if (!props.embedded) {
1343
- if (!panel.open) return null
1344
-
1345
- if (collapsed) {
1346
- // Collapsed but a question dialog is pending: keep the floating entry so it
1347
- // can still be opened from beside the dialog (not just the collapsed handle).
1348
- if (mainQuestion !== null) {
1349
- return <QuestionFab store={props.store} t={props.t} onOpen={openQuestionPanel} />
1350
- }
1351
- // Collapsed handle: a floating round button on the right edge.
1352
- return (
1353
- <Tooltip label={props.t('panel.expand')} side="bottom">
1354
- <button type="button" className={css.collapsedHandle} onClick={() => { setCollapsed(false) }}>
1355
- <IconPanelLeftOutline16 size={16} />
1356
- </button>
1357
- </Tooltip>
1358
- )
1359
- }
1360
- }
1361
-
1362
- const elapsedMs = anchor === null ? 0 : Math.max(0, now - anchor)
1363
- const showClock = elapsedMs >= 15000
1364
-
1365
- const panelClass = props.embedded
1366
- ? `${css.panel} ${css.panelEmbedded}`
1367
- : `${css.panel}${props.conflictHidden ? ` ${css.panelConflictHidden}` : ''}`
1368
-
1369
- return (
1370
- <div
1371
- className={panelClass}
1372
- style={props.embedded ? undefined : { width }}
1373
- data-sidechat-panel={props.embedded ? 'embedded' : 'floating'}
1374
- >
1375
- {!props.embedded && <div className={css.panelResize} onMouseDown={startResize} />}
1376
- {!props.embedded && (
1377
- <div className={css.panelHeader}>
1378
- <span className={css.panelTitle}>{props.t('panel.title')}</span>
1379
- <div className={css.panelHeaderActions}>
1380
- <Tooltip label={props.t('panel.collapse')} side="bottom">
1381
- <button type="button" className={css.panelIconButton} onClick={() => { setCollapsed(true) }}>
1382
- <IconPanelLeftOutline16 size={16} />
1383
- </button>
1384
- </Tooltip>
1385
- </div>
1386
- </div>
1387
- )}
1388
-
1389
- {mainQuestion !== null && (() => {
1390
- const visible = mainQuestion.filter((q) => !dismissedQuestionIds.includes(q.id))
1391
- if (visible.length === 0) return null
1392
- return (
1393
- <div className={css.questionBlock}>
1394
- <div className={css.questionBlockActions}>
1395
- <button
1396
- type="button"
1397
- className={css.questionToggle}
1398
- onClick={() => { setQuestionCollapsed(!questionCollapsed) }}
1399
- >
1400
- {questionCollapsed ? props.t('question.expand') : props.t('question.collapse')}
1401
- </button>
1402
- <button
1403
- type="button"
1404
- className={css.questionDeleteAll}
1405
- onClick={() => { props.store.dismissAllQuestions(visible.map((q) => q.id)) }}
1406
- >
1407
- {props.t('question.deleteAll')}
1408
- </button>
1409
- </div>
1410
- {visible.map((q) => {
1411
- if (questionCollapsed) {
1412
- return (
1413
- <div key={q.id} className={`${css.questionItem} ${css.questionItemCollapsed}`}>
1414
- <span className={css.questionHeaderText}>{q.header ?? q.question}</span>
1415
- </div>
1416
- )
1417
- }
1418
- const options = q.options ?? []
1419
- return (
1420
- <div key={q.id} className={css.questionItem}>
1421
- <div className={css.questionHeader}>
1422
- <span className={css.questionHeaderText}>{q.header ?? q.question}</span>
1423
- <div className={css.questionHeaderActions}>
1424
- <button
1425
- type="button"
1426
- className={css.questionBringButton}
1427
- disabled={bringingKey !== null}
1428
- onClick={() => { bringQuestionText(buildAllText(q), `newall:${q.id}`, true) }}
1429
- >
1430
- {bringingKey === `newall:${q.id}` ? props.t('question.bringing') : props.t('question.bringAllNew')}
1431
- </button>
1432
- <button
1433
- type="button"
1434
- className={css.questionBringButton}
1435
- disabled={bringingKey !== null}
1436
- onClick={() => { bringQuestionText(buildAllText(q), `all:${q.id}`, false) }}
1437
- >
1438
- {bringingKey === `all:${q.id}` ? props.t('question.bringing') : props.t('question.bringAll')}
1439
- </button>
1440
- <button
1441
- type="button"
1442
- className={css.questionDelete}
1443
- aria-label={props.t('question.delete')}
1444
- title={props.t('question.delete')}
1445
- onClick={() => { props.store.dismissQuestion(q.id) }}
1446
- >
1447
- ×
1448
- </button>
1449
- </div>
1450
- </div>
1451
- <div className={css.questionBody}>{q.question}</div>
1452
- {q.detail !== undefined && q.detail !== '' && <div className={css.questionDetail}>{q.detail}</div>}
1453
- {options.map((o) => {
1454
- const key = `${q.id}:${o.label}`
1455
- return (
1456
- <div key={o.label} className={css.questionOption}>
1457
- <span className={css.questionOptionText}>
1458
- <span className={css.questionOptionLabel}>{o.label}</span>
1459
- {o.description !== undefined && o.description !== '' && <span className={css.questionOptionDesc}> — {o.description}</span>}
1460
- </span>
1461
- <button
1462
- type="button"
1463
- className={css.questionBringButton}
1464
- disabled={bringingKey !== null}
1465
- onClick={() => { bringQuestionText(buildOneText(q, o), `new:${key}`, true) }}
1466
- >
1467
- {bringingKey === `new:${key}` ? props.t('question.bringing') : props.t('question.bringOneNew')}
1468
- </button>
1469
- <button
1470
- type="button"
1471
- className={css.questionBringButton}
1472
- disabled={bringingKey !== null}
1473
- onClick={() => { bringQuestionText(buildOneText(q, o), key, false) }}
1474
- >
1475
- {bringingKey === key ? props.t('question.bringing') : props.t('question.bringOne')}
1476
- </button>
1477
- </div>
1478
- )
1479
- })}
1480
- </div>
1481
- )
1482
- })}
1483
- </div>
1484
- )
1485
- })()}
1486
-
1487
- <div className={css.panelList}>
1488
- {panel.items.length === 0
1489
- ? <div className={css.panelEmpty}>{props.t('panel.empty')}</div>
1490
- : (
1491
- <>
1492
- <div className={css.panelListActions}>
1493
- <button type="button" className={css.panelListDeleteAll} onClick={disposeAll}>
1494
- {props.t('panel.deleteAll')}
1495
- </button>
1496
- </div>
1497
- {panel.items.map((item) => (
1498
- <div key={item.childId} className={css.panelListItemRow}>
1499
- <button
1500
- type="button"
1501
- className={`${css.panelListItem} ${item.childId === panel.activeChildId ? css.panelListItemActive : ''}`}
1502
- onClick={() => { props.store.setActive(item.childId); void refreshHistory(props.store, item.childId) }}
1503
- >
1504
- <span className={css.panelListItemDot} data-running={item.running ? '1' : undefined} />
1505
- <span className={css.panelListItemLabel}>{item.childId}</span>
1506
- </button>
1507
- <button
1508
- type="button"
1509
- className={css.panelListItemRemove}
1510
- aria-label={props.t('panel.delete')}
1511
- title={props.t('panel.delete')}
1512
- onClick={() => { disposeItem(item.childId) }}
1513
- >
1514
- ×
1515
- </button>
1516
- </div>
1517
- ))}
1518
- </>
1519
- )}
1520
- </div>
1521
-
1522
- <div className={css.panelTranscript} ref={scrollRef}>
1523
- {panel.messages.map((message, index) => {
1524
- const textBlocks = message.blocks.filter((b) => b.type === 'text')
1525
- const imageBlocks = message.blocks.filter((b) => b.type === 'image')
1526
- const reasoningBlocks = message.blocks.filter((b) => b.type === 'reasoning')
1527
- const text = textBlocks.map((b) => (b.type === 'text' ? b.text : '')).join('\n')
1528
- const images = imageBlocks.map((b) => (b.type === 'image' ? { attachment: b.ref } : null)).filter((x): x is { attachment: SidechatImageRef } => x !== null)
1529
- if (message.role === 'user') {
1530
- return (
1531
- <div key={index} className={css.messageUser}>
1532
- {text !== '' && <span className={css.messageUserText}>{text}</span>}
1533
- {images.length > 0 && (
1534
- <ImageGallery images={images.map((image) => image.attachment)} load={imageLoader} align="end" labels={messageImageLabels} />
1535
- )}
1536
- </div>
1537
- )
1538
- }
1539
- return (
1540
- <div key={index} className={css.messageAssistant} data-sidechat-role="assistant">
1541
- {reasoningBlocks.map((block, rIndex) => (
1542
- <ReasoningRow key={rIndex} text={block.type === 'reasoning' ? block.text : ''} t={props.t} />
1543
- ))}
1544
- {text !== '' && <MarkdownText text={text} labels={markdownLabels} />}
1545
- {text !== '' && (
1546
- <div className={css.messageActions}>
1547
- <button
1548
- type="button"
1549
- className={css.messageInsertButton}
1550
- onClick={() => {
1551
- void props.bringToMain(text).then((ok) => {
1552
- if (!ok) props.store.patch({ error: props.t('insert.failed') })
1553
- })
1554
- }}
1555
- >
1556
- {props.t('insert.direct')}
1557
- </button>
1558
- <button
1559
- type="button"
1560
- className={css.messageInsertButton}
1561
- disabled={summarizingIndex === index}
1562
- onClick={() => {
1563
- setSummarizingIndex(index)
1564
- void props.summarizeBring(text).then((ok) => {
1565
- setSummarizingIndex(null)
1566
- if (!ok) props.store.patch({ error: props.t('insert.summarizeFailed') })
1567
- })
1568
- }}
1569
- >
1570
- {summarizingIndex === index ? props.t('insert.summarizing') : props.t('insert.summarize')}
1571
- </button>
1572
- </div>
1573
- )}
1574
- </div>
1575
- )
1576
- })}
1577
- {activeRunning && (
1578
- <div className={css.panelRunning} role="status" aria-live="polite">
1579
- <span className={css.panelRunningDot} />
1580
- <span>{props.t('panel.running')}</span>
1581
- {showClock && <span className={css.panelRunningClock}>{props.formatDuration(elapsedMs)}</span>}
1582
- </div>
1583
- )}
1584
- {panel.error !== null && <div className={css.panelError}>{props.t('panel.error')}: {panel.error}</div>}
1585
- </div>
1586
-
1587
- <div className={css.panelComposer}>
1588
- {panel.attachment !== null && (
1589
- <div className={css.panelAttachment}>
1590
- <span className={css.panelAttachmentText}>{panel.attachment}</span>
1591
- <button type="button" className={css.panelAttachmentRemove} onClick={() => { props.store.patch({ attachment: null }) }}>×</button>
1592
- </div>
1593
- )}
1594
- {panel.attachments.length > 0 && (
1595
- <div className={css.panelAttachmentRail}>
1596
- <AttachmentRail
1597
- items={panel.attachments.map((a) => ({ id: a.id, previewUrl: a.previewUrl, alt: a.file.name || props.t('image.label'), removeLabel: props.t('image.remove') }))}
1598
- labels={attachmentRailLabels}
1599
- onOpen={(item) => { const a = panel.attachments.find((x) => x.id === item.id); if (a !== undefined) setLightbox(a) }}
1600
- onRemove={(item) => { removeImage(item.id) }}
1601
- />
1602
- </div>
1603
- )}
1604
- <textarea
1605
- className={css.panelTextarea}
1606
- placeholder={props.t('panel.input.placeholder')}
1607
- value={panel.draft}
1608
- onChange={(e) => { props.store.patch({ draft: e.target.value }) }}
1609
- onPaste={onPaste}
1610
- onKeyDown={(e) => {
1611
- if (e.key === 'Enter' && !e.shiftKey) {
1612
- e.preventDefault()
1613
- send()
1614
- }
1615
- }}
1616
- />
1617
- <div className={css.panelToolbar}>
1618
- <ModelSelect
1619
- directory={panel.directory}
1620
- selection={{ provider: panel.provider, model: panel.model, effort: panel.effort }}
1621
- onSelect={onModelSelect}
1622
- t={props.t}
1623
- />
1624
- <PermissionSelect
1625
- permissions={panel.permissions}
1626
- preset={panel.preset}
1627
- onSelect={onPresetChange}
1628
- t={props.t}
1629
- />
1630
- <Tooltip label={activeRunning ? props.t('panel.stop') : props.t('panel.send')} side="top" delayMs={500}>
1631
- <button
1632
- type="button"
1633
- className={css.primary}
1634
- aria-label={activeRunning ? props.t('panel.stop') : props.t('panel.send')}
1635
- onClick={activeRunning ? stop : send}
1636
- >
1637
- {activeRunning ? <IconStopFill16 size={16} /> : <IconSendOutline14 size={16} />}
1638
- </button>
1639
- </Tooltip>
1640
- </div>
1641
-
1642
- <label className={css.panelLookup}>
1643
- <input
1644
- type="checkbox"
1645
- checked={panel.lookup}
1646
- onChange={(e) => { props.store.patch({ lookup: e.target.checked }) }}
1647
- />
1648
- <span>{props.t('panel.lookup')}</span>
1649
- </label>
1650
- </div>
1651
-
1652
- <div className={css.panelFooter}>
1653
- <button type="button" className={css.panelDispose} onClick={dispose}>{props.t('panel.dispose')}</button>
1654
- </div>
1655
-
1656
- {dragActive && <DropOverlay disabled={false} labels={dropOverlayLabels} />}
1657
- {lightbox !== null && (
1658
- <ImageLightbox
1659
- src={lightbox.previewUrl}
1660
- alt={lightbox.file.name || props.t('image.label')}
1661
- labels={{ dialog: props.t('image.lightboxDialog'), close: props.t('image.close') }}
1662
- onClose={() => { setLightbox(null) }}
1663
- />
1664
- )}
1665
- </div>
1666
- )
1667
- }
1668
-
1669
- /** The "Side chat" settings section (two switches + a prompt textarea). */
1670
- /** Dock-mode body: the side-chat panel embedded in the built-in right sidebar's tab pane. */
1671
- function EmbeddedSidechatPanel(props: {
1672
- store: SidechatStore
1673
- t: (key: SidechatLocaleKey) => string
1674
- formatDuration: (ms: number) => string
1675
- bringToMain: (text: string) => Promise<boolean>
1676
- summarizeBring: (text: string) => Promise<boolean>
1677
- askSidechat: (text: string) => Promise<boolean>
1678
- askSidechatNew: (text: string) => Promise<boolean>
1679
- }) {
1680
- // The tab seat only mounts for the current session, so the shared store's
1681
- // current-conversation state is the right one to draw.
1682
- return (
1683
- <SidechatPanel
1684
- embedded
1685
- store={props.store}
1686
- t={props.t}
1687
- formatDuration={props.formatDuration}
1688
- bringToMain={props.bringToMain}
1689
- summarizeBring={props.summarizeBring}
1690
- askSidechat={props.askSidechat}
1691
- askSidechatNew={props.askSidechatNew}
1692
- />
1693
- )
1694
- }
1695
-
1696
- /** The portalled shell: floating menus + the panel, switching on prefs.panelHome. */
1697
- function SideChatShell(props: {
1698
- store: SidechatStore
1699
- t: (key: SidechatLocaleKey) => string
1700
- formatDuration: (ms: number) => string
1701
- bringToMain: (text: string) => Promise<boolean>
1702
- summarizeBring: (text: string) => Promise<boolean>
1703
- askSidechat: (text: string) => Promise<boolean>
1704
- askSidechatNew: (text: string) => Promise<boolean>
1705
- }) {
1706
- const snap = useSyncExternalStore(props.store.subscribe, props.store.getSnapshot)
1707
- const [builtinOverlay, setBuiltinOverlay] = useState(false)
1708
-
1709
- // While the built-in right sidebar actually shows its panel (docked, fullscreen
1710
- // or a floated pane), the floating side-chat panel would sit on top of it: yield
1711
- // — slide the side chat away and drop its layout margin until the built-in panel
1712
- // is closed again. Docked mode shares the sidebar instead, so it never needs
1713
- // this guard.
1714
- useEffect(() => {
1715
- const probe = (): boolean => {
1716
- if (document.querySelector('[data-sidebar-right-panel][data-sidebar-right-open]') !== null) return true
1717
- const floatHost = document.querySelector('[data-sidebar-right-float-host]')
1718
- if (floatHost !== null && floatHost.childElementCount > 0) return true
1719
- return false
1720
- }
1721
- let active = probe()
1722
- const apply = (): void => {
1723
- const next = probe()
1724
- if (next !== active) {
1725
- active = next
1726
- setBuiltinOverlay(next)
1727
- }
1728
- }
1729
- apply()
1730
- const observer = new MutationObserver(apply)
1731
- observer.observe(document.body, {
1732
- childList: true,
1733
- subtree: true,
1734
- attributes: true,
1735
- attributeFilter: ['data-sidebar-right-open', 'data-sidebar-right-panel', 'data-sidebar-right-float-host'],
1736
- })
1737
- window.addEventListener('resize', apply)
1738
- return () => {
1739
- observer.disconnect()
1740
- window.removeEventListener('resize', apply)
1741
- }
1742
- }, [])
1743
-
1744
- return (
1745
- <>
1746
- <SelectionMenu store={props.store} t={props.t} />
1747
- <BringBackMenu store={props.store} t={props.t} bringToMain={props.bringToMain} summarizeBring={props.summarizeBring} />
1748
- {/* The pending-question entry floats in the portal, not inside the panel:
1749
- in dock mode the panel (and its tab body) does not exist until the tab
1750
- was opened at least once, but the entry must appear regardless. */}
1751
- {snap.mainQuestion !== null && !snap.panel.open && (
1752
- <QuestionFab
1753
- store={props.store}
1754
- t={props.t}
1755
- onOpen={() => { props.store.openPanel(snap.panel.parentSessionId) }}
1756
- />
1757
- )}
1758
- {snap.prefs.panelHome === 'floating' && (
1759
- <SidechatPanel
1760
- store={props.store}
1761
- t={props.t}
1762
- formatDuration={props.formatDuration}
1763
- bringToMain={props.bringToMain}
1764
- summarizeBring={props.summarizeBring}
1765
- askSidechat={props.askSidechat}
1766
- askSidechatNew={props.askSidechatNew}
1767
- conflictHidden={builtinOverlay}
1768
- />
1769
- )}
1770
- </>
1771
- )
1772
- }
1773
-
1774
- function SettingsSection(props: { store: SidechatStore; t: (key: SidechatLocaleKey) => string }) {
1775
- const { store, t } = props
1776
- const { prefs } = useSyncExternalStore(store.subscribe, store.getSnapshot)
1777
- const [promptDraft, setPromptDraft] = useState(prefs.defaultPrompt)
1778
-
1779
- // Keep the local textarea in sync with the persisted value.
1780
- useEffect(() => { setPromptDraft(prefs.defaultPrompt) }, [prefs.defaultPrompt])
1781
-
1782
- const toggle = useCallback((patch: Partial<SubchatPrefs>) => {
1783
- const previous = prefs
1784
- const next = { ...previous, ...patch }
1785
- store.setPrefs(next)
1786
- void api.settingsUpdate(patch).then((result) => {
1787
- if (!result.ok) store.setPrefs(previous)
1788
- })
1789
- }, [prefs, store])
1790
-
1791
- const commitPrompt = useCallback(() => {
1792
- const value = promptDraft.trim()
1793
- if (value !== prefs.defaultPrompt) toggle({ defaultPrompt: value })
1794
- }, [promptDraft, prefs.defaultPrompt, toggle])
1795
-
1796
- return (
1797
- <div className={css.settingsSection}>
1798
- <label className={css.settingsRow}>
1799
- <span className={css.settingsRowText}>
1800
- <span className={css.settingsRowTitle}>{t('settings.lookupTitle')}</span>
1801
- <span className={css.settingsRowDesc}>{t('settings.lookupDesc')}</span>
1802
- </span>
1803
- <input
1804
- type="checkbox"
1805
- className={css.settingsToggle}
1806
- checked={prefs.lookupDefault}
1807
- aria-label={t('settings.lookupTitle')}
1808
- onChange={(e) => { toggle({ lookupDefault: e.currentTarget.checked }) }}
1809
- />
1810
- </label>
1811
- <label className={css.settingsRow}>
1812
- <span className={css.settingsRowText}>
1813
- <span className={css.settingsRowTitle}>{t('settings.sendImmediatelyTitle')}</span>
1814
- <span className={css.settingsRowDesc}>{t('settings.sendImmediatelyDesc')}</span>
1815
- </span>
1816
- <input
1817
- type="checkbox"
1818
- className={css.settingsToggle}
1819
- checked={prefs.sendImmediately}
1820
- aria-label={t('settings.sendImmediatelyTitle')}
1821
- onChange={(e) => { toggle({ sendImmediately: e.currentTarget.checked }) }}
1822
- />
1823
- </label>
1824
- <div className={css.settingsRow}>
1825
- <span className={css.settingsRowText}>
1826
- <span className={css.settingsRowTitle}>{t('settings.bringModeTitle')}</span>
1827
- <span className={css.settingsRowDesc}>{t('settings.bringModeDesc')}</span>
1828
- </span>
1829
- </div>
1830
- <div className={css.settingsBringMode}>
1831
- <label className={`${css.settingsBringOption} ${prefs.bringMode === 'draft' ? css.settingsBringOptionActive : ''}`}>
1832
- <input
1833
- type="radio"
1834
- name="dsh-side-chat-bring-mode"
1835
- className={css.settingsToggle}
1836
- checked={prefs.bringMode === 'draft'}
1837
- onChange={() => { toggle({ bringMode: 'draft' }) }}
1838
- />
1839
- <span className={css.settingsRowText}>
1840
- <span className={css.settingsRowTitle}>{t('settings.bringModeDraftTitle')}</span>
1841
- <span className={css.settingsRowDesc}>{t('settings.bringModeDraftDesc')}</span>
1842
- </span>
1843
- </label>
1844
- <label className={`${css.settingsBringOption} ${prefs.bringMode === 'context' ? css.settingsBringOptionActive : ''}`}>
1845
- <input
1846
- type="radio"
1847
- name="dsh-side-chat-bring-mode"
1848
- className={css.settingsToggle}
1849
- checked={prefs.bringMode === 'context'}
1850
- onChange={() => { toggle({ bringMode: 'context' }) }}
1851
- />
1852
- <span className={css.settingsRowText}>
1853
- <span className={css.settingsRowTitle}>{t('settings.bringModeContextTitle')}</span>
1854
- <span className={css.settingsRowDesc}>{t('settings.bringModeContextDesc')}</span>
1855
- </span>
1856
- </label>
1857
- </div>
1858
- <div className={css.settingsRow}>
1859
- <span className={css.settingsRowText}>
1860
- <span className={css.settingsRowTitle}>{t('settings.panelHomeTitle')}</span>
1861
- <span className={css.settingsRowDesc}>{t('settings.panelHomeDesc')}</span>
1862
- </span>
1863
- </div>
1864
- <div className={css.settingsBringMode}>
1865
- <label className={`${css.settingsBringOption} ${prefs.panelHome === 'floating' ? css.settingsBringOptionActive : ''}`}>
1866
- <input
1867
- type="radio"
1868
- name="dsh-side-chat-panel-home"
1869
- className={css.settingsToggle}
1870
- checked={prefs.panelHome === 'floating'}
1871
- onChange={() => { toggle({ panelHome: 'floating' }) }}
1872
- />
1873
- <span className={css.settingsRowText}>
1874
- <span className={css.settingsRowTitle}>{t('settings.panelHomeFloatingTitle')}</span>
1875
- <span className={css.settingsRowDesc}>{t('settings.panelHomeFloatingDesc')}</span>
1876
- </span>
1877
- </label>
1878
- <label className={`${css.settingsBringOption} ${prefs.panelHome === 'sidebar-right' ? css.settingsBringOptionActive : ''}`}>
1879
- <input
1880
- type="radio"
1881
- name="dsh-side-chat-panel-home"
1882
- className={css.settingsToggle}
1883
- checked={prefs.panelHome === 'sidebar-right'}
1884
- onChange={() => { toggle({ panelHome: 'sidebar-right' }) }}
1885
- />
1886
- <span className={css.settingsRowText}>
1887
- <span className={css.settingsRowTitle}>{t('settings.panelHomeDockTitle')}</span>
1888
- <span className={css.settingsRowDesc}>{t('settings.panelHomeDockDesc')}</span>
1889
- </span>
1890
- </label>
1891
- </div>
1892
- <div className={css.settingsRow}>
1893
- <span className={css.settingsRowText}>
1894
- <span className={css.settingsRowTitle}>{t('settings.defaultPromptTitle')}</span>
1895
- <span className={css.settingsRowDesc}>{t('settings.defaultPromptDesc')}</span>
1896
- </span>
1897
- </div>
1898
- <textarea
1899
- className={css.settingsPromptInput}
1900
- value={promptDraft}
1901
- placeholder={t('settings.defaultPromptPlaceholder')}
1902
- aria-label={t('settings.defaultPromptTitle')}
1903
- onChange={(e) => { setPromptDraft(e.currentTarget.value) }}
1904
- onBlur={commitPrompt}
1905
- />
1906
- </div>
1907
- )
1908
- }
1909
-
1910
- /** Client plugin body. */
1911
- export function apply(ctx: Context): void {
1912
- const store = createStore()
1913
-
1914
- // Localized copy follows the DSH locale (module-level mirror for callbacks).
1915
- let activeLocale = ctx.locale.getSnapshot().active
1916
-
1917
- /**
1918
- * The selected main-view Session. DSH 0.1.6-alpha.2 reworked Client Sessions
1919
- * into independent instances and moved the selection off the list snapshot
1920
- * (`sessions.list.current`, dropped) onto the ui-session scope binding;
1921
- * older lines keep it on the list. Read the binding first, then fall back.
1922
- */
1923
- const currentSessionId = (): string | undefined =>
1924
- ctx.uiSession.adapter?.current.getSnapshot().key ?? ctx.sessions.list.getSnapshot().current
1925
-
1926
- /** Subscribe to current-Session changes on whichever face this DSH exposes. */
1927
- const subscribeCurrentSession = (fn: () => void): (() => void) => {
1928
- const offList = ctx.sessions.list.subscribe(fn)
1929
- const offAdapter = ctx.uiSession.adapter?.current.subscribe(fn)
1930
- if (offAdapter === undefined) return offList
1931
- return () => { offList(); offAdapter() }
1932
- }
1933
-
1934
- /**
1935
- * The pending interaction for one Session — DSH 0.1.6-alpha.2 moved it from
1936
- * `uiSession.pendingInteractions` onto `uiSession.sessionStatus`; read
1937
- * whichever face this build exposes.
1938
- */
1939
- const pendingInteractionFor = (sessionId: string): SidePendingInteraction | undefined => {
1940
- if (ctx.uiSession.sessionStatus !== undefined) {
1941
- return ctx.uiSession.sessionStatus.getSnapshot().get(sessionId)?.pendingInteraction
1942
- }
1943
- return ctx.uiSession.pendingInteractions?.getSnapshot().get(sessionId)
1944
- }
1945
-
1946
- /** Subscribe to pending-interaction changes on whichever face this DSH exposes. */
1947
- const subscribePendingInteractions = (fn: () => void): (() => void) => {
1948
- if (ctx.uiSession.sessionStatus !== undefined) return ctx.uiSession.sessionStatus.subscribe(fn)
1949
- if (ctx.uiSession.pendingInteractions !== undefined) return ctx.uiSession.pendingInteractions.subscribe(fn)
1950
- return () => {}
1951
- }
1952
-
1953
- /** Append text to the main composer draft (draft bring mode). */
1954
- const draftBring = (text: string): boolean => {
1955
- const trimmed = text.trim()
1956
- if (trimmed === '') return false
1957
- const sessionId = currentSessionId()
1958
- if (sessionId === undefined) return false
1959
- try {
1960
- const actx = ctx.sessions.scope(sessionId)
1961
- if (actx === undefined) return false
1962
- const input = ctx.conversation.input.for(actx)
1963
- const draft = input.state.getSnapshot().draft
1964
- input.setDraft(draft === '' ? trimmed : `${draft}\n\n${trimmed}`)
1965
- return true
1966
- } catch {
1967
- return false
1968
- }
1969
- }
1970
-
1971
- /** Inject text into the main conversation as a collapsed, source-tagged context row. */
1972
- const injectBring = async (text: string, summary: string): Promise<boolean> => {
1973
- const trimmed = text.trim()
1974
- if (trimmed === '') return false
1975
- const sessionId = currentSessionId()
1976
- if (sessionId === undefined) return false
1977
- const result = await api.inject({ parentSessionId: sessionId, text: trimmed, summary })
1978
- return result.ok
1979
- }
1980
-
1981
- /** Land text in the main conversation per the configured bring mode. */
1982
- const landText = async (text: string, summaryKey: SidechatLocaleKey): Promise<boolean> => {
1983
- const mode = store.getSnapshot().prefs.bringMode
1984
- if (mode === 'context') {
1985
- return injectBring(text, translate(activeLocale, summaryKey))
1986
- }
1987
- return draftBring(text)
1988
- }
1989
-
1990
- /** Bring a reply back directly (routed through the configured mode). */
1991
- const bringToMain = (text: string): Promise<boolean> => landText(text, 'insert.contextSummary')
1992
-
1993
- /** Summarize text with the side chat's inherited model, then bring the summary back. */
1994
- const summarizeBring = async (text: string): Promise<boolean> => {
1995
- const trimmed = text.trim()
1996
- if (trimmed === '') return false
1997
- const snap = store.getSnapshot().panel
1998
- if (snap.parentSessionId === '') return false
1999
- const result = await api.summarize({
2000
- parentSessionId: snap.parentSessionId,
2001
- text: trimmed,
2002
- ...(snap.provider !== '' ? { provider: snap.provider } : {}),
2003
- ...(snap.model !== '' ? { model: snap.model } : {}),
2004
- ...(snap.effort !== '' ? { reasoningEffort: snap.effort } : {}),
2005
- locale: activeLocale,
2006
- })
2007
- if (!result.ok) return false
2008
- return landText(result.value.summary, 'insert.summarizeContextSummary')
2009
- }
2010
-
2011
- /** Ask a piece of text in the side chat (start a new one, or continue the active one). */
2012
- const askSidechat = async (text: string): Promise<boolean> => {
2013
- const trimmed = text.trim()
2014
- if (trimmed === '') return false
2015
- const parentSessionId = currentSessionId()
2016
- if (parentSessionId === undefined) return false
2017
- const panel = store.getSnapshot().panel
2018
- const content: PromptContentPart[] = [{ type: 'text', text: trimmed }]
2019
-
2020
- // Reuse the active side chat, else the first existing one, else create one —
2021
- // so bringing dialog questions in doesn't pile up a new side chat per ask.
2022
- const target = panel.activeChildId ?? panel.items[0]?.childId ?? null
2023
-
2024
- if (target === null) {
2025
- const result = await api.start({
2026
- parentSessionId,
2027
- content,
2028
- lookupEnabled: panel.lookup,
2029
- ...(panel.provider !== '' ? { provider: panel.provider } : {}),
2030
- ...(panel.model !== '' ? { model: panel.model } : {}),
2031
- ...(panel.effort !== '' ? { reasoningEffort: panel.effort } : {}),
2032
- })
2033
- if (result.ok) {
2034
- store.openPanel(parentSessionId)
2035
- store.setActive(result.value.childId)
2036
- store.patch({ provider: result.value.provider, model: result.value.model, effort: result.value.reasoningEffort ?? '' })
2037
- void refreshList(store, parentSessionId)
2038
- void refreshDirectory(store)
2039
- return true
2040
- }
2041
- return false
2042
- }
2043
-
2044
- const childId = target
2045
- if (childId !== panel.activeChildId) store.setActive(childId)
2046
- setItemRunning(store, childId, true)
2047
- const result = await api.followup({ childId, content, lookupEnabled: panel.lookup })
2048
- if (!result.ok) {
2049
- setItemRunning(store, childId, false)
2050
- store.patch({ error: result.error.message })
2051
- }
2052
- void refreshList(store, parentSessionId)
2053
- void refreshHistory(store, childId)
2054
- return result.ok
2055
- }
2056
-
2057
- /** Ask a piece of text in a brand-new side chat (never reuses an existing one). */
2058
- const askSidechatNew = async (text: string): Promise<boolean> => {
2059
- const trimmed = text.trim()
2060
- if (trimmed === '') return false
2061
- const parentSessionId = currentSessionId()
2062
- if (parentSessionId === undefined) return false
2063
- const panel = store.getSnapshot().panel
2064
- const content: PromptContentPart[] = [{ type: 'text', text: trimmed }]
2065
- const result = await api.start({
2066
- parentSessionId,
2067
- content,
2068
- lookupEnabled: panel.lookup,
2069
- ...(panel.provider !== '' ? { provider: panel.provider } : {}),
2070
- ...(panel.model !== '' ? { model: panel.model } : {}),
2071
- ...(panel.effort !== '' ? { reasoningEffort: panel.effort } : {}),
2072
- })
2073
- if (result.ok) {
2074
- store.openPanel(parentSessionId)
2075
- store.setActive(result.value.childId)
2076
- store.patch({ provider: result.value.provider, model: result.value.model, effort: result.value.reasoningEffort ?? '' })
2077
- void refreshList(store, parentSessionId)
2078
- void refreshDirectory(store)
2079
- return true
2080
- }
2081
- return false
2082
- }
2083
-
2084
- ctx.effect(() => {
2085
- const offZh = ctx.locale.register(LOCALE_NS, 'zh', zh)
2086
- const offEn = ctx.locale.register(LOCALE_NS, 'en', en)
2087
- const offSub = ctx.locale.subscribe(() => {
2088
- activeLocale = ctx.locale.getSnapshot().active
2089
- store.patch({})
2090
- })
2091
- return () => { offZh(); offEn(); offSub() }
2092
- }, 'dsh-side-chat: dictionaries')
2093
-
2094
- // Load the persisted preferences once.
2095
- void api.settingsGet().then((result) => {
2096
- if (!result.ok) return
2097
- const raw = result.value.value as Partial<SubchatPrefs> | null | undefined
2098
- if (raw === null || raw === undefined) return
2099
- store.setPrefs({
2100
- lookupDefault: typeof raw.lookupDefault === 'boolean' ? raw.lookupDefault : SUBCHAT_PREFS_DEFAULTS.lookupDefault,
2101
- sendImmediately: typeof raw.sendImmediately === 'boolean' ? raw.sendImmediately : SUBCHAT_PREFS_DEFAULTS.sendImmediately,
2102
- defaultPrompt: typeof raw.defaultPrompt === 'string' ? raw.defaultPrompt : SUBCHAT_PREFS_DEFAULTS.defaultPrompt,
2103
- bringMode: raw.bringMode === 'context' ? 'context' : 'draft',
2104
- panelHome: raw.panelHome === 'floating' ? 'floating' : 'sidebar-right',
2105
- })
2106
- })
2107
-
2108
- // Track the current conversation (per-conversation panel state).
2109
- ctx.effect(() => {
2110
- let lastId: string | undefined
2111
- const sync = (): void => {
2112
- const next = currentSessionId()
2113
- if (next === lastId) return
2114
- lastId = next
2115
- store.setCurrent(next)
2116
- if (next === undefined) return
2117
- void refreshList(store, next)
2118
- const panel = store.getSnapshot().panel
2119
- if (panel.open && panel.activeChildId !== null) {
2120
- void refreshHistory(store, panel.activeChildId)
2121
- }
2122
- }
2123
- sync()
2124
- return subscribeCurrentSession(sync)
2125
- }, 'dsh-side-chat: follow current conversation')
2126
-
2127
- // Track the main conversation's pending user-question dialog so the panel can
2128
- // list its questions/options with per-item bring-back buttons. DSH surfaces
2129
- // the pending interaction per Session: `uiSession.pendingInteractions`
2130
- // through 0.1.6-alpha.1, and `uiSession.sessionStatus` (`.pendingInteraction`)
2131
- // from 0.1.6-alpha.2 — `pendingInteractionFor` reads whichever is present.
2132
- // Only re-publishes when the question object identity changes.
2133
- ctx.effect(() => {
2134
- let lastQuestion: unknown = undefined
2135
- const read = (): void => {
2136
- const sessionId = currentSessionId()
2137
- if (sessionId === undefined) {
2138
- store.setMainQuestion(null)
2139
- return
2140
- }
2141
- const interaction = pendingInteractionFor(sessionId)
2142
- const isQuestion = interaction !== undefined && (interaction.kind === 'question' || interaction.kind === 'plan-review')
2143
- const question = isQuestion ? interaction : undefined
2144
- if (question === lastQuestion) return
2145
- lastQuestion = question
2146
- const questions = question?.questions ?? null
2147
- store.setMainQuestion(questions === null ? null : [...questions])
2148
- }
2149
- read()
2150
- const offPending = subscribePendingInteractions(read)
2151
- const offList = subscribeCurrentSession(read)
2152
- return () => { offPending(); offList() }
2153
- }, 'dsh-side-chat: track main question dialog')
2154
-
2155
- // Safety net: once the main conversation no longer has a pending question
2156
- // dialog, clear the tracked question so the side-panel list disappears too
2157
- // (covers cases where the subscription misses the settlement edge).
2158
- ctx.effect(() => {
2159
- const timer = window.setInterval(() => {
2160
- if (store.getSnapshot().mainQuestion === null) return
2161
- const sessionId = currentSessionId()
2162
- if (sessionId === undefined) return
2163
- const interaction = pendingInteractionFor(sessionId)
2164
- const hasQuestion = interaction !== undefined && (interaction.kind === 'question' || interaction.kind === 'plan-review')
2165
- if (!hasQuestion) store.setMainQuestion(null)
2166
- }, 1200)
2167
- return () => { window.clearInterval(timer) }
2168
- }, 'dsh-side-chat: clear stale question dialog')
2169
-
2170
- // Optional dock mode: live inside the new built-in right sidebar
2171
- // (dsh-client-ui-sidebar-right) as a "Side chat" tab instead of the floating
2172
- // panel. Registered on demand; falls back to floating when the service is
2173
- // absent (older deployments) without ever failing to mount.
2174
- ctx.effect(() => {
2175
- let cleanup: (() => void) | undefined
2176
- // Explicit phase machine: sync() runs on every store notify, and patching
2177
- // the store from inside sync() must never re-enter registration work.
2178
- let phase: 'idle' | 'docked' | 'unavailable' = 'idle'
2179
-
2180
- const sync = (): void => {
2181
- const docked = store.getSnapshot().prefs.panelHome === 'sidebar-right'
2182
- if (docked) {
2183
- if (phase === 'docked' || phase === 'unavailable') return
2184
- const sidebarRight = ctx.get('sidebarRight') as SideSidebarRight | undefined
2185
- // The controller carries its tab registry as `tabs` (TypeScript-private
2186
- // upstream, so a defensive read); official builds since 0.1.5 also
2187
- // publish the same registry as the standalone `sidebarRightTabs`
2188
- // service, which is the fallback when that field is ever renamed.
2189
- const sidebarRightTabs = sidebarRight?.tabs
2190
- ?? (ctx.get('sidebarRightTabs') as SideSidebarRightTabs | undefined)
2191
- if (sidebarRight === undefined || sidebarRightTabs === undefined) {
2192
- phase = 'unavailable'
2193
- store.patch({ error: translate(activeLocale, 'dock.unavailable') })
2194
- return
2195
- }
2196
- const dockT = (key: SidechatLocaleKey): string => translate(activeLocale, key)
2197
-
2198
- const disposeType = sidebarRightTabs.register({
2199
- id: SIDEBAR_TAB_ID,
2200
- kind: SIDEBAR_TAB_KIND,
2201
- priority: 'extension',
2202
- title: () => dockT('dock.title'),
2203
- })
2204
- // Body seat: rendered for the committed tab whose type id matches `key`.
2205
- const disposeBody = ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
2206
- name: 'sidebar.right.pane.tab',
2207
- key: SIDEBAR_TAB_ID,
2208
- inject: () => ({
2209
- store,
2210
- t: dockT,
2211
- formatDuration,
2212
- bringToMain,
2213
- summarizeBring,
2214
- askSidechat,
2215
- askSidechatNew,
2216
- }),
2217
- }, EmbeddedSidechatPanel))
2218
- const launch = (): void => {
2219
- try {
2220
- sidebarRight.openTab(SIDEBAR_TAB_KIND, { revealIfOpened: true })
2221
- } catch (error) {
2222
- store.patch({ error: `${translate(activeLocale, 'dock.openFailed')}: ${error instanceof Error ? error.message : String(error)}` })
2223
- }
2224
- }
2225
- store.setDockLaunch(launch)
2226
- cleanup = () => {
2227
- store.setDockLaunch(null)
2228
- disposeBody()
2229
- disposeType()
2230
- }
2231
- phase = 'docked'
2232
- return
2233
- }
2234
- if (phase === 'idle') return
2235
- phase = 'idle'
2236
- store.setDockLaunch(null)
2237
- cleanup?.()
2238
- cleanup = undefined
2239
- }
2240
-
2241
- sync()
2242
- const off = store.subscribe(sync)
2243
- return () => {
2244
- off()
2245
- cleanup?.()
2246
- }
2247
- }, 'dsh-side-chat: right-sidebar dock')
2248
-
2249
- // The "Side chat" settings section.
2250
- const settingsT = (key: SidechatLocaleKey): string => translate(activeLocale, key)
2251
- const formatDuration = (ms: number): string => formatRunDuration(ms, activeLocale)
2252
- ctx.slots.inject('settings.section', () => ctx.slots.register({
2253
- name: 'settings.section',
2254
- id: 'dsh-side-chat',
2255
- order: 110,
2256
- label: () => settingsT('settingsNav'),
2257
- inject: () => ({ store, t: settingsT }),
2258
- }, SettingsSection))
2259
-
2260
- // Mount the portalled tree onto document.body.
2261
- ctx.effect(() => {
2262
- const host = document.createElement('div')
2263
- host.setAttribute('data-dsh-side-chat', '')
2264
- document.body.appendChild(host)
2265
- const root = createRoot(host)
2266
-
2267
- const t = (key: SidechatLocaleKey): string => translate(activeLocale, key)
2268
- root.render(<SideChatShell
2269
- store={store}
2270
- t={t}
2271
- formatDuration={formatDuration}
2272
- bringToMain={bringToMain}
2273
- summarizeBring={summarizeBring}
2274
- askSidechat={askSidechat}
2275
- askSidechatNew={askSidechatNew}
2276
- />)
2277
-
2278
- return () => {
2279
- root.unmount()
2280
- host.remove()
2281
- }
2282
- }, 'dsh-side-chat: panel mount')
2283
- }
1
+ /**
2
+ * Client half of dsh-side-chat: a text-selection floating menu, a right-side
3
+ * side-chat panel (drag-resizable + collapsible), the main-conversation-style
4
+ * model/permission selectors and send/stop buttons, and a "Side chat" settings
5
+ * section. The panel is isolated per current conversation and talks to the
6
+ * host /sidechat API.
7
+ */
8
+ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent } from 'react'
9
+ import { useSyncExternalStore } from 'react'
10
+ import { createRoot, type Root } from 'react-dom/client'
11
+ import {
12
+ DisclosureRow,
13
+ IconCheckOutlineRegular,
14
+ IconChevronDownOutlineRegular,
15
+ IconChevronRightOutlineRegular,
16
+ IconPanelLeftOutlineRegular,
17
+ IconSendOutlineRegular,
18
+ IconStopFillRegular,
19
+ IconThinkOutlineRegular,
20
+ MarkdownText,
21
+ Menu,
22
+ Tooltip,
23
+ } from '@deepseek-ai/dsh-client-ui-primitives'
24
+ import {
25
+ AttachmentRail,
26
+ DropOverlay,
27
+ ImageGallery,
28
+ ImageLightbox,
29
+ type ImageLoader,
30
+ } from './attachments/index.ts'
31
+ import type { Context, SidePendingInteraction, SideQuestionItem, SideQuestionOption, SideSidebarRight, SideSidebarRightTabs } from '../context-types.ts'
32
+ import {
33
+ api,
34
+ type PromptContentPart,
35
+ type SidechatDirectory,
36
+ type SidechatImageRef,
37
+ type SidechatListItem,
38
+ type SidechatMessage,
39
+ type SidechatPermissions,
40
+ } from './api.ts'
41
+ import { en, LOCALE_NS, zh, type SidechatLocaleKey } from './locales.ts'
42
+ import { SUBCHAT_PREFS_DEFAULTS, type SubchatPrefs } from '../settings-shared.ts'
43
+ import css from './client.module.css'
44
+ import './layout.css'
45
+
46
+ /** Services required before mounting. */
47
+ export const inject = ['sessions', 'locale', 'slots', 'conversation', 'uiSession']
48
+
49
+ /** A text selection the floating menu anchors to. */
50
+ interface SelectionAnchor {
51
+ text: string
52
+ x: number
53
+ y: number
54
+ }
55
+
56
+ /** The panel UI state, per current parent conversation. */
57
+ interface PanelState {
58
+ open: boolean
59
+ parentSessionId: string
60
+ activeChildId: string | null
61
+ items: SidechatListItem[]
62
+ messages: SidechatMessage[]
63
+ draft: string
64
+ /** Staged selection shown as an attachment while "send immediately" is off. */
65
+ attachment: string | null
66
+ /** Browser-owned draft images (object URLs); serialized on send. */
67
+ attachments: ComposerAttachment[]
68
+ lookup: boolean
69
+ directory: SidechatDirectory | null
70
+ permissions: SidechatPermissions | null
71
+ provider: string
72
+ model: string
73
+ effort: string
74
+ preset: string
75
+ /** Which selector the command menu asked to open (consumed once). */
76
+ commandOpen: 'model' | 'permission' | null
77
+ planActive: boolean
78
+ planPending: boolean
79
+ goalObjective: string | null
80
+ error: string | null
81
+ }
82
+
83
+ /** The whole browser-side snapshot. */
84
+ interface SidechatSnapshot {
85
+ current: string | undefined
86
+ panel: PanelState
87
+ anchor: SelectionAnchor | null
88
+ prefs: SubchatPrefs
89
+ /** The current main conversation's pending user-question dialog (null = none). */
90
+ mainQuestion: SideQuestionItem[] | null
91
+ /** Question ids the user deleted from the panel list. */
92
+ dismissedQuestionIds: string[]
93
+ }
94
+
95
+ /** The whole browser-side store (one per activation). */
96
+ interface SidechatStore {
97
+ getSnapshot(): SidechatSnapshot
98
+ subscribe(fn: () => void): () => void
99
+ setCurrent(current: string | undefined): void
100
+ setAnchor(anchor: SelectionAnchor | null): void
101
+ setPrefs(prefs: SubchatPrefs): void
102
+ setMainQuestion(questions: SideQuestionItem[] | null): void
103
+ dismissQuestion(id: string): void
104
+ dismissAllQuestions(ids: string[]): void
105
+ openPanel(parentSessionId: string): void
106
+ closePanel(): void
107
+ /** Install (or clear) the dock-mode launcher; openPanel() fires it after opening. */
108
+ setDockLaunch(fn: (() => void) | null): void
109
+ setActive(childId: string): void
110
+ patch(partial: Partial<PanelState>): void
111
+ }
112
+
113
+ function emptyPanel(): PanelState {
114
+ return {
115
+ open: false,
116
+ parentSessionId: '',
117
+ activeChildId: null,
118
+ items: [],
119
+ messages: [],
120
+ draft: '',
121
+ attachment: null,
122
+ attachments: [],
123
+ lookup: false,
124
+ directory: null,
125
+ permissions: null,
126
+ provider: '',
127
+ model: '',
128
+ effort: '',
129
+ preset: '',
130
+ commandOpen: null,
131
+ planActive: false,
132
+ planPending: false,
133
+ goalObjective: null,
134
+ error: null,
135
+ }
136
+ }
137
+
138
+ /** Create the browser store (one instance per activation, per the factory rule). */
139
+ function createStore(): SidechatStore {
140
+ let current: string | undefined
141
+ let panel: PanelState = emptyPanel()
142
+ let anchor: SelectionAnchor | null = null
143
+ let prefs: SubchatPrefs = { ...SUBCHAT_PREFS_DEFAULTS }
144
+ let mainQuestion: SideQuestionItem[] | null = null
145
+ let dismissedQuestionIds: string[] = []
146
+ // Per-conversation panel state so switching away and back restores the side
147
+ // chats instead of resetting them. The side chats stay live on the host, so
148
+ // the client must remember each conversation's open panel + active child.
149
+ const bySession = new Map<string, PanelState>()
150
+ const listeners = new Set<() => void>()
151
+ // Dock mode: while the panel lives in the built-in right sidebar, opening the
152
+ // panel also reveals the tab (registered by the dock effect; null otherwise).
153
+ let dockLaunch: (() => void) | null = null
154
+ // Cached snapshot: useSyncExternalStore compares identity, so the object is
155
+ // only rebuilt on a mutation — never inside getSnapshot itself.
156
+ let snapshot: SidechatSnapshot = { current, panel, anchor, prefs, mainQuestion, dismissedQuestionIds }
157
+
158
+ const notify = (): void => {
159
+ snapshot = { current, panel, anchor, prefs, mainQuestion, dismissedQuestionIds }
160
+ for (const fn of [...listeners]) fn()
161
+ }
162
+
163
+ return {
164
+ getSnapshot: () => snapshot,
165
+ subscribe: (fn) => {
166
+ listeners.add(fn)
167
+ return () => { listeners.delete(fn) }
168
+ },
169
+ setCurrent(next) {
170
+ if (next === current) return
171
+ if (current !== undefined) bySession.set(current, panel)
172
+ current = next
173
+ panel = next === undefined
174
+ ? emptyPanel()
175
+ : (bySession.get(next) ?? { ...emptyPanel(), parentSessionId: next, lookup: prefs.lookupDefault })
176
+ anchor = null
177
+ mainQuestion = null
178
+ dismissedQuestionIds = []
179
+ notify()
180
+ },
181
+ setAnchor(next) {
182
+ anchor = next
183
+ notify()
184
+ },
185
+ setPrefs(next) {
186
+ prefs = next
187
+ notify()
188
+ },
189
+ setMainQuestion(questions) {
190
+ mainQuestion = questions
191
+ // Keep dismissal state: a dismissed question must not reappear just
192
+ // because the pending snapshot re-publishes while the dialog is still open.
193
+ notify()
194
+ },
195
+ dismissQuestion(id) {
196
+ if (!dismissedQuestionIds.includes(id)) {
197
+ dismissedQuestionIds = [...dismissedQuestionIds, id]
198
+ notify()
199
+ }
200
+ },
201
+ dismissAllQuestions(ids) {
202
+ dismissedQuestionIds = [...new Set([...dismissedQuestionIds, ...ids])]
203
+ notify()
204
+ },
205
+ openPanel(parentSessionId) {
206
+ panel = { ...panel, open: true, parentSessionId }
207
+ dockLaunch?.()
208
+ notify()
209
+ },
210
+ closePanel() {
211
+ panel = { ...panel, open: false }
212
+ notify()
213
+ },
214
+ setDockLaunch(fn) {
215
+ dockLaunch = fn
216
+ },
217
+ setActive(childId) {
218
+ panel = { ...panel, activeChildId: childId, messages: [], error: null }
219
+ notify()
220
+ },
221
+ patch(partial) {
222
+ panel = { ...panel, ...partial }
223
+ notify()
224
+ },
225
+ }
226
+ }
227
+
228
+ /** Resolve the localized label for one locale key (module-level active locale). */
229
+ function translate(activeLocale: string, key: SidechatLocaleKey): string {
230
+ const dict = activeLocale === 'en' ? en : zh
231
+ return dict[key] ?? key
232
+ }
233
+
234
+ /** Format a run duration like the main conversation: "Xs" / "Xm SSs" (or Chinese). */
235
+ function formatRunDuration(ms: number, activeLocale: string): string {
236
+ const total = Math.max(0, Math.floor(ms / 1000))
237
+ const minutes = Math.floor(total / 60)
238
+ const seconds = total % 60
239
+ if (minutes > 0) {
240
+ return activeLocale === 'en'
241
+ ? `${minutes}m ${String(seconds).padStart(2, '0')}s`
242
+ : `${minutes}分${String(seconds).padStart(2, '0')}秒`
243
+ }
244
+ return activeLocale === 'en' ? `${seconds}s` : `${seconds}秒`
245
+ }
246
+
247
+ /** Browser-owned draft image (object URL preview). */
248
+ interface ComposerAttachment {
249
+ id: string
250
+ file: File
251
+ previewUrl: string
252
+ }
253
+
254
+ /** Accepted image media types (mirror of dsh-attachment's ImageMediaType). */
255
+ const IMAGE_MEDIA_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif']
256
+
257
+ /** Create runtime draft images with object URLs (validates media type). */
258
+ function createDraftImages(files: readonly File[]): ComposerAttachment[] {
259
+ return files.map((file) => {
260
+ if (!IMAGE_MEDIA_TYPES.includes(file.type)) {
261
+ throw new Error(`unsupported image type: ${file.type || 'unknown'}`)
262
+ }
263
+ return { id: crypto.randomUUID(), file, previewUrl: URL.createObjectURL(file) }
264
+ })
265
+ }
266
+
267
+ /**
268
+ * Read one image file's intrinsic pixel size, releasing the decoded bitmap.
269
+ * Returns null when the browser cannot decode it: the host's `validateImage`
270
+ * stays the authority either way.
271
+ */
272
+ async function imageSize(file: File): Promise<{ width: number; height: number } | null> {
273
+ try {
274
+ const bitmap = await createImageBitmap(file)
275
+ const size = { width: bitmap.width, height: bitmap.height }
276
+ bitmap.close()
277
+ return size
278
+ } catch {
279
+ return null
280
+ }
281
+ }
282
+
283
+ /** Revoke one draft image's preview URL. */
284
+ function releaseDraftImage(attachment: ComposerAttachment): void {
285
+ URL.revokeObjectURL(attachment.previewUrl)
286
+ }
287
+
288
+ /** Serialize draft images to base64 prompt parts (mirror of main sendSession). */
289
+ async function serializeImages(attachments: readonly ComposerAttachment[]): Promise<PromptContentPart[]> {
290
+ return Promise.all(attachments.map(async (a) => {
291
+ const bytes = new Uint8Array(await a.file.arrayBuffer())
292
+ let binary = ''
293
+ const chunk = 32768
294
+ for (let offset = 0; offset < bytes.length; offset += chunk) {
295
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + chunk))
296
+ }
297
+ return {
298
+ type: 'image',
299
+ mediaType: a.file.type,
300
+ data: btoa(binary),
301
+ ...(a.file.name === '' ? {} : { name: a.file.name }),
302
+ }
303
+ }))
304
+ }
305
+
306
+ /** Convert a base64 string to an object URL for transcript image rendering. */
307
+ function base64ObjectUrl(mediaType: string, data: string): string {
308
+ const binary = atob(data)
309
+ const bytes = new Uint8Array(binary.length)
310
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
311
+ return URL.createObjectURL(new Blob([bytes], { type: mediaType }))
312
+ }
313
+
314
+ /** Pull the side-chat list for one parent conversation. */
315
+ async function refreshList(store: SidechatStore, parentSessionId: string): Promise<void> {
316
+ const result = await api.list({ parentSessionId })
317
+ if (result.ok) store.patch({ items: result.value.items })
318
+ }
319
+
320
+ /** Optimistically flip one side-chat's running flag before the list round-trip. */
321
+ function setItemRunning(store: SidechatStore, childId: string, running: boolean): void {
322
+ const items = store.getSnapshot().panel.items
323
+ store.patch({ items: items.map((i) => (i.childId === childId ? { ...i, running } : i)) })
324
+ }
325
+
326
+ /** Pull the active side chat's transcript. */
327
+ async function refreshHistory(store: SidechatStore, childId: string): Promise<void> {
328
+ const result = await api.history({ childId })
329
+ if (result.ok) store.patch({ messages: result.value.messages })
330
+ }
331
+
332
+ /** Pull the model directory + permission catalog once. */
333
+ async function refreshDirectory(store: SidechatStore): Promise<void> {
334
+ const [directoryResult, permissionsResult] = await Promise.all([
335
+ api.directory(),
336
+ api.permissions(),
337
+ ])
338
+ const patch: Partial<PanelState> = {}
339
+ if (directoryResult.ok) patch.directory = directoryResult.value
340
+ if (permissionsResult.ok) {
341
+ patch.permissions = permissionsResult.value
342
+ // Only seed the preset on first load; never clobber an explicit user pick.
343
+ if (store.getSnapshot().panel.preset === '') {
344
+ patch.preset = permissionsResult.value.current
345
+ }
346
+ }
347
+ store.patch(patch)
348
+ }
349
+
350
+ /** The side-chat's current model selection (provider + model + effort). */
351
+ interface ModelSelection {
352
+ provider: string
353
+ model: string
354
+ effort: string
355
+ }
356
+
357
+ /** One directory model's reasoning slice. */
358
+ type DirectoryReasoning = SidechatDirectory['groups'][number]['models'][number]['reasoning']
359
+
360
+ /** The first non-empty line of a reasoning block (collapsed summary). */
361
+ function firstLine(text: string): string {
362
+ const end = text.indexOf('\n')
363
+ return end === -1 ? text : text.slice(0, end)
364
+ }
365
+
366
+ /** Main-conversation-style reasoning disclosure row (Think). */
367
+ function ReasoningRow(props: { text: string; t: (key: SidechatLocaleKey) => string }) {
368
+ const [expanded, setExpanded] = useState(false)
369
+ const summary = firstLine(props.text)
370
+ return (
371
+ <DisclosureRow
372
+ icon={<IconThinkOutlineRegular size={14} />}
373
+ title={props.t('panel.think')}
374
+ open={expanded}
375
+ expandable={true}
376
+ expandOnRowClick={true}
377
+ onToggle={() => { setExpanded((value) => !value) }}
378
+ collapsedContent={<span className={css.reasoningSummary}>{summary}</span>}
379
+ >
380
+ <div className={css.reasoningBody}>{props.text}</div>
381
+ </DisclosureRow>
382
+ )
383
+ }
384
+
385
+ /**
386
+ * Main-conversation-style model selector: a compact trigger showing
387
+ * `model · effort`, opening a two-level menu (provider groups → models, then
388
+ * effort levels). UI mirrors dsh-client-ui-model-selection's ModelSelect.
389
+ */
390
+ function ModelSelect(props: {
391
+ directory: SidechatDirectory | null
392
+ selection: ModelSelection
393
+ onSelect: (provider: string, model: string, effort?: string) => void
394
+ t: (key: SidechatLocaleKey) => string
395
+ openSignal?: boolean
396
+ onOpenConsumed?: () => void
397
+ }) {
398
+ const { directory, selection, onSelect, t, openSignal, onOpenConsumed } = props
399
+ const [open, setOpen] = useState(false)
400
+ const [pane, setPane] = useState<'root' | 'model' | 'effort'>('root')
401
+ const rootRef = useRef<HTMLDivElement | null>(null)
402
+
403
+ // External open signal (the + command menu asks the selector to open).
404
+ useEffect(() => {
405
+ if (openSignal === true) {
406
+ setPane('root')
407
+ setOpen(true)
408
+ onOpenConsumed?.()
409
+ }
410
+ }, [openSignal, onOpenConsumed])
411
+
412
+ // Current model entry across all provider groups.
413
+ let currentChoice: { name: string; reasoning?: DirectoryReasoning } | undefined
414
+ for (const group of directory?.groups ?? []) {
415
+ const model = group.models.find((m) => m.id === selection.model && group.id === selection.provider)
416
+ if (model !== undefined) {
417
+ currentChoice = { name: model.name, reasoning: model.reasoning }
418
+ break
419
+ }
420
+ }
421
+ const reasoning = currentChoice?.reasoning
422
+ const effectiveEffort = selection.effort !== '' ? selection.effort : reasoning?.defaultEffort
423
+ const effortLabel = reasoning === undefined
424
+ ? undefined
425
+ : effectiveEffort === undefined
426
+ ? t('panel.effortDefault')
427
+ : reasoning.efforts.find((e) => e.id === effectiveEffort)?.name ?? effectiveEffort
428
+ const modelLabel = currentChoice?.name ?? t('panel.noModel')
429
+ const effortChoices = reasoning === undefined
430
+ ? []
431
+ : [
432
+ ...(reasoning.defaultEffort === undefined ? [{ key: 'default', effort: undefined as string | undefined, label: t('panel.effortDefault') }] : []),
433
+ ...reasoning.efforts.map((e) => ({ key: e.id, effort: e.id, label: e.name })),
434
+ ]
435
+
436
+ // Close on outside pointer-down / Escape.
437
+ useEffect(() => {
438
+ if (!open) return
439
+ const onDown = (e: globalThis.MouseEvent): void => {
440
+ if (rootRef.current !== null && !rootRef.current.contains(e.target as Node)) setOpen(false)
441
+ }
442
+ const onKey = (e: KeyboardEvent): void => {
443
+ if (e.key === 'Escape') setOpen(false)
444
+ }
445
+ document.addEventListener('mousedown', onDown)
446
+ document.addEventListener('keydown', onKey)
447
+ return () => {
448
+ document.removeEventListener('mousedown', onDown)
449
+ document.removeEventListener('keydown', onKey)
450
+ }
451
+ }, [open])
452
+
453
+ const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
454
+
455
+ return (
456
+ <div ref={rootRef} className={css.modelSelect}>
457
+ <button
458
+ type="button"
459
+ className={css.modelSelectTrigger}
460
+ aria-haspopup="menu"
461
+ aria-expanded={open}
462
+ title={triggerLabel}
463
+ onClick={() => {
464
+ if (open) setOpen(false)
465
+ else { setPane('root'); setOpen(true) }
466
+ }}
467
+ >
468
+ <span className={css.modelSelectLabel}>{modelLabel}</span>
469
+ {effortLabel !== undefined && <span className={css.modelSelectEffort}>{effortLabel}</span>}
470
+ <IconChevronDownOutlineRegular className={open ? css.chevronOpen : undefined} />
471
+ </button>
472
+
473
+ {open && (
474
+ <div className={css.modelSelectMenu} role="menu">
475
+ {pane === 'root' && (
476
+ <>
477
+ <button type="button" role="menuitem" className={css.modelCell} onClick={() => { setPane('model') }}>
478
+ <span className={css.modelCellLabel}>{t('panel.model')}</span>
479
+ <span className={css.modelCellValue}>{modelLabel}</span>
480
+ <IconChevronRightOutlineRegular className={css.modelCellChevron} />
481
+ </button>
482
+ {reasoning !== undefined && (
483
+ <button type="button" role="menuitem" className={css.modelCell} onClick={() => { setPane('effort') }}>
484
+ <span className={css.modelCellLabel}>{t('panel.effort')}</span>
485
+ <span className={css.modelCellValue}>{effortLabel}</span>
486
+ <IconChevronRightOutlineRegular className={css.modelCellChevron} />
487
+ </button>
488
+ )}
489
+ </>
490
+ )}
491
+
492
+ {pane === 'model' && (
493
+ <div className={css.modelGroups}>
494
+ {(directory?.groups ?? []).map((group) => (
495
+ <section key={group.id} role="group" aria-label={group.name} className={css.modelGroup}>
496
+ <div className={css.modelGroupTitle}>{group.name}</div>
497
+ {group.models.map((model) => {
498
+ const selected = selection.provider === group.id && selection.model === model.id
499
+ return (
500
+ <button
501
+ key={model.id}
502
+ type="button"
503
+ role="menuitemradio"
504
+ aria-checked={selected}
505
+ className={selected ? `${css.modelOption} ${css.modelOptionSelected}` : css.modelOption}
506
+ title={model.name}
507
+ onClick={() => {
508
+ onSelect(group.id, model.id)
509
+ setOpen(false)
510
+ }}
511
+ >
512
+ <span className={css.modelOptionCopy}>
513
+ <span className={css.modelName}>{model.name}</span>
514
+ {model.description !== undefined && <span className={css.modelDescription}>{model.description}</span>}
515
+ </span>
516
+ <span className={css.modelCheck}>{selected ? <IconCheckOutlineRegular /> : null}</span>
517
+ </button>
518
+ )
519
+ })}
520
+ </section>
521
+ ))}
522
+ {(directory?.groups ?? []).length === 0 && <div className={css.modelEmpty}>{t('panel.noModel')}</div>}
523
+ </div>
524
+ )}
525
+
526
+ {pane === 'effort' && (
527
+ <>
528
+ {effortChoices.length === 0
529
+ ? <div className={css.modelEmpty}>{t('panel.effort')}</div>
530
+ : effortChoices.map((level) => {
531
+ const selected = effectiveEffort === level.effort
532
+ return (
533
+ <button
534
+ key={level.key}
535
+ type="button"
536
+ role="menuitemradio"
537
+ aria-checked={selected}
538
+ className={selected ? `${css.modelOption} ${css.modelOptionSelected}` : css.modelOption}
539
+ onClick={() => {
540
+ onSelect(selection.provider, selection.model, level.effort)
541
+ setOpen(false)
542
+ }}
543
+ >
544
+ <span className={css.modelOptionCopy}>
545
+ <span className={css.modelName}>{level.label}</span>
546
+ </span>
547
+ <span className={css.modelCheck}>{selected ? <IconCheckOutlineRegular /> : null}</span>
548
+ </button>
549
+ )
550
+ })}
551
+ </>
552
+ )}
553
+ </div>
554
+ )}
555
+ </div>
556
+ )
557
+ }
558
+
559
+ /** Main-conversation-style permission selector (Menu + compact trigger). */
560
+ function PermissionSelect(props: {
561
+ permissions: SidechatPermissions | null
562
+ preset: string
563
+ onSelect: (preset: string) => void
564
+ t: (key: SidechatLocaleKey) => string
565
+ openSignal?: boolean
566
+ onOpenConsumed?: () => void
567
+ }) {
568
+ const { permissions, preset, onSelect, openSignal, onOpenConsumed } = props
569
+ const [open, setOpen] = useState(false)
570
+ const options = (permissions?.options ?? []).filter((o) => o.value !== 'custom')
571
+ const current = options.find((o) => o.value === preset)
572
+ const items = options.map((o) => ({ id: o.value, label: o.name }))
573
+
574
+ // External open signal (the + command menu asks the selector to open).
575
+ useEffect(() => {
576
+ if (openSignal === true) {
577
+ setOpen(true)
578
+ onOpenConsumed?.()
579
+ }
580
+ }, [openSignal, onOpenConsumed])
581
+
582
+ return (
583
+ <Menu
584
+ open={open}
585
+ side="top"
586
+ align="end"
587
+ items={items}
588
+ selectedId={preset}
589
+ onSelect={(id) => { setOpen(false); onSelect(id) }}
590
+ onClose={() => { setOpen(false) }}
591
+ anchor={(
592
+ <button
593
+ type="button"
594
+ className={css.modelSelectTrigger}
595
+ aria-haspopup="menu"
596
+ aria-expanded={open}
597
+ title={current?.description}
598
+ onClick={() => { setOpen(!open) }}
599
+ >
600
+ <span className={css.modelSelectLabel}>{current?.name ?? preset}</span>
601
+ <IconChevronDownOutlineRegular />
602
+ </button>
603
+ )}
604
+ />
605
+ )
606
+ }
607
+
608
+ /**
609
+ * The floating selection menu: listens to the document selection and shows
610
+ * one or two buttons (start / continue), dispatching to the host API.
611
+ */
612
+ function SelectionMenu(props: { store: SidechatStore; t: (key: SidechatLocaleKey) => string }) {
613
+ const { anchor, current, panel, prefs } = useSyncExternalStore(props.store.subscribe, props.store.getSnapshot)
614
+ const [local, setLocal] = useState<SelectionAnchor | null>(null)
615
+
616
+ useEffect(() => {
617
+ const compute = (): void => {
618
+ const selection = window.getSelection()
619
+ if (selection === null || selection.isCollapsed) {
620
+ setLocal(null)
621
+ return
622
+ }
623
+ const text = selection.toString().trim()
624
+ if (text === '') {
625
+ setLocal(null)
626
+ return
627
+ }
628
+ const range = selection.getRangeAt(0)
629
+ const node = range.startContainer
630
+ const element = node.nodeType === 1 ? (node as Element) : node.parentElement
631
+ if (element !== null && element.closest('input, textarea, [contenteditable="true"]') !== null) {
632
+ setLocal(null)
633
+ return
634
+ }
635
+ // Never offer "ask in side chat" for selections inside the side-chat panel
636
+ // (those belong to the bring-back-to-main menu instead).
637
+ if (element !== null && element.closest('[data-dsh-side-chat]') !== null) {
638
+ setLocal(null)
639
+ return
640
+ }
641
+ const rect = range.getBoundingClientRect()
642
+ if (rect.width === 0 && rect.height === 0) {
643
+ setLocal(null)
644
+ return
645
+ }
646
+ setLocal({ text, x: rect.left + rect.width / 2, y: rect.top })
647
+ }
648
+ const onMouseUp = (): void => { window.setTimeout(compute, 0) }
649
+ document.addEventListener('mouseup', onMouseUp)
650
+ document.addEventListener('selectionchange', compute)
651
+ return () => {
652
+ document.removeEventListener('mouseup', onMouseUp)
653
+ document.removeEventListener('selectionchange', compute)
654
+ }
655
+ }, [])
656
+
657
+ const start = useCallback(() => {
658
+ if (local === null || current === undefined) return
659
+ const parentSessionId = current
660
+ const text = local.text
661
+ const snap = props.store.getSnapshot().panel
662
+ if (prefs.sendImmediately) {
663
+ const content: PromptContentPart[] = [
664
+ { type: 'text', text },
665
+ ...(prefs.defaultPrompt.trim() !== '' ? [{ type: 'text' as const, text: prefs.defaultPrompt.trim() }] : []),
666
+ ]
667
+ void api.start({
668
+ parentSessionId,
669
+ content,
670
+ lookupEnabled: prefs.lookupDefault,
671
+ ...(snap.provider !== '' ? { provider: snap.provider } : {}),
672
+ ...(snap.model !== '' ? { model: snap.model } : {}),
673
+ ...(snap.effort !== '' ? { reasoningEffort: snap.effort } : {}),
674
+ }).then((result) => {
675
+ if (result.ok) {
676
+ props.store.openPanel(parentSessionId)
677
+ props.store.setActive(result.value.childId)
678
+ props.store.patch({
679
+ provider: result.value.provider,
680
+ model: result.value.model,
681
+ effort: result.value.reasoningEffort ?? '',
682
+ })
683
+ void refreshList(props.store, parentSessionId)
684
+ void refreshDirectory(props.store)
685
+ }
686
+ })
687
+ } else {
688
+ // Stage the selection as an attachment; a new side chat is created on
689
+ // send. Detach from any previously active child, but show the parent's
690
+ // inherited model until the user picks one.
691
+ props.store.openPanel(parentSessionId)
692
+ props.store.patch({ attachment: text, activeChildId: null, messages: [], draft: '', provider: '', model: '', effort: '' })
693
+ void api.inherit({ parentSessionId }).then((result) => {
694
+ if (result.ok) {
695
+ props.store.patch({
696
+ provider: result.value.provider,
697
+ model: result.value.model,
698
+ effort: result.value.reasoningEffort ?? '',
699
+ })
700
+ }
701
+ })
702
+ void refreshDirectory(props.store)
703
+ }
704
+ setLocal(null)
705
+ }, [local, current, prefs, props.store])
706
+
707
+ const continueChat = useCallback(() => {
708
+ if (local === null || current === undefined) return
709
+ const parentSessionId = current
710
+ const text = local.text
711
+ const active = props.store.getSnapshot().panel.activeChildId
712
+ if (active === null) return
713
+ props.store.openPanel(parentSessionId)
714
+ if (prefs.sendImmediately) {
715
+ const content: PromptContentPart[] = [
716
+ { type: 'text', text },
717
+ ...(prefs.defaultPrompt.trim() !== '' ? [{ type: 'text' as const, text: prefs.defaultPrompt.trim() }] : []),
718
+ ]
719
+ setItemRunning(props.store, active, true)
720
+ void api.followup({ childId: active, content, lookupEnabled: prefs.lookupDefault }).then((result) => {
721
+ if (!result.ok) props.store.patch({ error: result.error.message })
722
+ void refreshList(props.store, parentSessionId)
723
+ void refreshHistory(props.store, active)
724
+ })
725
+ } else {
726
+ props.store.patch({ attachment: text })
727
+ }
728
+ setLocal(null)
729
+ }, [local, current, prefs, props.store])
730
+
731
+ if (local === null || current === undefined) return null
732
+ const hasActive = panel.activeChildId !== null
733
+ return (
734
+ <div className={css.selectionMenu} style={{ left: local.x, top: local.y - 46 }}>
735
+ <button type="button" className={css.selectionButton} onClick={start}>{props.t('ask.new')}</button>
736
+ {hasActive && <button type="button" className={css.selectionButton} onClick={continueChat}>{props.t('ask.continue')}</button>}
737
+ </div>
738
+ )
739
+ }
740
+
741
+ /**
742
+ * The floating bring-back-to-main menu: listens to the document selection and,
743
+ * when the selection is inside an assistant reply in the side-chat panel, shows
744
+ * two actions — "insert directly" and "summarize then insert" — both appending
745
+ * into the main composer without sending.
746
+ */
747
+ function BringBackMenu(props: {
748
+ store: SidechatStore
749
+ t: (key: SidechatLocaleKey) => string
750
+ bringToMain: (text: string) => Promise<boolean>
751
+ summarizeBring: (text: string) => Promise<boolean>
752
+ }) {
753
+ const [local, setLocal] = useState<SelectionAnchor | null>(null)
754
+ const [summarizing, setSummarizing] = useState(false)
755
+
756
+ useEffect(() => {
757
+ const compute = (): void => {
758
+ const selection = window.getSelection()
759
+ if (selection === null || selection.isCollapsed) {
760
+ setLocal(null)
761
+ return
762
+ }
763
+ const text = selection.toString().trim()
764
+ if (text === '') {
765
+ setLocal(null)
766
+ return
767
+ }
768
+ const range = selection.getRangeAt(0)
769
+ const node = range.startContainer
770
+ const element = node.nodeType === 1 ? (node as Element) : node.parentElement
771
+ if (element !== null && element.closest('input, textarea, [contenteditable="true"]') !== null) {
772
+ setLocal(null)
773
+ return
774
+ }
775
+ if (element === null || element.closest('[data-sidechat-role="assistant"]') === null) {
776
+ setLocal(null)
777
+ return
778
+ }
779
+ const rect = range.getBoundingClientRect()
780
+ if (rect.width === 0 && rect.height === 0) {
781
+ setLocal(null)
782
+ return
783
+ }
784
+ setLocal({ text, x: rect.left + rect.width / 2, y: rect.top })
785
+ }
786
+ const onMouseUp = (): void => { window.setTimeout(compute, 0) }
787
+ document.addEventListener('mouseup', onMouseUp)
788
+ document.addEventListener('selectionchange', compute)
789
+ return () => {
790
+ document.removeEventListener('mouseup', onMouseUp)
791
+ document.removeEventListener('selectionchange', compute)
792
+ }
793
+ }, [])
794
+
795
+ if (local === null) return null
796
+
797
+ const summarize = async (): Promise<void> => {
798
+ setSummarizing(true)
799
+ const ok = await props.summarizeBring(local.text)
800
+ setSummarizing(false)
801
+ if (!ok) props.store.patch({ error: props.t('insert.summarizeFailed') })
802
+ else setLocal(null)
803
+ }
804
+
805
+ return (
806
+ <div className={css.selectionMenu} style={{ left: local.x, top: local.y - 46 }}>
807
+ <button
808
+ type="button"
809
+ className={css.selectionButton}
810
+ onClick={() => {
811
+ void props.bringToMain(local.text).then((ok) => {
812
+ if (!ok) props.store.patch({ error: props.t('insert.failed') })
813
+ else setLocal(null)
814
+ })
815
+ }}
816
+ >
817
+ {props.t('insert.direct')}
818
+ </button>
819
+ <button type="button" className={css.selectionButton} disabled={summarizing} onClick={() => { void summarize() }}>
820
+ {summarizing ? props.t('insert.summarizing') : props.t('insert.summarize')}
821
+ </button>
822
+ </div>
823
+ )
824
+ }
825
+
826
+ /**
827
+ * Floating entry shown while the panel is closed and the main conversation has
828
+ * a pending question dialog. It anchors beside the dialog's header (without
829
+ * covering its text) and disappears once clicked (the panel opens instead).
830
+ */
831
+ function QuestionFab(props: {
832
+ store: SidechatStore
833
+ t: (key: SidechatLocaleKey) => string
834
+ onOpen: () => void
835
+ }) {
836
+ const [pos, setPos] = useState<{ left: number; top: number } | null>(null)
837
+
838
+ useEffect(() => {
839
+ let raf = 0
840
+ let missing = 0
841
+ const tick = (): void => {
842
+ const el = document.querySelector<HTMLElement>('[data-question-key], [data-approval-key]')
843
+ if (el === null) {
844
+ missing += 1
845
+ // A brief grace period covers the initial render; if the dialog stays
846
+ // absent, clear the tracked question so this entry disappears too.
847
+ if (missing > 30) {
848
+ props.store.setMainQuestion(null)
849
+ return
850
+ }
851
+ setPos(null)
852
+ raf = requestAnimationFrame(tick)
853
+ return
854
+ }
855
+ missing = 0
856
+ // The dialog's header (its title/eyebrow block) is the anchor. Newer DSH
857
+ // wraps it as `section > header` inside the data-question frame, so locate
858
+ // the `header` tag generically (older builds had it as the first child).
859
+ const header = el.querySelector<HTMLElement>('header') ?? (el.firstElementChild as HTMLElement | null) ?? el
860
+ const rect = header.getBoundingClientRect()
861
+ const size = 32
862
+ const left = Math.min(rect.right + 8, window.innerWidth - size - 8)
863
+ const top = rect.top + rect.height / 2
864
+ setPos({ left, top })
865
+ raf = requestAnimationFrame(tick)
866
+ }
867
+ tick()
868
+ return () => { cancelAnimationFrame(raf) }
869
+ }, [props.store])
870
+
871
+ const style: CSSProperties = pos !== null
872
+ ? { left: pos.left, top: pos.top, transform: 'translateY(-50%)' }
873
+ : { left: '50%', bottom: 160, transform: 'translateX(-50%)' }
874
+
875
+ return (
876
+ <Tooltip label={props.t('question.openHint')} side="top">
877
+ <button
878
+ type="button"
879
+ className={css.questionFab}
880
+ style={style}
881
+ aria-label={props.t('question.openHint')}
882
+ onClick={props.onOpen}
883
+ >
884
+ <IconPanelLeftOutlineRegular size={16} />
885
+ <span className={css.questionFabDot} />
886
+ </button>
887
+ </Tooltip>
888
+ )
889
+ }
890
+
891
+ /** Panel width bounds. The panel never takes more than ~40% of the window and
892
+ * never squeezes the main chat below a usable minimum — so the panel adapts to
893
+ * whatever resolution / zoom the browser window is at. */
894
+ const PANEL_MIN_WIDTH = 280
895
+ const PANEL_MAX_WIDTH = 720
896
+ const PANEL_DEFAULT_WIDTH = 360
897
+ const MAIN_CHAT_MIN_WIDTH = 480
898
+ /** localStorage key remembering the last panel width across reloads. */
899
+ const PANEL_WIDTH_KEY = 'dsh-side-chat.panelWidth'
900
+
901
+ /** Right-sidebar dock identity (kind is the `openTab` discriminator; id keys the body seat). */
902
+ const SIDEBAR_TAB_ID = 'dsh-side-chat-plus/side-chat'
903
+ const SIDEBAR_TAB_KIND = 'side-chat'
904
+
905
+ /** The viewport-aware maximum panel width for the current window. */
906
+ function panelCap(): number {
907
+ const vw = window.innerWidth
908
+ return Math.max(PANEL_MIN_WIDTH, Math.min(PANEL_MAX_WIDTH, vw * 0.4, vw - MAIN_CHAT_MIN_WIDTH))
909
+ }
910
+
911
+ /** The last user-chosen width, if any (re-clamped to the viewport on load). */
912
+ function savedPanelWidth(): number | null {
913
+ try {
914
+ const raw = window.localStorage.getItem(PANEL_WIDTH_KEY)
915
+ if (raw === null) return null
916
+ const n = Number(raw)
917
+ return Number.isFinite(n) && n > 0 ? n : null
918
+ } catch {
919
+ return null
920
+ }
921
+ }
922
+
923
+ /** The side-chat panel body. */
924
+ function SidechatPanel(props: {
925
+ store: SidechatStore
926
+ t: (key: SidechatLocaleKey) => string
927
+ formatDuration: (ms: number) => string
928
+ bringToMain: (text: string) => Promise<boolean>
929
+ summarizeBring: (text: string) => Promise<boolean>
930
+ askSidechat: (text: string) => Promise<boolean>
931
+ askSidechatNew: (text: string) => Promise<boolean>
932
+ /** Render inside the built-in right sidebar's tab pane (no floating chrome). */
933
+ embedded?: boolean
934
+ /** A built-in sidebar overlay (fullscreen preview / floating panel) owns the viewport: hide this panel. */
935
+ conflictHidden?: boolean
936
+ }) {
937
+ const { panel, mainQuestion, dismissedQuestionIds } = useSyncExternalStore(props.store.subscribe, props.store.getSnapshot)
938
+ const scrollRef = useRef<HTMLDivElement | null>(null)
939
+ const markdownLabels = useMemo(() => ({
940
+ code: { copyLabel: props.t('panel.copy'), copiedLabel: props.t('panel.copied') },
941
+ footnotes: props.t('panel.footnotes'),
942
+ }), [props.t])
943
+ const attachmentRailLabels = useMemo(() => ({
944
+ group: props.t('image.railGroup'),
945
+ open: props.t('image.railOpen'),
946
+ scrollLeft: props.t('image.railScrollLeft'),
947
+ scrollRight: props.t('image.railScrollRight'),
948
+ }), [props.t])
949
+ const messageImageLabels = useMemo(() => ({
950
+ image: props.t('image.label'),
951
+ open: props.t('image.open'),
952
+ openNamed: (name: string): string => name,
953
+ loading: props.t('image.loading'),
954
+ loadFailed: props.t('image.loadFailed'),
955
+ lightbox: { dialog: props.t('image.lightboxDialog'), close: props.t('image.close') },
956
+ }), [props.t])
957
+ const dropOverlayLabels = useMemo(() => ({
958
+ title: props.t('image.dropTitle'),
959
+ desc: props.t('image.dropDesc'),
960
+ }), [props.t])
961
+ // Width starts at the user's last choice when it fits the current window,
962
+ // otherwise adapts to the viewport (small screens get a smaller default).
963
+ const [width, setWidth] = useState(() => {
964
+ const base = savedPanelWidth() ?? PANEL_DEFAULT_WIDTH
965
+ return Math.max(PANEL_MIN_WIDTH, Math.min(panelCap(), base))
966
+ })
967
+ const [collapsed, setCollapsed] = useState(false)
968
+ const [now, setNow] = useState(() => Date.now())
969
+ const [dragActive, setDragActive] = useState(false)
970
+ const [lightbox, setLightbox] = useState<ComposerAttachment | null>(null)
971
+ const [limits, setLimits] = useState<{ mediaTypes: string[]; maxImageBytes: number; maxImagesPerMessage: number; maxMessageImageBytes: number; maxImagePixels: number; maxImageDimension?: number } | null>(null)
972
+ /** Index of the assistant message whose "summarize then insert" is in flight. */
973
+ const [summarizingIndex, setSummarizingIndex] = useState<number | null>(null)
974
+ /** Which question-dialog item is being brought into the side chat ('all' or an option label). */
975
+ const [bringingKey, setBringingKey] = useState<string | null>(null)
976
+ /** Whether the question-dialog list is collapsed (headers only). */
977
+ const [questionCollapsed, setQuestionCollapsed] = useState(false)
978
+
979
+ // Auto-expand the panel whenever a side chat is started or activated, so
980
+ // starting from a collapsed panel still reveals the conversation.
981
+ useEffect(() => {
982
+ if (panel.open && panel.activeChildId !== null) setCollapsed(false)
983
+ }, [panel.open, panel.activeChildId])
984
+
985
+ // Open (and expand) the panel to show the pending question dialog.
986
+ const openQuestionPanel = useCallback(() => {
987
+ props.store.openPanel(panel.parentSessionId)
988
+ setCollapsed(false)
989
+ }, [props.store, panel.parentSessionId])
990
+
991
+ /** Assemble one question + all its options into a prompt. */
992
+ const buildAllText = (q: SideQuestionItem): string => {
993
+ const lines: string[] = []
994
+ if (q.header !== undefined && q.header !== '') lines.push(`【${q.header}】`)
995
+ lines.push(q.question)
996
+ if (q.detail !== undefined && q.detail !== '') lines.push(q.detail)
997
+ const options = q.options ?? []
998
+ if (options.length > 0) {
999
+ lines.push(props.t('question.options'))
1000
+ for (const o of options) {
1001
+ lines.push(`- ${o.label}${o.description !== undefined && o.description !== '' ? ` — ${o.description}` : ''}`)
1002
+ }
1003
+ }
1004
+ lines.push(props.t('question.allPrompt'))
1005
+ return lines.join('\n')
1006
+ }
1007
+
1008
+ /** Assemble one question + one specific option into a prompt. */
1009
+ const buildOneText = (q: SideQuestionItem, o: SideQuestionOption): string => {
1010
+ const lines: string[] = []
1011
+ if (q.header !== undefined && q.header !== '') lines.push(`【${q.header}】`)
1012
+ lines.push(q.question)
1013
+ if (q.detail !== undefined && q.detail !== '') lines.push(q.detail)
1014
+ lines.push(`${props.t('question.option')}:${o.label}${o.description !== undefined && o.description !== '' ? ` — ${o.description}` : ''}`)
1015
+ lines.push(props.t('question.onePrompt'))
1016
+ return lines.join('\n')
1017
+ }
1018
+
1019
+ const bringQuestionText = (text: string, key: string, useNew: boolean): void => {
1020
+ setBringingKey(key)
1021
+ const fn = useNew ? props.askSidechatNew : props.askSidechat
1022
+ void fn(text).then((ok) => {
1023
+ setBringingKey(null)
1024
+ if (!ok) props.store.patch({ error: props.t('question.failed') })
1025
+ })
1026
+ }
1027
+
1028
+ const activeItem = panel.items.find((i) => i.childId === panel.activeChildId)
1029
+ const activeRunning = activeItem?.running ?? false
1030
+ const [anchor, setAnchor] = useState<number | null>(null)
1031
+
1032
+ useEffect(() => {
1033
+ if (props.embedded) return
1034
+ const w = panel.open && !collapsed && !props.conflictHidden ? `${width}px` : '0px'
1035
+ document.documentElement.style.setProperty('--dsh-subchat-width', w)
1036
+ return () => { document.documentElement.style.setProperty('--dsh-subchat-width', '0px') }
1037
+ }, [props.embedded, panel.open, collapsed, width, props.conflictHidden])
1038
+
1039
+ // Re-adapt the panel width when the window is resized: if the viewport
1040
+ // shrinks (smaller window, different monitor, higher zoom), the panel is
1041
+ // clamped to the new cap and the layout margin follows via the effect above.
1042
+ useEffect(() => {
1043
+ const onResize = (): void => {
1044
+ setWidth((w) => Math.min(w, panelCap()))
1045
+ }
1046
+ window.addEventListener('resize', onResize)
1047
+ return () => { window.removeEventListener('resize', onResize) }
1048
+ }, [])
1049
+
1050
+ // Remember the width across reloads; on the next load it is re-clamped to
1051
+ // whatever window is present then.
1052
+ useEffect(() => {
1053
+ try {
1054
+ window.localStorage.setItem(PANEL_WIDTH_KEY, String(width))
1055
+ } catch {
1056
+ // Storage unavailable (private mode etc.) — the width just won't persist.
1057
+ }
1058
+ }, [width])
1059
+
1060
+ // Lazy-load the model/permission directory whenever the panel is open but the
1061
+ // directory has not hydrated yet (covers page reload + continue + direct open).
1062
+ useEffect(() => {
1063
+ if (panel.open && panel.directory === null) {
1064
+ void refreshDirectory(props.store)
1065
+ }
1066
+ }, [panel.open, panel.directory, props.store])
1067
+
1068
+ useEffect(() => {
1069
+ if (activeRunning) {
1070
+ if (anchor === null) setAnchor(activeItem?.runningSince ?? Date.now())
1071
+ } else if (anchor !== null) {
1072
+ setAnchor(null)
1073
+ }
1074
+ }, [activeRunning, activeItem?.runningSince, anchor])
1075
+
1076
+ useEffect(() => {
1077
+ if (!activeRunning) return
1078
+ const id = window.setInterval(() => { setNow(Date.now()) }, 1000)
1079
+ return () => { window.clearInterval(id) }
1080
+ }, [activeRunning])
1081
+
1082
+ useEffect(() => {
1083
+ const el = scrollRef.current
1084
+ if (el !== null) el.scrollTop = el.scrollHeight
1085
+ }, [panel.messages.length, panel.activeChildId])
1086
+
1087
+ useEffect(() => {
1088
+ if (!panel.open || panel.activeChildId === null) return
1089
+ const tick = (): void => {
1090
+ const snap = props.store.getSnapshot().panel
1091
+ const childId = snap.activeChildId
1092
+ if (childId === null) return
1093
+ void refreshList(props.store, snap.parentSessionId)
1094
+ void refreshHistory(props.store, childId)
1095
+ }
1096
+ tick()
1097
+ const id = window.setInterval(tick, 1200)
1098
+ return () => { window.clearInterval(id) }
1099
+ }, [panel.open, panel.activeChildId, props.store])
1100
+
1101
+ const send = useCallback(() => {
1102
+ const draft = panel.draft.trim()
1103
+ const attachment = panel.attachment === null ? '' : panel.attachment
1104
+ const text = attachment === '' ? draft : (draft === '' ? attachment : `${attachment}\n\n${draft}`)
1105
+ void serializeImages(panel.attachments).then((imageParts) => {
1106
+ const content: PromptContentPart[] = [...imageParts, ...(text === '' ? [] : [{ type: 'text', text }] as PromptContentPart[])]
1107
+ if (content.length === 0) return
1108
+ const toRelease = panel.attachments
1109
+ props.store.patch({ draft: '', attachment: null, attachments: [] })
1110
+ toRelease.forEach(releaseDraftImage)
1111
+
1112
+ if (panel.activeChildId === null) {
1113
+ void api.start({
1114
+ parentSessionId: panel.parentSessionId,
1115
+ content,
1116
+ lookupEnabled: panel.lookup,
1117
+ ...(panel.provider !== '' ? { provider: panel.provider } : {}),
1118
+ ...(panel.model !== '' ? { model: panel.model } : {}),
1119
+ ...(panel.effort !== '' ? { reasoningEffort: panel.effort } : {}),
1120
+ ...(panel.preset !== '' ? { preset: panel.preset } : {}),
1121
+ }).then((result) => {
1122
+ if (result.ok) {
1123
+ props.store.setActive(result.value.childId)
1124
+ props.store.patch({
1125
+ provider: result.value.provider,
1126
+ model: result.value.model,
1127
+ effort: result.value.reasoningEffort ?? '',
1128
+ })
1129
+ void refreshList(props.store, panel.parentSessionId)
1130
+ void refreshDirectory(props.store)
1131
+ } else {
1132
+ props.store.patch({ error: result.error.message })
1133
+ }
1134
+ })
1135
+ return
1136
+ }
1137
+
1138
+ const childId = panel.activeChildId
1139
+ setItemRunning(props.store, childId, true)
1140
+ void api.followup({ childId, content, lookupEnabled: panel.lookup }).then((result) => {
1141
+ if (!result.ok) {
1142
+ setItemRunning(props.store, childId, false)
1143
+ props.store.patch({ error: result.error.message })
1144
+ }
1145
+ void refreshList(props.store, panel.parentSessionId)
1146
+ void refreshHistory(props.store, childId)
1147
+ })
1148
+ }).catch((error: unknown) => {
1149
+ props.store.patch({ error: error instanceof Error ? error.message : String(error) })
1150
+ })
1151
+ }, [panel, props.store])
1152
+
1153
+ const stop = useCallback(() => {
1154
+ if (panel.activeChildId === null) return
1155
+ const childId = panel.activeChildId
1156
+ setItemRunning(props.store, childId, false)
1157
+ void api.stop({ childId }).then(() => {
1158
+ void refreshList(props.store, panel.parentSessionId)
1159
+ void refreshHistory(props.store, childId)
1160
+ })
1161
+ }, [panel, props.store])
1162
+
1163
+ const onModelSelect = useCallback((provider: string, model: string, effort?: string) => {
1164
+ // Always update the panel selection; only a live child can receive the
1165
+ // selection immediately (staged mode applies it at creation instead).
1166
+ props.store.patch({ provider, model, effort: effort ?? '' })
1167
+ if (panel.activeChildId !== null) {
1168
+ void api.selectModel({ childId: panel.activeChildId, provider, model, ...(effort === undefined ? {} : { reasoningEffort: effort }) })
1169
+ }
1170
+ }, [panel.activeChildId, props.store])
1171
+
1172
+ const onPresetChange = useCallback((value: string) => {
1173
+ if (value === 'custom' || value === '') return
1174
+ props.store.patch({ preset: value })
1175
+ if (panel.activeChildId !== null) {
1176
+ void api.selectPermission({ childId: panel.activeChildId, presetName: value }).then((result) => {
1177
+ if (!result.ok) props.store.patch({ error: result.error.message })
1178
+ })
1179
+ }
1180
+ }, [panel.activeChildId, props.store])
1181
+
1182
+ const dispose = useCallback(() => {
1183
+ if (panel.activeChildId === null) return
1184
+ const childId = panel.activeChildId
1185
+ void api.dispose({ childId }).then(() => {
1186
+ void refreshList(props.store, panel.parentSessionId)
1187
+ props.store.patch({ activeChildId: null, messages: [] })
1188
+ })
1189
+ }, [panel, props.store])
1190
+
1191
+ /** Delete one side chat from the list. */
1192
+ const disposeItem = useCallback((childId: string) => {
1193
+ void api.dispose({ childId }).then(() => {
1194
+ void refreshList(props.store, panel.parentSessionId)
1195
+ if (panel.activeChildId === childId) {
1196
+ props.store.patch({ activeChildId: null, messages: [] })
1197
+ }
1198
+ })
1199
+ }, [panel.activeChildId, panel.parentSessionId, props.store])
1200
+
1201
+ /** Delete every side chat of this conversation at once. */
1202
+ const disposeAll = useCallback(() => {
1203
+ const ids = panel.items.map((i) => i.childId)
1204
+ if (ids.length === 0) return
1205
+ void Promise.all(ids.map((id) => api.dispose({ childId: id }))).then(() => {
1206
+ void refreshList(props.store, panel.parentSessionId)
1207
+ props.store.patch({ activeChildId: null, messages: [] })
1208
+ })
1209
+ }, [panel.items, panel.parentSessionId, props.store])
1210
+
1211
+ const startResize = useCallback((e: ReactMouseEvent<HTMLDivElement>) => {
1212
+ e.preventDefault()
1213
+ const startX = e.clientX
1214
+ const startWidth = width
1215
+ const onMove = (ev: globalThis.MouseEvent): void => {
1216
+ setWidth(Math.max(PANEL_MIN_WIDTH, Math.min(panelCap(), startWidth + (startX - ev.clientX))))
1217
+ }
1218
+ const onUp = (): void => {
1219
+ window.removeEventListener('mousemove', onMove)
1220
+ window.removeEventListener('mouseup', onUp)
1221
+ }
1222
+ window.addEventListener('mousemove', onMove)
1223
+ window.addEventListener('mouseup', onUp)
1224
+ }, [width])
1225
+
1226
+ const intakeImages = useCallback((files: File[]) => {
1227
+ if (files.length === 0) return
1228
+ const images = files.filter((f) => f.type.startsWith('image/'))
1229
+ if (images.length === 0) {
1230
+ props.store.patch({ error: props.t('image.unsupported') })
1231
+ return
1232
+ }
1233
+ void (async () => {
1234
+ try {
1235
+ if (limits !== null) {
1236
+ if (images.some((f) => !limits.mediaTypes.includes(f.type))) {
1237
+ props.store.patch({ error: props.t('image.unsupported') })
1238
+ return
1239
+ }
1240
+ if (panel.attachments.length + images.length > limits.maxImagesPerMessage) {
1241
+ props.store.patch({ error: props.t('image.tooMany') })
1242
+ return
1243
+ }
1244
+ if (images.some((f) => f.size > limits.maxImageBytes)) {
1245
+ props.store.patch({ error: props.t('image.fileTooLarge') })
1246
+ return
1247
+ }
1248
+ // DSH 0.1.6 added a per-image intrinsic width/height ceiling; older
1249
+ // lines omit it. Decoding is async, so this check is awaited rather
1250
+ // than folded into the size-type ones above.
1251
+ const maxDimension = limits.maxImageDimension
1252
+ if (typeof maxDimension === 'number' && maxDimension > 0) {
1253
+ for (const file of images) {
1254
+ const size = await imageSize(file)
1255
+ if (size !== null && (size.width > maxDimension || size.height > maxDimension)) {
1256
+ props.store.patch({ error: props.t('image.dimensionTooLarge') })
1257
+ return
1258
+ }
1259
+ }
1260
+ }
1261
+ }
1262
+ const created = createDraftImages(images)
1263
+ props.store.patch({ attachments: [...panel.attachments, ...created], error: null })
1264
+ } catch (error) {
1265
+ props.store.patch({ error: error instanceof Error ? error.message : String(error) })
1266
+ }
1267
+ })()
1268
+ }, [limits, panel.attachments, props.store, props.t])
1269
+
1270
+ // Load the deployment image policy once (fast-path checks mirror the host).
1271
+ useEffect(() => {
1272
+ void api.limits().then((result) => {
1273
+ if (result.ok) setLimits(result.value)
1274
+ })
1275
+ }, [])
1276
+
1277
+ // Full-page file drag: track enter/leave depth and accept image drops.
1278
+ useEffect(() => {
1279
+ if (!panel.open || collapsed) return
1280
+ const dragDepth = { value: 0 }
1281
+ const hasFiles = (event: DragEvent): boolean => event.dataTransfer?.types.includes('Files') ?? false
1282
+ const reset = (): void => { dragDepth.value = 0; setDragActive(false) }
1283
+ const onDragEnter = (event: DragEvent): void => {
1284
+ if (!hasFiles(event)) return
1285
+ event.preventDefault()
1286
+ dragDepth.value += 1
1287
+ setDragActive(true)
1288
+ }
1289
+ const onDragOver = (event: DragEvent): void => {
1290
+ if (!hasFiles(event) || event.dataTransfer === null) return
1291
+ event.preventDefault()
1292
+ event.dataTransfer.dropEffect = 'copy'
1293
+ }
1294
+ const onDragLeave = (event: DragEvent): void => {
1295
+ if (!hasFiles(event)) return
1296
+ dragDepth.value = Math.max(0, dragDepth.value - 1)
1297
+ if (dragDepth.value === 0) setDragActive(false)
1298
+ }
1299
+ const onDrop = (event: DragEvent): void => {
1300
+ if (!hasFiles(event)) return
1301
+ event.preventDefault()
1302
+ reset()
1303
+ intakeImages([...event.dataTransfer?.files ?? []])
1304
+ }
1305
+ document.addEventListener('dragenter', onDragEnter)
1306
+ document.addEventListener('dragover', onDragOver)
1307
+ document.addEventListener('dragleave', onDragLeave)
1308
+ document.addEventListener('drop', onDrop)
1309
+ window.addEventListener('dragend', reset)
1310
+ return () => {
1311
+ document.removeEventListener('dragenter', onDragEnter)
1312
+ document.removeEventListener('dragover', onDragOver)
1313
+ document.removeEventListener('dragleave', onDragLeave)
1314
+ document.removeEventListener('drop', onDrop)
1315
+ window.removeEventListener('dragend', reset)
1316
+ }
1317
+ }, [panel.open, collapsed, intakeImages])
1318
+
1319
+ const onPaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
1320
+ const files = Array.from(e.clipboardData.items).filter((item) => item.kind === 'file').map((item) => item.getAsFile()).filter((f): f is File => f !== null)
1321
+ if (files.length === 0) return
1322
+ e.preventDefault()
1323
+ intakeImages(files)
1324
+ }, [intakeImages])
1325
+
1326
+ const removeImage = useCallback((id: string) => {
1327
+ const target = panel.attachments.find((a) => a.id === id)
1328
+ if (target !== undefined) releaseDraftImage(target)
1329
+ props.store.patch({ attachments: panel.attachments.filter((a) => a.id !== id) })
1330
+ }, [panel.attachments, props.store])
1331
+
1332
+ // Load one durable image's bytes → object URL for history rendering.
1333
+ const imageLoader: ImageLoader = useCallback(async (ref) => {
1334
+ const result = await api.attachment({ childId: panel.activeChildId ?? '', attachmentId: ref.attachmentId })
1335
+ if (!result.ok) throw new Error(result.error.message)
1336
+ return base64ObjectUrl(result.value.mediaType, result.value.data)
1337
+ }, [panel.activeChildId])
1338
+
1339
+ // The pending-question entry floats in the portal (SideChatShell), where it is
1340
+ // mounted in both modes; only the floating-mode collapsed handle still lives
1341
+ // here, next to the collapsed state it belongs to.
1342
+ if (!props.embedded) {
1343
+ if (!panel.open) return null
1344
+
1345
+ if (collapsed) {
1346
+ // Collapsed but a question dialog is pending: keep the floating entry so it
1347
+ // can still be opened from beside the dialog (not just the collapsed handle).
1348
+ if (mainQuestion !== null) {
1349
+ return <QuestionFab store={props.store} t={props.t} onOpen={openQuestionPanel} />
1350
+ }
1351
+ // Collapsed handle: a floating round button on the right edge.
1352
+ return (
1353
+ <Tooltip label={props.t('panel.expand')} side="bottom">
1354
+ <button type="button" className={css.collapsedHandle} onClick={() => { setCollapsed(false) }}>
1355
+ <IconPanelLeftOutlineRegular size={16} />
1356
+ </button>
1357
+ </Tooltip>
1358
+ )
1359
+ }
1360
+ }
1361
+
1362
+ const elapsedMs = anchor === null ? 0 : Math.max(0, now - anchor)
1363
+ const showClock = elapsedMs >= 15000
1364
+
1365
+ const panelClass = props.embedded
1366
+ ? `${css.panel} ${css.panelEmbedded}`
1367
+ : `${css.panel}${props.conflictHidden ? ` ${css.panelConflictHidden}` : ''}`
1368
+
1369
+ return (
1370
+ <div
1371
+ className={panelClass}
1372
+ style={props.embedded ? undefined : { width }}
1373
+ data-sidechat-panel={props.embedded ? 'embedded' : 'floating'}
1374
+ >
1375
+ {!props.embedded && <div className={css.panelResize} onMouseDown={startResize} />}
1376
+ {!props.embedded && (
1377
+ <div className={css.panelHeader}>
1378
+ <span className={css.panelTitle}>{props.t('panel.title')}</span>
1379
+ <div className={css.panelHeaderActions}>
1380
+ <Tooltip label={props.t('panel.collapse')} side="bottom">
1381
+ <button type="button" className={css.panelIconButton} onClick={() => { setCollapsed(true) }}>
1382
+ <IconPanelLeftOutlineRegular size={16} />
1383
+ </button>
1384
+ </Tooltip>
1385
+ </div>
1386
+ </div>
1387
+ )}
1388
+
1389
+ {mainQuestion !== null && (() => {
1390
+ const visible = mainQuestion.filter((q) => !dismissedQuestionIds.includes(q.id))
1391
+ if (visible.length === 0) return null
1392
+ return (
1393
+ <div className={css.questionBlock}>
1394
+ <div className={css.questionBlockActions}>
1395
+ <button
1396
+ type="button"
1397
+ className={css.questionToggle}
1398
+ onClick={() => { setQuestionCollapsed(!questionCollapsed) }}
1399
+ >
1400
+ {questionCollapsed ? props.t('question.expand') : props.t('question.collapse')}
1401
+ </button>
1402
+ <button
1403
+ type="button"
1404
+ className={css.questionDeleteAll}
1405
+ onClick={() => { props.store.dismissAllQuestions(visible.map((q) => q.id)) }}
1406
+ >
1407
+ {props.t('question.deleteAll')}
1408
+ </button>
1409
+ </div>
1410
+ {visible.map((q) => {
1411
+ if (questionCollapsed) {
1412
+ return (
1413
+ <div key={q.id} className={`${css.questionItem} ${css.questionItemCollapsed}`}>
1414
+ <span className={css.questionHeaderText}>{q.header ?? q.question}</span>
1415
+ </div>
1416
+ )
1417
+ }
1418
+ const options = q.options ?? []
1419
+ return (
1420
+ <div key={q.id} className={css.questionItem}>
1421
+ <div className={css.questionHeader}>
1422
+ <span className={css.questionHeaderText}>{q.header ?? q.question}</span>
1423
+ <div className={css.questionHeaderActions}>
1424
+ <button
1425
+ type="button"
1426
+ className={css.questionBringButton}
1427
+ disabled={bringingKey !== null}
1428
+ onClick={() => { bringQuestionText(buildAllText(q), `newall:${q.id}`, true) }}
1429
+ >
1430
+ {bringingKey === `newall:${q.id}` ? props.t('question.bringing') : props.t('question.bringAllNew')}
1431
+ </button>
1432
+ <button
1433
+ type="button"
1434
+ className={css.questionBringButton}
1435
+ disabled={bringingKey !== null}
1436
+ onClick={() => { bringQuestionText(buildAllText(q), `all:${q.id}`, false) }}
1437
+ >
1438
+ {bringingKey === `all:${q.id}` ? props.t('question.bringing') : props.t('question.bringAll')}
1439
+ </button>
1440
+ <button
1441
+ type="button"
1442
+ className={css.questionDelete}
1443
+ aria-label={props.t('question.delete')}
1444
+ title={props.t('question.delete')}
1445
+ onClick={() => { props.store.dismissQuestion(q.id) }}
1446
+ >
1447
+ ×
1448
+ </button>
1449
+ </div>
1450
+ </div>
1451
+ <div className={css.questionBody}>{q.question}</div>
1452
+ {q.detail !== undefined && q.detail !== '' && <div className={css.questionDetail}>{q.detail}</div>}
1453
+ {options.map((o) => {
1454
+ const key = `${q.id}:${o.label}`
1455
+ return (
1456
+ <div key={o.label} className={css.questionOption}>
1457
+ <span className={css.questionOptionText}>
1458
+ <span className={css.questionOptionLabel}>{o.label}</span>
1459
+ {o.description !== undefined && o.description !== '' && <span className={css.questionOptionDesc}> — {o.description}</span>}
1460
+ </span>
1461
+ <button
1462
+ type="button"
1463
+ className={css.questionBringButton}
1464
+ disabled={bringingKey !== null}
1465
+ onClick={() => { bringQuestionText(buildOneText(q, o), `new:${key}`, true) }}
1466
+ >
1467
+ {bringingKey === `new:${key}` ? props.t('question.bringing') : props.t('question.bringOneNew')}
1468
+ </button>
1469
+ <button
1470
+ type="button"
1471
+ className={css.questionBringButton}
1472
+ disabled={bringingKey !== null}
1473
+ onClick={() => { bringQuestionText(buildOneText(q, o), key, false) }}
1474
+ >
1475
+ {bringingKey === key ? props.t('question.bringing') : props.t('question.bringOne')}
1476
+ </button>
1477
+ </div>
1478
+ )
1479
+ })}
1480
+ </div>
1481
+ )
1482
+ })}
1483
+ </div>
1484
+ )
1485
+ })()}
1486
+
1487
+ <div className={css.panelList}>
1488
+ {panel.items.length === 0
1489
+ ? <div className={css.panelEmpty}>{props.t('panel.empty')}</div>
1490
+ : (
1491
+ <>
1492
+ <div className={css.panelListActions}>
1493
+ <button type="button" className={css.panelListDeleteAll} onClick={disposeAll}>
1494
+ {props.t('panel.deleteAll')}
1495
+ </button>
1496
+ </div>
1497
+ {panel.items.map((item) => (
1498
+ <div key={item.childId} className={css.panelListItemRow}>
1499
+ <button
1500
+ type="button"
1501
+ className={`${css.panelListItem} ${item.childId === panel.activeChildId ? css.panelListItemActive : ''}`}
1502
+ onClick={() => { props.store.setActive(item.childId); void refreshHistory(props.store, item.childId) }}
1503
+ >
1504
+ <span className={css.panelListItemDot} data-running={item.running ? '1' : undefined} />
1505
+ <span className={css.panelListItemLabel}>{item.childId}</span>
1506
+ </button>
1507
+ <button
1508
+ type="button"
1509
+ className={css.panelListItemRemove}
1510
+ aria-label={props.t('panel.delete')}
1511
+ title={props.t('panel.delete')}
1512
+ onClick={() => { disposeItem(item.childId) }}
1513
+ >
1514
+ ×
1515
+ </button>
1516
+ </div>
1517
+ ))}
1518
+ </>
1519
+ )}
1520
+ </div>
1521
+
1522
+ <div className={css.panelTranscript} ref={scrollRef}>
1523
+ {panel.messages.map((message, index) => {
1524
+ const textBlocks = message.blocks.filter((b) => b.type === 'text')
1525
+ const imageBlocks = message.blocks.filter((b) => b.type === 'image')
1526
+ const reasoningBlocks = message.blocks.filter((b) => b.type === 'reasoning')
1527
+ const text = textBlocks.map((b) => (b.type === 'text' ? b.text : '')).join('\n')
1528
+ const images = imageBlocks.map((b) => (b.type === 'image' ? { attachment: b.ref } : null)).filter((x): x is { attachment: SidechatImageRef } => x !== null)
1529
+ if (message.role === 'user') {
1530
+ return (
1531
+ <div key={index} className={css.messageUser}>
1532
+ {text !== '' && <span className={css.messageUserText}>{text}</span>}
1533
+ {images.length > 0 && (
1534
+ <ImageGallery images={images.map((image) => image.attachment)} load={imageLoader} align="end" labels={messageImageLabels} />
1535
+ )}
1536
+ </div>
1537
+ )
1538
+ }
1539
+ return (
1540
+ <div key={index} className={css.messageAssistant} data-sidechat-role="assistant">
1541
+ {reasoningBlocks.map((block, rIndex) => (
1542
+ <ReasoningRow key={rIndex} text={block.type === 'reasoning' ? block.text : ''} t={props.t} />
1543
+ ))}
1544
+ {text !== '' && <MarkdownText text={text} labels={markdownLabels} />}
1545
+ {text !== '' && (
1546
+ <div className={css.messageActions}>
1547
+ <button
1548
+ type="button"
1549
+ className={css.messageInsertButton}
1550
+ onClick={() => {
1551
+ void props.bringToMain(text).then((ok) => {
1552
+ if (!ok) props.store.patch({ error: props.t('insert.failed') })
1553
+ })
1554
+ }}
1555
+ >
1556
+ {props.t('insert.direct')}
1557
+ </button>
1558
+ <button
1559
+ type="button"
1560
+ className={css.messageInsertButton}
1561
+ disabled={summarizingIndex === index}
1562
+ onClick={() => {
1563
+ setSummarizingIndex(index)
1564
+ void props.summarizeBring(text).then((ok) => {
1565
+ setSummarizingIndex(null)
1566
+ if (!ok) props.store.patch({ error: props.t('insert.summarizeFailed') })
1567
+ })
1568
+ }}
1569
+ >
1570
+ {summarizingIndex === index ? props.t('insert.summarizing') : props.t('insert.summarize')}
1571
+ </button>
1572
+ </div>
1573
+ )}
1574
+ </div>
1575
+ )
1576
+ })}
1577
+ {activeRunning && (
1578
+ <div className={css.panelRunning} role="status" aria-live="polite">
1579
+ <span className={css.panelRunningDot} />
1580
+ <span>{props.t('panel.running')}</span>
1581
+ {showClock && <span className={css.panelRunningClock}>{props.formatDuration(elapsedMs)}</span>}
1582
+ </div>
1583
+ )}
1584
+ {panel.error !== null && <div className={css.panelError}>{props.t('panel.error')}: {panel.error}</div>}
1585
+ </div>
1586
+
1587
+ <div className={css.panelComposer}>
1588
+ {panel.attachment !== null && (
1589
+ <div className={css.panelAttachment}>
1590
+ <span className={css.panelAttachmentText}>{panel.attachment}</span>
1591
+ <button type="button" className={css.panelAttachmentRemove} onClick={() => { props.store.patch({ attachment: null }) }}>×</button>
1592
+ </div>
1593
+ )}
1594
+ {panel.attachments.length > 0 && (
1595
+ <div className={css.panelAttachmentRail}>
1596
+ <AttachmentRail
1597
+ items={panel.attachments.map((a) => ({ id: a.id, previewUrl: a.previewUrl, alt: a.file.name || props.t('image.label'), removeLabel: props.t('image.remove') }))}
1598
+ labels={attachmentRailLabels}
1599
+ onOpen={(item) => { const a = panel.attachments.find((x) => x.id === item.id); if (a !== undefined) setLightbox(a) }}
1600
+ onRemove={(item) => { removeImage(item.id) }}
1601
+ />
1602
+ </div>
1603
+ )}
1604
+ <textarea
1605
+ className={css.panelTextarea}
1606
+ placeholder={props.t('panel.input.placeholder')}
1607
+ value={panel.draft}
1608
+ onChange={(e) => { props.store.patch({ draft: e.target.value }) }}
1609
+ onPaste={onPaste}
1610
+ onKeyDown={(e) => {
1611
+ if (e.key === 'Enter' && !e.shiftKey) {
1612
+ e.preventDefault()
1613
+ send()
1614
+ }
1615
+ }}
1616
+ />
1617
+ <div className={css.panelToolbar}>
1618
+ <ModelSelect
1619
+ directory={panel.directory}
1620
+ selection={{ provider: panel.provider, model: panel.model, effort: panel.effort }}
1621
+ onSelect={onModelSelect}
1622
+ t={props.t}
1623
+ />
1624
+ <PermissionSelect
1625
+ permissions={panel.permissions}
1626
+ preset={panel.preset}
1627
+ onSelect={onPresetChange}
1628
+ t={props.t}
1629
+ />
1630
+ <Tooltip label={activeRunning ? props.t('panel.stop') : props.t('panel.send')} side="top" delayMs={500}>
1631
+ <button
1632
+ type="button"
1633
+ className={css.primary}
1634
+ aria-label={activeRunning ? props.t('panel.stop') : props.t('panel.send')}
1635
+ onClick={activeRunning ? stop : send}
1636
+ >
1637
+ {activeRunning ? <IconStopFillRegular size={16} /> : <IconSendOutlineRegular size={16} />}
1638
+ </button>
1639
+ </Tooltip>
1640
+ </div>
1641
+
1642
+ <label className={css.panelLookup}>
1643
+ <input
1644
+ type="checkbox"
1645
+ checked={panel.lookup}
1646
+ onChange={(e) => { props.store.patch({ lookup: e.target.checked }) }}
1647
+ />
1648
+ <span>{props.t('panel.lookup')}</span>
1649
+ </label>
1650
+ </div>
1651
+
1652
+ <div className={css.panelFooter}>
1653
+ <button type="button" className={css.panelDispose} onClick={dispose}>{props.t('panel.dispose')}</button>
1654
+ </div>
1655
+
1656
+ {dragActive && <DropOverlay disabled={false} labels={dropOverlayLabels} />}
1657
+ {lightbox !== null && (
1658
+ <ImageLightbox
1659
+ src={lightbox.previewUrl}
1660
+ alt={lightbox.file.name || props.t('image.label')}
1661
+ labels={{ dialog: props.t('image.lightboxDialog'), close: props.t('image.close') }}
1662
+ onClose={() => { setLightbox(null) }}
1663
+ />
1664
+ )}
1665
+ </div>
1666
+ )
1667
+ }
1668
+
1669
+ /** The "Side chat" settings section (two switches + a prompt textarea). */
1670
+ /** Dock-mode body: the side-chat panel embedded in the built-in right sidebar's tab pane. */
1671
+ function EmbeddedSidechatPanel(props: {
1672
+ store: SidechatStore
1673
+ t: (key: SidechatLocaleKey) => string
1674
+ formatDuration: (ms: number) => string
1675
+ bringToMain: (text: string) => Promise<boolean>
1676
+ summarizeBring: (text: string) => Promise<boolean>
1677
+ askSidechat: (text: string) => Promise<boolean>
1678
+ askSidechatNew: (text: string) => Promise<boolean>
1679
+ }) {
1680
+ // The tab seat only mounts for the current session, so the shared store's
1681
+ // current-conversation state is the right one to draw.
1682
+ return (
1683
+ <SidechatPanel
1684
+ embedded
1685
+ store={props.store}
1686
+ t={props.t}
1687
+ formatDuration={props.formatDuration}
1688
+ bringToMain={props.bringToMain}
1689
+ summarizeBring={props.summarizeBring}
1690
+ askSidechat={props.askSidechat}
1691
+ askSidechatNew={props.askSidechatNew}
1692
+ />
1693
+ )
1694
+ }
1695
+
1696
+ /** The portalled shell: floating menus + the panel, switching on prefs.panelHome. */
1697
+ function SideChatShell(props: {
1698
+ store: SidechatStore
1699
+ t: (key: SidechatLocaleKey) => string
1700
+ formatDuration: (ms: number) => string
1701
+ bringToMain: (text: string) => Promise<boolean>
1702
+ summarizeBring: (text: string) => Promise<boolean>
1703
+ askSidechat: (text: string) => Promise<boolean>
1704
+ askSidechatNew: (text: string) => Promise<boolean>
1705
+ }) {
1706
+ const snap = useSyncExternalStore(props.store.subscribe, props.store.getSnapshot)
1707
+ const [builtinOverlay, setBuiltinOverlay] = useState(false)
1708
+
1709
+ // While the built-in right sidebar actually shows its panel (docked, fullscreen
1710
+ // or a floated pane), the floating side-chat panel would sit on top of it: yield
1711
+ // — slide the side chat away and drop its layout margin until the built-in panel
1712
+ // is closed again. Docked mode shares the sidebar instead, so it never needs
1713
+ // this guard.
1714
+ useEffect(() => {
1715
+ const probe = (): boolean => {
1716
+ if (document.querySelector('[data-sidebar-right-panel][data-sidebar-right-open]') !== null) return true
1717
+ const floatHost = document.querySelector('[data-sidebar-right-float-host]')
1718
+ if (floatHost !== null && floatHost.childElementCount > 0) return true
1719
+ return false
1720
+ }
1721
+ let active = probe()
1722
+ const apply = (): void => {
1723
+ const next = probe()
1724
+ if (next !== active) {
1725
+ active = next
1726
+ setBuiltinOverlay(next)
1727
+ }
1728
+ }
1729
+ apply()
1730
+ const observer = new MutationObserver(apply)
1731
+ observer.observe(document.body, {
1732
+ childList: true,
1733
+ subtree: true,
1734
+ attributes: true,
1735
+ attributeFilter: ['data-sidebar-right-open', 'data-sidebar-right-panel', 'data-sidebar-right-float-host'],
1736
+ })
1737
+ window.addEventListener('resize', apply)
1738
+ return () => {
1739
+ observer.disconnect()
1740
+ window.removeEventListener('resize', apply)
1741
+ }
1742
+ }, [])
1743
+
1744
+ return (
1745
+ <>
1746
+ <SelectionMenu store={props.store} t={props.t} />
1747
+ <BringBackMenu store={props.store} t={props.t} bringToMain={props.bringToMain} summarizeBring={props.summarizeBring} />
1748
+ {/* The pending-question entry floats in the portal, not inside the panel:
1749
+ in dock mode the panel (and its tab body) does not exist until the tab
1750
+ was opened at least once, but the entry must appear regardless. */}
1751
+ {snap.mainQuestion !== null && !snap.panel.open && (
1752
+ <QuestionFab
1753
+ store={props.store}
1754
+ t={props.t}
1755
+ onOpen={() => { props.store.openPanel(snap.panel.parentSessionId) }}
1756
+ />
1757
+ )}
1758
+ {snap.prefs.panelHome === 'floating' && (
1759
+ <SidechatPanel
1760
+ store={props.store}
1761
+ t={props.t}
1762
+ formatDuration={props.formatDuration}
1763
+ bringToMain={props.bringToMain}
1764
+ summarizeBring={props.summarizeBring}
1765
+ askSidechat={props.askSidechat}
1766
+ askSidechatNew={props.askSidechatNew}
1767
+ conflictHidden={builtinOverlay}
1768
+ />
1769
+ )}
1770
+ </>
1771
+ )
1772
+ }
1773
+
1774
+ function SettingsSection(props: { store: SidechatStore; t: (key: SidechatLocaleKey) => string }) {
1775
+ const { store, t } = props
1776
+ const { prefs } = useSyncExternalStore(store.subscribe, store.getSnapshot)
1777
+ const [promptDraft, setPromptDraft] = useState(prefs.defaultPrompt)
1778
+
1779
+ // Keep the local textarea in sync with the persisted value.
1780
+ useEffect(() => { setPromptDraft(prefs.defaultPrompt) }, [prefs.defaultPrompt])
1781
+
1782
+ const toggle = useCallback((patch: Partial<SubchatPrefs>) => {
1783
+ const previous = prefs
1784
+ const next = { ...previous, ...patch }
1785
+ store.setPrefs(next)
1786
+ void api.settingsUpdate(patch).then((result) => {
1787
+ if (!result.ok) store.setPrefs(previous)
1788
+ })
1789
+ }, [prefs, store])
1790
+
1791
+ const commitPrompt = useCallback(() => {
1792
+ const value = promptDraft.trim()
1793
+ if (value !== prefs.defaultPrompt) toggle({ defaultPrompt: value })
1794
+ }, [promptDraft, prefs.defaultPrompt, toggle])
1795
+
1796
+ return (
1797
+ <div className={css.settingsSection}>
1798
+ <label className={css.settingsRow}>
1799
+ <span className={css.settingsRowText}>
1800
+ <span className={css.settingsRowTitle}>{t('settings.lookupTitle')}</span>
1801
+ <span className={css.settingsRowDesc}>{t('settings.lookupDesc')}</span>
1802
+ </span>
1803
+ <input
1804
+ type="checkbox"
1805
+ className={css.settingsToggle}
1806
+ checked={prefs.lookupDefault}
1807
+ aria-label={t('settings.lookupTitle')}
1808
+ onChange={(e) => { toggle({ lookupDefault: e.currentTarget.checked }) }}
1809
+ />
1810
+ </label>
1811
+ <label className={css.settingsRow}>
1812
+ <span className={css.settingsRowText}>
1813
+ <span className={css.settingsRowTitle}>{t('settings.sendImmediatelyTitle')}</span>
1814
+ <span className={css.settingsRowDesc}>{t('settings.sendImmediatelyDesc')}</span>
1815
+ </span>
1816
+ <input
1817
+ type="checkbox"
1818
+ className={css.settingsToggle}
1819
+ checked={prefs.sendImmediately}
1820
+ aria-label={t('settings.sendImmediatelyTitle')}
1821
+ onChange={(e) => { toggle({ sendImmediately: e.currentTarget.checked }) }}
1822
+ />
1823
+ </label>
1824
+ <div className={css.settingsRow}>
1825
+ <span className={css.settingsRowText}>
1826
+ <span className={css.settingsRowTitle}>{t('settings.bringModeTitle')}</span>
1827
+ <span className={css.settingsRowDesc}>{t('settings.bringModeDesc')}</span>
1828
+ </span>
1829
+ </div>
1830
+ <div className={css.settingsBringMode}>
1831
+ <label className={`${css.settingsBringOption} ${prefs.bringMode === 'draft' ? css.settingsBringOptionActive : ''}`}>
1832
+ <input
1833
+ type="radio"
1834
+ name="dsh-side-chat-bring-mode"
1835
+ className={css.settingsToggle}
1836
+ checked={prefs.bringMode === 'draft'}
1837
+ onChange={() => { toggle({ bringMode: 'draft' }) }}
1838
+ />
1839
+ <span className={css.settingsRowText}>
1840
+ <span className={css.settingsRowTitle}>{t('settings.bringModeDraftTitle')}</span>
1841
+ <span className={css.settingsRowDesc}>{t('settings.bringModeDraftDesc')}</span>
1842
+ </span>
1843
+ </label>
1844
+ <label className={`${css.settingsBringOption} ${prefs.bringMode === 'context' ? css.settingsBringOptionActive : ''}`}>
1845
+ <input
1846
+ type="radio"
1847
+ name="dsh-side-chat-bring-mode"
1848
+ className={css.settingsToggle}
1849
+ checked={prefs.bringMode === 'context'}
1850
+ onChange={() => { toggle({ bringMode: 'context' }) }}
1851
+ />
1852
+ <span className={css.settingsRowText}>
1853
+ <span className={css.settingsRowTitle}>{t('settings.bringModeContextTitle')}</span>
1854
+ <span className={css.settingsRowDesc}>{t('settings.bringModeContextDesc')}</span>
1855
+ </span>
1856
+ </label>
1857
+ </div>
1858
+ <div className={css.settingsRow}>
1859
+ <span className={css.settingsRowText}>
1860
+ <span className={css.settingsRowTitle}>{t('settings.panelHomeTitle')}</span>
1861
+ <span className={css.settingsRowDesc}>{t('settings.panelHomeDesc')}</span>
1862
+ </span>
1863
+ </div>
1864
+ <div className={css.settingsBringMode}>
1865
+ <label className={`${css.settingsBringOption} ${prefs.panelHome === 'floating' ? css.settingsBringOptionActive : ''}`}>
1866
+ <input
1867
+ type="radio"
1868
+ name="dsh-side-chat-panel-home"
1869
+ className={css.settingsToggle}
1870
+ checked={prefs.panelHome === 'floating'}
1871
+ onChange={() => { toggle({ panelHome: 'floating' }) }}
1872
+ />
1873
+ <span className={css.settingsRowText}>
1874
+ <span className={css.settingsRowTitle}>{t('settings.panelHomeFloatingTitle')}</span>
1875
+ <span className={css.settingsRowDesc}>{t('settings.panelHomeFloatingDesc')}</span>
1876
+ </span>
1877
+ </label>
1878
+ <label className={`${css.settingsBringOption} ${prefs.panelHome === 'sidebar-right' ? css.settingsBringOptionActive : ''}`}>
1879
+ <input
1880
+ type="radio"
1881
+ name="dsh-side-chat-panel-home"
1882
+ className={css.settingsToggle}
1883
+ checked={prefs.panelHome === 'sidebar-right'}
1884
+ onChange={() => { toggle({ panelHome: 'sidebar-right' }) }}
1885
+ />
1886
+ <span className={css.settingsRowText}>
1887
+ <span className={css.settingsRowTitle}>{t('settings.panelHomeDockTitle')}</span>
1888
+ <span className={css.settingsRowDesc}>{t('settings.panelHomeDockDesc')}</span>
1889
+ </span>
1890
+ </label>
1891
+ </div>
1892
+ <div className={css.settingsRow}>
1893
+ <span className={css.settingsRowText}>
1894
+ <span className={css.settingsRowTitle}>{t('settings.defaultPromptTitle')}</span>
1895
+ <span className={css.settingsRowDesc}>{t('settings.defaultPromptDesc')}</span>
1896
+ </span>
1897
+ </div>
1898
+ <textarea
1899
+ className={css.settingsPromptInput}
1900
+ value={promptDraft}
1901
+ placeholder={t('settings.defaultPromptPlaceholder')}
1902
+ aria-label={t('settings.defaultPromptTitle')}
1903
+ onChange={(e) => { setPromptDraft(e.currentTarget.value) }}
1904
+ onBlur={commitPrompt}
1905
+ />
1906
+ </div>
1907
+ )
1908
+ }
1909
+
1910
+ /** Client plugin body. */
1911
+ export function apply(ctx: Context): void {
1912
+ const store = createStore()
1913
+
1914
+ // Localized copy follows the DSH locale (module-level mirror for callbacks).
1915
+ let activeLocale = ctx.locale.getSnapshot().active
1916
+
1917
+ /**
1918
+ * The selected main-view Session. DSH 0.1.6-alpha.2 reworked Client Sessions
1919
+ * into independent instances and moved the selection off the list snapshot
1920
+ * (`sessions.list.current`, dropped) onto the ui-session scope binding;
1921
+ * older lines keep it on the list. Read the binding first, then fall back.
1922
+ */
1923
+ const currentSessionId = (): string | undefined =>
1924
+ ctx.uiSession.adapter?.current.getSnapshot().key ?? ctx.sessions.list.getSnapshot().current
1925
+
1926
+ /** Subscribe to current-Session changes on whichever face this DSH exposes. */
1927
+ const subscribeCurrentSession = (fn: () => void): (() => void) => {
1928
+ const offList = ctx.sessions.list.subscribe(fn)
1929
+ const offAdapter = ctx.uiSession.adapter?.current.subscribe(fn)
1930
+ if (offAdapter === undefined) return offList
1931
+ return () => { offList(); offAdapter() }
1932
+ }
1933
+
1934
+ /**
1935
+ * The pending interaction for one Session — DSH 0.1.6-alpha.2 moved it from
1936
+ * `uiSession.pendingInteractions` onto `uiSession.sessionStatus`; read
1937
+ * whichever face this build exposes.
1938
+ */
1939
+ const pendingInteractionFor = (sessionId: string): SidePendingInteraction | undefined => {
1940
+ if (ctx.uiSession.sessionStatus !== undefined) {
1941
+ return ctx.uiSession.sessionStatus.getSnapshot().get(sessionId)?.pendingInteraction
1942
+ }
1943
+ return ctx.uiSession.pendingInteractions?.getSnapshot().get(sessionId)
1944
+ }
1945
+
1946
+ /** Subscribe to pending-interaction changes on whichever face this DSH exposes. */
1947
+ const subscribePendingInteractions = (fn: () => void): (() => void) => {
1948
+ if (ctx.uiSession.sessionStatus !== undefined) return ctx.uiSession.sessionStatus.subscribe(fn)
1949
+ if (ctx.uiSession.pendingInteractions !== undefined) return ctx.uiSession.pendingInteractions.subscribe(fn)
1950
+ return () => {}
1951
+ }
1952
+
1953
+ /** Append text to the main composer draft (draft bring mode). */
1954
+ const draftBring = (text: string): boolean => {
1955
+ const trimmed = text.trim()
1956
+ if (trimmed === '') return false
1957
+ const sessionId = currentSessionId()
1958
+ if (sessionId === undefined) return false
1959
+ try {
1960
+ const actx = ctx.sessions.scope(sessionId)
1961
+ if (actx === undefined) return false
1962
+ const input = ctx.conversation.input.for(actx)
1963
+ const draft = input.state.getSnapshot().draft
1964
+ input.setDraft(draft === '' ? trimmed : `${draft}\n\n${trimmed}`)
1965
+ return true
1966
+ } catch {
1967
+ return false
1968
+ }
1969
+ }
1970
+
1971
+ /** Inject text into the main conversation as a collapsed, source-tagged context row. */
1972
+ const injectBring = async (text: string, summary: string): Promise<boolean> => {
1973
+ const trimmed = text.trim()
1974
+ if (trimmed === '') return false
1975
+ const sessionId = currentSessionId()
1976
+ if (sessionId === undefined) return false
1977
+ const result = await api.inject({ parentSessionId: sessionId, text: trimmed, summary })
1978
+ return result.ok
1979
+ }
1980
+
1981
+ /** Land text in the main conversation per the configured bring mode. */
1982
+ const landText = async (text: string, summaryKey: SidechatLocaleKey): Promise<boolean> => {
1983
+ const mode = store.getSnapshot().prefs.bringMode
1984
+ if (mode === 'context') {
1985
+ return injectBring(text, translate(activeLocale, summaryKey))
1986
+ }
1987
+ return draftBring(text)
1988
+ }
1989
+
1990
+ /** Bring a reply back directly (routed through the configured mode). */
1991
+ const bringToMain = (text: string): Promise<boolean> => landText(text, 'insert.contextSummary')
1992
+
1993
+ /** Summarize text with the side chat's inherited model, then bring the summary back. */
1994
+ const summarizeBring = async (text: string): Promise<boolean> => {
1995
+ const trimmed = text.trim()
1996
+ if (trimmed === '') return false
1997
+ const snap = store.getSnapshot().panel
1998
+ if (snap.parentSessionId === '') return false
1999
+ const result = await api.summarize({
2000
+ parentSessionId: snap.parentSessionId,
2001
+ text: trimmed,
2002
+ ...(snap.provider !== '' ? { provider: snap.provider } : {}),
2003
+ ...(snap.model !== '' ? { model: snap.model } : {}),
2004
+ ...(snap.effort !== '' ? { reasoningEffort: snap.effort } : {}),
2005
+ locale: activeLocale,
2006
+ })
2007
+ if (!result.ok) return false
2008
+ return landText(result.value.summary, 'insert.summarizeContextSummary')
2009
+ }
2010
+
2011
+ /** Ask a piece of text in the side chat (start a new one, or continue the active one). */
2012
+ const askSidechat = async (text: string): Promise<boolean> => {
2013
+ const trimmed = text.trim()
2014
+ if (trimmed === '') return false
2015
+ const parentSessionId = currentSessionId()
2016
+ if (parentSessionId === undefined) return false
2017
+ const panel = store.getSnapshot().panel
2018
+ const content: PromptContentPart[] = [{ type: 'text', text: trimmed }]
2019
+
2020
+ // Reuse the active side chat, else the first existing one, else create one —
2021
+ // so bringing dialog questions in doesn't pile up a new side chat per ask.
2022
+ const target = panel.activeChildId ?? panel.items[0]?.childId ?? null
2023
+
2024
+ if (target === null) {
2025
+ const result = await api.start({
2026
+ parentSessionId,
2027
+ content,
2028
+ lookupEnabled: panel.lookup,
2029
+ ...(panel.provider !== '' ? { provider: panel.provider } : {}),
2030
+ ...(panel.model !== '' ? { model: panel.model } : {}),
2031
+ ...(panel.effort !== '' ? { reasoningEffort: panel.effort } : {}),
2032
+ })
2033
+ if (result.ok) {
2034
+ store.openPanel(parentSessionId)
2035
+ store.setActive(result.value.childId)
2036
+ store.patch({ provider: result.value.provider, model: result.value.model, effort: result.value.reasoningEffort ?? '' })
2037
+ void refreshList(store, parentSessionId)
2038
+ void refreshDirectory(store)
2039
+ return true
2040
+ }
2041
+ return false
2042
+ }
2043
+
2044
+ const childId = target
2045
+ if (childId !== panel.activeChildId) store.setActive(childId)
2046
+ setItemRunning(store, childId, true)
2047
+ const result = await api.followup({ childId, content, lookupEnabled: panel.lookup })
2048
+ if (!result.ok) {
2049
+ setItemRunning(store, childId, false)
2050
+ store.patch({ error: result.error.message })
2051
+ }
2052
+ void refreshList(store, parentSessionId)
2053
+ void refreshHistory(store, childId)
2054
+ return result.ok
2055
+ }
2056
+
2057
+ /** Ask a piece of text in a brand-new side chat (never reuses an existing one). */
2058
+ const askSidechatNew = async (text: string): Promise<boolean> => {
2059
+ const trimmed = text.trim()
2060
+ if (trimmed === '') return false
2061
+ const parentSessionId = currentSessionId()
2062
+ if (parentSessionId === undefined) return false
2063
+ const panel = store.getSnapshot().panel
2064
+ const content: PromptContentPart[] = [{ type: 'text', text: trimmed }]
2065
+ const result = await api.start({
2066
+ parentSessionId,
2067
+ content,
2068
+ lookupEnabled: panel.lookup,
2069
+ ...(panel.provider !== '' ? { provider: panel.provider } : {}),
2070
+ ...(panel.model !== '' ? { model: panel.model } : {}),
2071
+ ...(panel.effort !== '' ? { reasoningEffort: panel.effort } : {}),
2072
+ })
2073
+ if (result.ok) {
2074
+ store.openPanel(parentSessionId)
2075
+ store.setActive(result.value.childId)
2076
+ store.patch({ provider: result.value.provider, model: result.value.model, effort: result.value.reasoningEffort ?? '' })
2077
+ void refreshList(store, parentSessionId)
2078
+ void refreshDirectory(store)
2079
+ return true
2080
+ }
2081
+ return false
2082
+ }
2083
+
2084
+ ctx.effect(() => {
2085
+ const offZh = ctx.locale.register(LOCALE_NS, 'zh', zh)
2086
+ const offEn = ctx.locale.register(LOCALE_NS, 'en', en)
2087
+ const offSub = ctx.locale.subscribe(() => {
2088
+ activeLocale = ctx.locale.getSnapshot().active
2089
+ store.patch({})
2090
+ })
2091
+ return () => { offZh(); offEn(); offSub() }
2092
+ }, 'dsh-side-chat: dictionaries')
2093
+
2094
+ // Load the persisted preferences once.
2095
+ void api.settingsGet().then((result) => {
2096
+ if (!result.ok) return
2097
+ const raw = result.value.value as Partial<SubchatPrefs> | null | undefined
2098
+ if (raw === null || raw === undefined) return
2099
+ store.setPrefs({
2100
+ lookupDefault: typeof raw.lookupDefault === 'boolean' ? raw.lookupDefault : SUBCHAT_PREFS_DEFAULTS.lookupDefault,
2101
+ sendImmediately: typeof raw.sendImmediately === 'boolean' ? raw.sendImmediately : SUBCHAT_PREFS_DEFAULTS.sendImmediately,
2102
+ defaultPrompt: typeof raw.defaultPrompt === 'string' ? raw.defaultPrompt : SUBCHAT_PREFS_DEFAULTS.defaultPrompt,
2103
+ bringMode: raw.bringMode === 'context' ? 'context' : 'draft',
2104
+ panelHome: raw.panelHome === 'floating' ? 'floating' : 'sidebar-right',
2105
+ })
2106
+ })
2107
+
2108
+ // Track the current conversation (per-conversation panel state).
2109
+ ctx.effect(() => {
2110
+ let lastId: string | undefined
2111
+ const sync = (): void => {
2112
+ const next = currentSessionId()
2113
+ if (next === lastId) return
2114
+ lastId = next
2115
+ store.setCurrent(next)
2116
+ if (next === undefined) return
2117
+ void refreshList(store, next)
2118
+ const panel = store.getSnapshot().panel
2119
+ if (panel.open && panel.activeChildId !== null) {
2120
+ void refreshHistory(store, panel.activeChildId)
2121
+ }
2122
+ }
2123
+ sync()
2124
+ return subscribeCurrentSession(sync)
2125
+ }, 'dsh-side-chat: follow current conversation')
2126
+
2127
+ // Track the main conversation's pending user-question dialog so the panel can
2128
+ // list its questions/options with per-item bring-back buttons. DSH surfaces
2129
+ // the pending interaction per Session: `uiSession.pendingInteractions`
2130
+ // through 0.1.6-alpha.1, and `uiSession.sessionStatus` (`.pendingInteraction`)
2131
+ // from 0.1.6-alpha.2 — `pendingInteractionFor` reads whichever is present.
2132
+ // Only re-publishes when the question object identity changes.
2133
+ ctx.effect(() => {
2134
+ let lastQuestion: unknown = undefined
2135
+ const read = (): void => {
2136
+ const sessionId = currentSessionId()
2137
+ if (sessionId === undefined) {
2138
+ store.setMainQuestion(null)
2139
+ return
2140
+ }
2141
+ const interaction = pendingInteractionFor(sessionId)
2142
+ const isQuestion = interaction !== undefined && (interaction.kind === 'question' || interaction.kind === 'plan-review')
2143
+ const question = isQuestion ? interaction : undefined
2144
+ if (question === lastQuestion) return
2145
+ lastQuestion = question
2146
+ const questions = question?.questions ?? null
2147
+ store.setMainQuestion(questions === null ? null : [...questions])
2148
+ }
2149
+ read()
2150
+ const offPending = subscribePendingInteractions(read)
2151
+ const offList = subscribeCurrentSession(read)
2152
+ return () => { offPending(); offList() }
2153
+ }, 'dsh-side-chat: track main question dialog')
2154
+
2155
+ // Safety net: once the main conversation no longer has a pending question
2156
+ // dialog, clear the tracked question so the side-panel list disappears too
2157
+ // (covers cases where the subscription misses the settlement edge).
2158
+ ctx.effect(() => {
2159
+ const timer = window.setInterval(() => {
2160
+ if (store.getSnapshot().mainQuestion === null) return
2161
+ const sessionId = currentSessionId()
2162
+ if (sessionId === undefined) return
2163
+ const interaction = pendingInteractionFor(sessionId)
2164
+ const hasQuestion = interaction !== undefined && (interaction.kind === 'question' || interaction.kind === 'plan-review')
2165
+ if (!hasQuestion) store.setMainQuestion(null)
2166
+ }, 1200)
2167
+ return () => { window.clearInterval(timer) }
2168
+ }, 'dsh-side-chat: clear stale question dialog')
2169
+
2170
+ // Optional dock mode: live inside the new built-in right sidebar
2171
+ // (dsh-client-ui-sidebar-right) as a "Side chat" tab instead of the floating
2172
+ // panel. Registered on demand; falls back to floating when the service is
2173
+ // absent (older deployments) without ever failing to mount.
2174
+ ctx.effect(() => {
2175
+ let cleanup: (() => void) | undefined
2176
+ // Explicit phase machine: sync() runs on every store notify, and patching
2177
+ // the store from inside sync() must never re-enter registration work.
2178
+ let phase: 'idle' | 'docked' | 'unavailable' = 'idle'
2179
+
2180
+ const sync = (): void => {
2181
+ const docked = store.getSnapshot().prefs.panelHome === 'sidebar-right'
2182
+ if (docked) {
2183
+ if (phase === 'docked' || phase === 'unavailable') return
2184
+ const sidebarRight = ctx.get('sidebarRight') as SideSidebarRight | undefined
2185
+ // Use the public registry shared by official Sidebar conversations.
2186
+ const sidebarRightTabs = (ctx.get('sidebarRightTabs') as SideSidebarRightTabs | undefined)
2187
+ ?? sidebarRight?.tabs
2188
+ if (sidebarRight === undefined || sidebarRightTabs === undefined) {
2189
+ phase = 'unavailable'
2190
+ store.patch({ error: translate(activeLocale, 'dock.unavailable') })
2191
+ return
2192
+ }
2193
+ const dockT = (key: SidechatLocaleKey): string => translate(activeLocale, key)
2194
+
2195
+ const disposeType = sidebarRightTabs.register({
2196
+ id: SIDEBAR_TAB_ID,
2197
+ kind: SIDEBAR_TAB_KIND,
2198
+ priority: 'extension',
2199
+ title: () => dockT('dock.title'),
2200
+ })
2201
+ // Body seat: rendered for the committed tab whose type id matches `key`.
2202
+ const disposeBody = ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
2203
+ name: 'sidebar.right.pane.tab',
2204
+ key: SIDEBAR_TAB_ID,
2205
+ inject: () => ({
2206
+ store,
2207
+ t: dockT,
2208
+ formatDuration,
2209
+ bringToMain,
2210
+ summarizeBring,
2211
+ askSidechat,
2212
+ askSidechatNew,
2213
+ }),
2214
+ }, EmbeddedSidechatPanel))
2215
+ const launch = (): void => {
2216
+ try {
2217
+ sidebarRight.openTab(SIDEBAR_TAB_KIND, { revealIfOpened: true })
2218
+ } catch (error) {
2219
+ store.patch({ error: `${translate(activeLocale, 'dock.openFailed')}: ${error instanceof Error ? error.message : String(error)}` })
2220
+ }
2221
+ }
2222
+ store.setDockLaunch(launch)
2223
+ cleanup = () => {
2224
+ store.setDockLaunch(null)
2225
+ disposeBody()
2226
+ disposeType()
2227
+ }
2228
+ phase = 'docked'
2229
+ return
2230
+ }
2231
+ if (phase === 'idle') return
2232
+ phase = 'idle'
2233
+ store.setDockLaunch(null)
2234
+ cleanup?.()
2235
+ cleanup = undefined
2236
+ }
2237
+
2238
+ sync()
2239
+ const off = store.subscribe(sync)
2240
+ return () => {
2241
+ off()
2242
+ cleanup?.()
2243
+ }
2244
+ }, 'dsh-side-chat: right-sidebar dock')
2245
+
2246
+ // The "Side chat" settings section.
2247
+ const settingsT = (key: SidechatLocaleKey): string => translate(activeLocale, key)
2248
+ const formatDuration = (ms: number): string => formatRunDuration(ms, activeLocale)
2249
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
2250
+ name: 'settings.section',
2251
+ id: 'dsh-side-chat',
2252
+ order: 110,
2253
+ label: () => settingsT('settingsNav'),
2254
+ inject: () => ({ store, t: settingsT }),
2255
+ }, SettingsSection))
2256
+
2257
+ // Mount the portalled tree onto document.body.
2258
+ ctx.effect(() => {
2259
+ const host = document.createElement('div')
2260
+ host.setAttribute('data-dsh-side-chat', '')
2261
+ document.body.appendChild(host)
2262
+ const root = createRoot(host)
2263
+
2264
+ const t = (key: SidechatLocaleKey): string => translate(activeLocale, key)
2265
+ root.render(<SideChatShell
2266
+ store={store}
2267
+ t={t}
2268
+ formatDuration={formatDuration}
2269
+ bringToMain={bringToMain}
2270
+ summarizeBring={summarizeBring}
2271
+ askSidechat={askSidechat}
2272
+ askSidechatNew={askSidechatNew}
2273
+ />)
2274
+
2275
+ return () => {
2276
+ root.unmount()
2277
+ host.remove()
2278
+ }
2279
+ }, 'dsh-side-chat: panel mount')
2280
+ }