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.
- package/README.md +327 -326
- package/README.zh.md +269 -268
- package/dsh.plugin.json +3 -3
- package/lib/client-registry.js +397 -181
- package/lib/client-registry.js.map +1 -1
- package/lib/client.js +397 -181
- package/lib/client.js.map +1 -1
- package/lib/index.js +22 -7
- package/lib/types/client/locales.d.ts +18 -0
- package/lib/types/context-types.d.ts +36 -4
- package/lib/types/settings-shared.d.ts +4 -0
- package/package.json +37 -37
- package/src/client/attachments/AttachmentRail.module.css +89 -89
- package/src/client/attachments/AttachmentRail.tsx +173 -173
- package/src/client/attachments/DropOverlay.module.css +38 -38
- package/src/client/attachments/DropOverlay.tsx +62 -62
- package/src/client/attachments/ImageLightbox.module.css +44 -44
- package/src/client/attachments/ImageLightbox.tsx +58 -58
- package/src/client/attachments/MessageImage.module.css +61 -61
- package/src/client/attachments/MessageImage.tsx +120 -120
- package/src/client/attachments/index.ts +19 -19
- package/src/client/client.module.css +1051 -1032
- package/src/client/index.tsx +2209 -1966
- package/src/client/locales.ts +191 -173
- package/src/context-types.ts +415 -384
- package/src/index.ts +29 -9
- package/src/settings-shared.ts +6 -0
package/src/client/index.tsx
CHANGED
|
@@ -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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
let
|
|
141
|
-
let
|
|
142
|
-
let
|
|
143
|
-
let
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/**
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
//
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
</
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
})
|
|
677
|
-
void
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
)
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
const
|
|
755
|
-
if (
|
|
756
|
-
setLocal(null)
|
|
757
|
-
return
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
/**
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
//
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
const
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
/**
|
|
959
|
-
const
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
/** Assemble one question +
|
|
976
|
-
const
|
|
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
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
}
|
|
1032
|
-
}, [
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
const
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
void
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
props.store
|
|
1141
|
-
|
|
1142
|
-
void
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
void
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
void
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
}
|
|
1250
|
-
const
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
return (
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
<
|
|
1323
|
-
<
|
|
1324
|
-
<
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
</
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
)}
|
|
1546
|
-
|
|
1547
|
-
className={css.
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
)
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
const
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
const
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
const
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
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
|
+
}
|