dsh-side-chat-plus 0.3.2 → 0.3.4

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