dsh-side-chat-plus 0.3.2 → 0.3.3

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