dsh-side-chat-plus 0.3.3 → 0.4.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,7 +14,7 @@ import {
14
14
  IconChevronDownOutline14,
15
15
  IconChevronRightOutline14,
16
16
  IconPanelLeftOutline16,
17
- IconSendOutline16,
17
+ IconSendOutline14,
18
18
  IconStopFill16,
19
19
  IconThinkOutline14,
20
20
  MarkdownText,
@@ -28,7 +28,7 @@ import {
28
28
  ImageLightbox,
29
29
  type ImageLoader,
30
30
  } from './attachments/index.ts'
31
- import type { Context, SideQuestionItem, SideQuestionOption } from '../context-types.ts'
31
+ import type { Context, SideQuestionItem, SideQuestionOption, SideSidebarRight } from '../context-types.ts'
32
32
  import {
33
33
  api,
34
34
  type PromptContentPart,
@@ -104,6 +104,8 @@ interface SidechatStore {
104
104
  dismissAllQuestions(ids: string[]): void
105
105
  openPanel(parentSessionId: string): void
106
106
  closePanel(): void
107
+ /** Install (or clear) the dock-mode launcher; openPanel() fires it after opening. */
108
+ setDockLaunch(fn: (() => void) | null): void
107
109
  setActive(childId: string): void
108
110
  patch(partial: Partial<PanelState>): void
109
111
  }
@@ -146,6 +148,9 @@ function createStore(): SidechatStore {
146
148
  // the client must remember each conversation's open panel + active child.
147
149
  const bySession = new Map<string, PanelState>()
148
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
149
154
  // Cached snapshot: useSyncExternalStore compares identity, so the object is
150
155
  // only rebuilt on a mutation — never inside getSnapshot itself.
151
156
  let snapshot: SidechatSnapshot = { current, panel, anchor, prefs, mainQuestion, dismissedQuestionIds }
@@ -199,12 +204,16 @@ function createStore(): SidechatStore {
199
204
  },
200
205
  openPanel(parentSessionId) {
201
206
  panel = { ...panel, open: true, parentSessionId }
207
+ dockLaunch?.()
202
208
  notify()
203
209
  },
204
210
  closePanel() {
205
211
  panel = { ...panel, open: false }
206
212
  notify()
207
213
  },
214
+ setDockLaunch(fn) {
215
+ dockLaunch = fn
216
+ },
208
217
  setActive(childId) {
209
218
  panel = { ...panel, activeChildId: childId, messages: [], error: null }
210
219
  notify()
@@ -255,6 +264,22 @@ function createDraftImages(files: readonly File[]): ComposerAttachment[] {
255
264
  })
256
265
  }
257
266
 
267
+ /**
268
+ * Read one image file's intrinsic pixel size, releasing the decoded bitmap.
269
+ * Returns null when the browser cannot decode it: the host's `validateImage`
270
+ * stays the authority either way.
271
+ */
272
+ async function imageSize(file: File): Promise<{ width: number; height: number } | null> {
273
+ try {
274
+ const bitmap = await createImageBitmap(file)
275
+ const size = { width: bitmap.width, height: bitmap.height }
276
+ bitmap.close()
277
+ return size
278
+ } catch {
279
+ return null
280
+ }
281
+ }
282
+
258
283
  /** Revoke one draft image's preview URL. */
259
284
  function releaseDraftImage(attachment: ComposerAttachment): void {
260
285
  URL.revokeObjectURL(attachment.previewUrl)
@@ -873,6 +898,10 @@ const MAIN_CHAT_MIN_WIDTH = 480
873
898
  /** localStorage key remembering the last panel width across reloads. */
874
899
  const PANEL_WIDTH_KEY = 'dsh-side-chat.panelWidth'
875
900
 
901
+ /** Right-sidebar dock identity (kind is the `openTab` discriminator; id keys the body seat). */
902
+ const SIDEBAR_TAB_ID = 'dsh-side-chat-plus/side-chat'
903
+ const SIDEBAR_TAB_KIND = 'side-chat'
904
+
876
905
  /** The viewport-aware maximum panel width for the current window. */
877
906
  function panelCap(): number {
878
907
  const vw = window.innerWidth
@@ -900,6 +929,10 @@ function SidechatPanel(props: {
900
929
  summarizeBring: (text: string) => Promise<boolean>
901
930
  askSidechat: (text: string) => Promise<boolean>
902
931
  askSidechatNew: (text: string) => Promise<boolean>
932
+ /** Render inside the built-in right sidebar's tab pane (no floating chrome). */
933
+ embedded?: boolean
934
+ /** A built-in sidebar overlay (fullscreen preview / floating panel) owns the viewport: hide this panel. */
935
+ conflictHidden?: boolean
903
936
  }) {
904
937
  const { panel, mainQuestion, dismissedQuestionIds } = useSyncExternalStore(props.store.subscribe, props.store.getSnapshot)
905
938
  const scrollRef = useRef<HTMLDivElement | null>(null)
@@ -935,7 +968,7 @@ function SidechatPanel(props: {
935
968
  const [now, setNow] = useState(() => Date.now())
936
969
  const [dragActive, setDragActive] = useState(false)
937
970
  const [lightbox, setLightbox] = useState<ComposerAttachment | null>(null)
938
- const [limits, setLimits] = useState<{ mediaTypes: string[]; maxImageBytes: number; maxImagesPerMessage: number; maxMessageImageBytes: number } | null>(null)
971
+ const [limits, setLimits] = useState<{ mediaTypes: string[]; maxImageBytes: number; maxImagesPerMessage: number; maxMessageImageBytes: number; maxImagePixels: number; maxImageDimension?: number } | null>(null)
939
972
  /** Index of the assistant message whose "summarize then insert" is in flight. */
940
973
  const [summarizingIndex, setSummarizingIndex] = useState<number | null>(null)
941
974
  /** Which question-dialog item is being brought into the side chat ('all' or an option label). */
@@ -997,10 +1030,11 @@ function SidechatPanel(props: {
997
1030
  const [anchor, setAnchor] = useState<number | null>(null)
998
1031
 
999
1032
  useEffect(() => {
1000
- const w = panel.open && !collapsed ? `${width}px` : '0px'
1033
+ if (props.embedded) return
1034
+ const w = panel.open && !collapsed && !props.conflictHidden ? `${width}px` : '0px'
1001
1035
  document.documentElement.style.setProperty('--dsh-subchat-width', w)
1002
1036
  return () => { document.documentElement.style.setProperty('--dsh-subchat-width', '0px') }
1003
- }, [panel.open, collapsed, width])
1037
+ }, [props.embedded, panel.open, collapsed, width, props.conflictHidden])
1004
1038
 
1005
1039
  // Re-adapt the panel width when the window is resized: if the viewport
1006
1040
  // shrinks (smaller window, different monitor, higher zoom), the panel is
@@ -1196,26 +1230,41 @@ function SidechatPanel(props: {
1196
1230
  props.store.patch({ error: props.t('image.unsupported') })
1197
1231
  return
1198
1232
  }
1199
- try {
1200
- if (limits !== null) {
1201
- if (images.some((f) => !limits.mediaTypes.includes(f.type))) {
1202
- props.store.patch({ error: props.t('image.unsupported') })
1203
- return
1204
- }
1205
- if (panel.attachments.length + images.length > limits.maxImagesPerMessage) {
1206
- props.store.patch({ error: props.t('image.tooMany') })
1207
- return
1208
- }
1209
- if (images.some((f) => f.size > limits.maxImageBytes)) {
1210
- props.store.patch({ error: props.t('image.fileTooLarge') })
1211
- return
1233
+ void (async () => {
1234
+ try {
1235
+ if (limits !== null) {
1236
+ if (images.some((f) => !limits.mediaTypes.includes(f.type))) {
1237
+ props.store.patch({ error: props.t('image.unsupported') })
1238
+ return
1239
+ }
1240
+ if (panel.attachments.length + images.length > limits.maxImagesPerMessage) {
1241
+ props.store.patch({ error: props.t('image.tooMany') })
1242
+ return
1243
+ }
1244
+ if (images.some((f) => f.size > limits.maxImageBytes)) {
1245
+ props.store.patch({ error: props.t('image.fileTooLarge') })
1246
+ return
1247
+ }
1248
+ // DSH 0.1.6 added a per-image intrinsic width/height ceiling; older
1249
+ // lines omit it. Decoding is async, so this check is awaited rather
1250
+ // than folded into the size-type ones above.
1251
+ const maxDimension = limits.maxImageDimension
1252
+ if (typeof maxDimension === 'number' && maxDimension > 0) {
1253
+ for (const file of images) {
1254
+ const size = await imageSize(file)
1255
+ if (size !== null && (size.width > maxDimension || size.height > maxDimension)) {
1256
+ props.store.patch({ error: props.t('image.dimensionTooLarge') })
1257
+ return
1258
+ }
1259
+ }
1260
+ }
1212
1261
  }
1262
+ const created = createDraftImages(images)
1263
+ props.store.patch({ attachments: [...panel.attachments, ...created], error: null })
1264
+ } catch (error) {
1265
+ props.store.patch({ error: error instanceof Error ? error.message : String(error) })
1213
1266
  }
1214
- const created = createDraftImages(images)
1215
- props.store.patch({ attachments: [...panel.attachments, ...created], error: null })
1216
- } catch (error) {
1217
- props.store.patch({ error: error instanceof Error ? error.message : String(error) })
1218
- }
1267
+ })()
1219
1268
  }, [limits, panel.attachments, props.store, props.t])
1220
1269
 
1221
1270
  // Load the deployment image policy once (fast-path checks mirror the host).
@@ -1287,46 +1336,55 @@ function SidechatPanel(props: {
1287
1336
  return base64ObjectUrl(result.value.mediaType, result.value.data)
1288
1337
  }, [panel.activeChildId])
1289
1338
 
1290
- if (!panel.open) {
1291
- // Panel closed but the main conversation has a pending question dialog:
1292
- // show a floating entry anchored beside the dialog header.
1293
- if (mainQuestion !== null) {
1294
- return <QuestionFab store={props.store} t={props.t} onOpen={openQuestionPanel} />
1295
- }
1296
- return null
1297
- }
1298
-
1299
- if (collapsed) {
1300
- // Collapsed but a question dialog is pending: keep the floating entry so it
1301
- // can still be opened from beside the dialog (not just the collapsed handle).
1302
- if (mainQuestion !== null) {
1303
- return <QuestionFab store={props.store} t={props.t} onOpen={openQuestionPanel} />
1339
+ // The pending-question entry floats in the portal (SideChatShell), where it is
1340
+ // mounted in both modes; only the floating-mode collapsed handle still lives
1341
+ // here, next to the collapsed state it belongs to.
1342
+ if (!props.embedded) {
1343
+ if (!panel.open) return null
1344
+
1345
+ if (collapsed) {
1346
+ // Collapsed but a question dialog is pending: keep the floating entry so it
1347
+ // can still be opened from beside the dialog (not just the collapsed handle).
1348
+ if (mainQuestion !== null) {
1349
+ return <QuestionFab store={props.store} t={props.t} onOpen={openQuestionPanel} />
1350
+ }
1351
+ // Collapsed handle: a floating round button on the right edge.
1352
+ return (
1353
+ <Tooltip label={props.t('panel.expand')} side="bottom">
1354
+ <button type="button" className={css.collapsedHandle} onClick={() => { setCollapsed(false) }}>
1355
+ <IconPanelLeftOutline16 size={16} />
1356
+ </button>
1357
+ </Tooltip>
1358
+ )
1304
1359
  }
1305
- return (
1306
- <Tooltip label={props.t('panel.expand')} side="bottom">
1307
- <button type="button" className={css.collapsedHandle} onClick={() => { setCollapsed(false) }}>
1308
- <IconPanelLeftOutline16 size={16} />
1309
- </button>
1310
- </Tooltip>
1311
- )
1312
1360
  }
1313
1361
 
1314
1362
  const elapsedMs = anchor === null ? 0 : Math.max(0, now - anchor)
1315
1363
  const showClock = elapsedMs >= 15000
1316
1364
 
1365
+ const panelClass = props.embedded
1366
+ ? `${css.panel} ${css.panelEmbedded}`
1367
+ : `${css.panel}${props.conflictHidden ? ` ${css.panelConflictHidden}` : ''}`
1368
+
1317
1369
  return (
1318
- <div className={css.panel} style={{ width }}>
1319
- <div className={css.panelResize} onMouseDown={startResize} />
1320
- <div className={css.panelHeader}>
1321
- <span className={css.panelTitle}>{props.t('panel.title')}</span>
1322
- <div className={css.panelHeaderActions}>
1323
- <Tooltip label={props.t('panel.collapse')} side="bottom">
1324
- <button type="button" className={css.panelIconButton} onClick={() => { setCollapsed(true) }}>
1325
- <IconPanelLeftOutline16 size={16} />
1326
- </button>
1327
- </Tooltip>
1370
+ <div
1371
+ className={panelClass}
1372
+ style={props.embedded ? undefined : { width }}
1373
+ data-sidechat-panel={props.embedded ? 'embedded' : 'floating'}
1374
+ >
1375
+ {!props.embedded && <div className={css.panelResize} onMouseDown={startResize} />}
1376
+ {!props.embedded && (
1377
+ <div className={css.panelHeader}>
1378
+ <span className={css.panelTitle}>{props.t('panel.title')}</span>
1379
+ <div className={css.panelHeaderActions}>
1380
+ <Tooltip label={props.t('panel.collapse')} side="bottom">
1381
+ <button type="button" className={css.panelIconButton} onClick={() => { setCollapsed(true) }}>
1382
+ <IconPanelLeftOutline16 size={16} />
1383
+ </button>
1384
+ </Tooltip>
1385
+ </div>
1328
1386
  </div>
1329
- </div>
1387
+ )}
1330
1388
 
1331
1389
  {mainQuestion !== null && (() => {
1332
1390
  const visible = mainQuestion.filter((q) => !dismissedQuestionIds.includes(q.id))
@@ -1576,7 +1634,7 @@ function SidechatPanel(props: {
1576
1634
  aria-label={activeRunning ? props.t('panel.stop') : props.t('panel.send')}
1577
1635
  onClick={activeRunning ? stop : send}
1578
1636
  >
1579
- {activeRunning ? <IconStopFill16 size={16} /> : <IconSendOutline16 size={16} />}
1637
+ {activeRunning ? <IconStopFill16 size={16} /> : <IconSendOutline14 size={16} />}
1580
1638
  </button>
1581
1639
  </Tooltip>
1582
1640
  </div>
@@ -1609,6 +1667,110 @@ function SidechatPanel(props: {
1609
1667
  }
1610
1668
 
1611
1669
  /** The "Side chat" settings section (two switches + a prompt textarea). */
1670
+ /** Dock-mode body: the side-chat panel embedded in the built-in right sidebar's tab pane. */
1671
+ function EmbeddedSidechatPanel(props: {
1672
+ store: SidechatStore
1673
+ t: (key: SidechatLocaleKey) => string
1674
+ formatDuration: (ms: number) => string
1675
+ bringToMain: (text: string) => Promise<boolean>
1676
+ summarizeBring: (text: string) => Promise<boolean>
1677
+ askSidechat: (text: string) => Promise<boolean>
1678
+ askSidechatNew: (text: string) => Promise<boolean>
1679
+ }) {
1680
+ // The tab seat only mounts for the current session, so the shared store's
1681
+ // current-conversation state is the right one to draw.
1682
+ return (
1683
+ <SidechatPanel
1684
+ embedded
1685
+ store={props.store}
1686
+ t={props.t}
1687
+ formatDuration={props.formatDuration}
1688
+ bringToMain={props.bringToMain}
1689
+ summarizeBring={props.summarizeBring}
1690
+ askSidechat={props.askSidechat}
1691
+ askSidechatNew={props.askSidechatNew}
1692
+ />
1693
+ )
1694
+ }
1695
+
1696
+ /** The portalled shell: floating menus + the panel, switching on prefs.panelHome. */
1697
+ function SideChatShell(props: {
1698
+ store: SidechatStore
1699
+ t: (key: SidechatLocaleKey) => string
1700
+ formatDuration: (ms: number) => string
1701
+ bringToMain: (text: string) => Promise<boolean>
1702
+ summarizeBring: (text: string) => Promise<boolean>
1703
+ askSidechat: (text: string) => Promise<boolean>
1704
+ askSidechatNew: (text: string) => Promise<boolean>
1705
+ }) {
1706
+ const snap = useSyncExternalStore(props.store.subscribe, props.store.getSnapshot)
1707
+ const [builtinOverlay, setBuiltinOverlay] = useState(false)
1708
+
1709
+ // While the built-in right sidebar actually shows its panel (docked, fullscreen
1710
+ // or a floated pane), the floating side-chat panel would sit on top of it: yield
1711
+ // — slide the side chat away and drop its layout margin until the built-in panel
1712
+ // is closed again. Docked mode shares the sidebar instead, so it never needs
1713
+ // this guard.
1714
+ useEffect(() => {
1715
+ const probe = (): boolean => {
1716
+ if (document.querySelector('[data-sidebar-right-panel][data-sidebar-right-open]') !== null) return true
1717
+ const floatHost = document.querySelector('[data-sidebar-right-float-host]')
1718
+ if (floatHost !== null && floatHost.childElementCount > 0) return true
1719
+ return false
1720
+ }
1721
+ let active = probe()
1722
+ const apply = (): void => {
1723
+ const next = probe()
1724
+ if (next !== active) {
1725
+ active = next
1726
+ setBuiltinOverlay(next)
1727
+ }
1728
+ }
1729
+ apply()
1730
+ const observer = new MutationObserver(apply)
1731
+ observer.observe(document.body, {
1732
+ childList: true,
1733
+ subtree: true,
1734
+ attributes: true,
1735
+ attributeFilter: ['data-sidebar-right-open', 'data-sidebar-right-panel', 'data-sidebar-right-float-host'],
1736
+ })
1737
+ window.addEventListener('resize', apply)
1738
+ return () => {
1739
+ observer.disconnect()
1740
+ window.removeEventListener('resize', apply)
1741
+ }
1742
+ }, [])
1743
+
1744
+ return (
1745
+ <>
1746
+ <SelectionMenu store={props.store} t={props.t} />
1747
+ <BringBackMenu store={props.store} t={props.t} bringToMain={props.bringToMain} summarizeBring={props.summarizeBring} />
1748
+ {/* The pending-question entry floats in the portal, not inside the panel:
1749
+ in dock mode the panel (and its tab body) does not exist until the tab
1750
+ was opened at least once, but the entry must appear regardless. */}
1751
+ {snap.mainQuestion !== null && !snap.panel.open && (
1752
+ <QuestionFab
1753
+ store={props.store}
1754
+ t={props.t}
1755
+ onOpen={() => { props.store.openPanel(snap.panel.parentSessionId) }}
1756
+ />
1757
+ )}
1758
+ {snap.prefs.panelHome === 'floating' && (
1759
+ <SidechatPanel
1760
+ store={props.store}
1761
+ t={props.t}
1762
+ formatDuration={props.formatDuration}
1763
+ bringToMain={props.bringToMain}
1764
+ summarizeBring={props.summarizeBring}
1765
+ askSidechat={props.askSidechat}
1766
+ askSidechatNew={props.askSidechatNew}
1767
+ conflictHidden={builtinOverlay}
1768
+ />
1769
+ )}
1770
+ </>
1771
+ )
1772
+ }
1773
+
1612
1774
  function SettingsSection(props: { store: SidechatStore; t: (key: SidechatLocaleKey) => string }) {
1613
1775
  const { store, t } = props
1614
1776
  const { prefs } = useSyncExternalStore(store.subscribe, store.getSnapshot)
@@ -1693,6 +1855,40 @@ function SettingsSection(props: { store: SidechatStore; t: (key: SidechatLocaleK
1693
1855
  </span>
1694
1856
  </label>
1695
1857
  </div>
1858
+ <div className={css.settingsRow}>
1859
+ <span className={css.settingsRowText}>
1860
+ <span className={css.settingsRowTitle}>{t('settings.panelHomeTitle')}</span>
1861
+ <span className={css.settingsRowDesc}>{t('settings.panelHomeDesc')}</span>
1862
+ </span>
1863
+ </div>
1864
+ <div className={css.settingsBringMode}>
1865
+ <label className={`${css.settingsBringOption} ${prefs.panelHome === 'floating' ? css.settingsBringOptionActive : ''}`}>
1866
+ <input
1867
+ type="radio"
1868
+ name="dsh-side-chat-panel-home"
1869
+ className={css.settingsToggle}
1870
+ checked={prefs.panelHome === 'floating'}
1871
+ onChange={() => { toggle({ panelHome: 'floating' }) }}
1872
+ />
1873
+ <span className={css.settingsRowText}>
1874
+ <span className={css.settingsRowTitle}>{t('settings.panelHomeFloatingTitle')}</span>
1875
+ <span className={css.settingsRowDesc}>{t('settings.panelHomeFloatingDesc')}</span>
1876
+ </span>
1877
+ </label>
1878
+ <label className={`${css.settingsBringOption} ${prefs.panelHome === 'sidebar-right' ? css.settingsBringOptionActive : ''}`}>
1879
+ <input
1880
+ type="radio"
1881
+ name="dsh-side-chat-panel-home"
1882
+ className={css.settingsToggle}
1883
+ checked={prefs.panelHome === 'sidebar-right'}
1884
+ onChange={() => { toggle({ panelHome: 'sidebar-right' }) }}
1885
+ />
1886
+ <span className={css.settingsRowText}>
1887
+ <span className={css.settingsRowTitle}>{t('settings.panelHomeDockTitle')}</span>
1888
+ <span className={css.settingsRowDesc}>{t('settings.panelHomeDockDesc')}</span>
1889
+ </span>
1890
+ </label>
1891
+ </div>
1696
1892
  <div className={css.settingsRow}>
1697
1893
  <span className={css.settingsRowText}>
1698
1894
  <span className={css.settingsRowTitle}>{t('settings.defaultPromptTitle')}</span>
@@ -1869,6 +2065,7 @@ export function apply(ctx: Context): void {
1869
2065
  sendImmediately: typeof raw.sendImmediately === 'boolean' ? raw.sendImmediately : SUBCHAT_PREFS_DEFAULTS.sendImmediately,
1870
2066
  defaultPrompt: typeof raw.defaultPrompt === 'string' ? raw.defaultPrompt : SUBCHAT_PREFS_DEFAULTS.defaultPrompt,
1871
2067
  bringMode: raw.bringMode === 'context' ? 'context' : 'draft',
2068
+ panelHome: raw.panelHome === 'floating' ? 'floating' : 'sidebar-right',
1872
2069
  })
1873
2070
  })
1874
2071
 
@@ -1933,8 +2130,82 @@ export function apply(ctx: Context): void {
1933
2130
  return () => { window.clearInterval(timer) }
1934
2131
  }, 'dsh-side-chat: clear stale question dialog')
1935
2132
 
2133
+ // Optional dock mode: live inside the new built-in right sidebar
2134
+ // (dsh-client-ui-sidebar-right) as a "Side chat" tab instead of the floating
2135
+ // panel. Registered on demand; falls back to floating when the service is
2136
+ // absent (older deployments) without ever failing to mount.
2137
+ ctx.effect(() => {
2138
+ let cleanup: (() => void) | undefined
2139
+ // Explicit phase machine: sync() runs on every store notify, and patching
2140
+ // the store from inside sync() must never re-enter registration work.
2141
+ let phase: 'idle' | 'docked' | 'unavailable' = 'idle'
2142
+
2143
+ const sync = (): void => {
2144
+ const docked = store.getSnapshot().prefs.panelHome === 'sidebar-right'
2145
+ if (docked) {
2146
+ if (phase === 'docked' || phase === 'unavailable') return
2147
+ const sidebarRight = ctx.get('sidebarRight') as SideSidebarRight | undefined
2148
+ if (sidebarRight === undefined) {
2149
+ phase = 'unavailable'
2150
+ store.patch({ error: translate(activeLocale, 'dock.unavailable') })
2151
+ return
2152
+ }
2153
+ const dockT = (key: SidechatLocaleKey): string => translate(activeLocale, key)
2154
+
2155
+ const disposeType = sidebarRight.tabs.register({
2156
+ id: SIDEBAR_TAB_ID,
2157
+ kind: SIDEBAR_TAB_KIND,
2158
+ priority: 'extension',
2159
+ title: () => dockT('dock.title'),
2160
+ })
2161
+ // Body seat: rendered for the committed tab whose type id matches `key`.
2162
+ const disposeBody = ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
2163
+ name: 'sidebar.right.pane.tab',
2164
+ key: SIDEBAR_TAB_ID,
2165
+ inject: () => ({
2166
+ store,
2167
+ t: dockT,
2168
+ formatDuration,
2169
+ bringToMain,
2170
+ summarizeBring,
2171
+ askSidechat,
2172
+ askSidechatNew,
2173
+ }),
2174
+ }, EmbeddedSidechatPanel))
2175
+ const launch = (): void => {
2176
+ try {
2177
+ sidebarRight.openTab(SIDEBAR_TAB_KIND, { revealIfOpened: true })
2178
+ } catch (error) {
2179
+ store.patch({ error: `${translate(activeLocale, 'dock.openFailed')}: ${error instanceof Error ? error.message : String(error)}` })
2180
+ }
2181
+ }
2182
+ store.setDockLaunch(launch)
2183
+ cleanup = () => {
2184
+ store.setDockLaunch(null)
2185
+ disposeBody()
2186
+ disposeType()
2187
+ }
2188
+ phase = 'docked'
2189
+ return
2190
+ }
2191
+ if (phase === 'idle') return
2192
+ phase = 'idle'
2193
+ store.setDockLaunch(null)
2194
+ cleanup?.()
2195
+ cleanup = undefined
2196
+ }
2197
+
2198
+ sync()
2199
+ const off = store.subscribe(sync)
2200
+ return () => {
2201
+ off()
2202
+ cleanup?.()
2203
+ }
2204
+ }, 'dsh-side-chat: right-sidebar dock')
2205
+
1936
2206
  // The "Side chat" settings section.
1937
2207
  const settingsT = (key: SidechatLocaleKey): string => translate(activeLocale, key)
2208
+ const formatDuration = (ms: number): string => formatRunDuration(ms, activeLocale)
1938
2209
  ctx.slots.inject('settings.section', () => ctx.slots.register({
1939
2210
  name: 'settings.section',
1940
2211
  id: 'dsh-side-chat',
@@ -1951,12 +2222,15 @@ export function apply(ctx: Context): void {
1951
2222
  const root = createRoot(host)
1952
2223
 
1953
2224
  const t = (key: SidechatLocaleKey): string => translate(activeLocale, key)
1954
- const formatDuration = (ms: number): string => formatRunDuration(ms, activeLocale)
1955
- root.render(<>
1956
- <SelectionMenu store={store} t={t} />
1957
- <BringBackMenu store={store} t={t} bringToMain={bringToMain} summarizeBring={summarizeBring} />
1958
- <SidechatPanel store={store} t={t} formatDuration={formatDuration} bringToMain={bringToMain} summarizeBring={summarizeBring} askSidechat={askSidechat} askSidechatNew={askSidechatNew} />
1959
- </>)
2225
+ root.render(<SideChatShell
2226
+ store={store}
2227
+ t={t}
2228
+ formatDuration={formatDuration}
2229
+ bringToMain={bringToMain}
2230
+ summarizeBring={summarizeBring}
2231
+ askSidechat={askSidechat}
2232
+ askSidechatNew={askSidechatNew}
2233
+ />)
1960
2234
 
1961
2235
  return () => {
1962
2236
  root.unmount()
@@ -53,6 +53,7 @@ export const zh = {
53
53
  'image.unsupported': '不支持的图片格式',
54
54
  'image.tooMany': '图片数量超出限制',
55
55
  'image.fileTooLarge': '图片过大',
56
+ 'image.dimensionTooLarge': '图片尺寸超出限制',
56
57
  'settingsNav': '侧边聊天',
57
58
  'settings.lookupTitle': '默认勾选「需要时从工作区 / 主会话查信息」',
58
59
  'settings.lookupDesc': '新会话的侧边聊天默认打开此开关;可在每次聊天中临时更改',
@@ -67,6 +68,15 @@ export const zh = {
67
68
  'settings.bringModeDraftDesc': '内容进入主会话输入框,可直接编辑后再发送',
68
69
  'settings.bringModeContextTitle': '注入为折叠提示行',
69
70
  'settings.bringModeContextDesc': '内容作为一条折叠的上下文提示行注入主会话(带来源标记),不写输入框;下次对话时模型可见',
71
+ 'settings.panelHomeTitle': '侧边聊天面板位置',
72
+ 'settings.panelHomeDesc': '浮动面板固定在窗口右缘;停靠模式把侧聊放进新版右侧栏(与文档预览同一区域),两种模式都不会与内置右侧栏互相遮挡',
73
+ 'settings.panelHomeFloatingTitle': '浮动面板(经典)',
74
+ 'settings.panelHomeFloatingDesc': '固定在窗口右缘,可拖拽调宽、独立收起',
75
+ 'settings.panelHomeDockTitle': '停靠到右侧栏',
76
+ 'settings.panelHomeDockDesc': '作为「侧边聊天」标签停靠在新版右侧栏,与文档预览共用同一边栏',
77
+ 'dock.title': '侧边聊天',
78
+ 'dock.unavailable': '当前部署未提供新版右侧栏,已回退为浮动面板',
79
+ 'dock.openFailed': '无法打开右侧栏侧边标签',
70
80
  'insert.direct': '直接带回',
71
81
  'insert.summarize': '摘要后带回',
72
82
  'insert.summarizing': '摘要中…',
@@ -140,6 +150,7 @@ export const en = {
140
150
  'image.unsupported': 'Unsupported image type',
141
151
  'image.tooMany': 'Too many images',
142
152
  'image.fileTooLarge': 'Image too large',
153
+ 'image.dimensionTooLarge': 'Image dimensions exceed the limit',
143
154
  'settingsNav': 'Side chat',
144
155
  'settings.lookupTitle': 'Default on: "look up workspace / parent when needed"',
145
156
  'settings.lookupDesc': 'New conversations start with this switch on; you can still change it per chat',
@@ -154,6 +165,15 @@ export const en = {
154
165
  'settings.bringModeDraftDesc': 'Text lands in the main composer so you can edit before sending',
155
166
  'settings.bringModeContextTitle': 'As a collapsed context row',
156
167
  'settings.bringModeContextDesc': 'Text is injected as a collapsed, source-tagged context row — not into the composer; the model sees it next turn',
168
+ 'settings.panelHomeTitle': 'Side-chat panel location',
169
+ 'settings.panelHomeDesc': 'The classic panel floats on the window\u2019s right edge; dock mode puts the side chat inside the new right sidebar (the same rail as document previews). Neither mode covers the built-in sidebar',
170
+ 'settings.panelHomeFloatingTitle': 'Floating panel (classic)',
171
+ 'settings.panelHomeFloatingDesc': 'Fixed to the window\u2019s right edge; drag to resize and collapse independently',
172
+ 'settings.panelHomeDockTitle': 'Dock in the right sidebar',
173
+ 'settings.panelHomeDockDesc': 'Lives as a "Side chat" tab in the new right sidebar, sharing the rail with document previews',
174
+ 'dock.title': 'Side chat',
175
+ 'dock.unavailable': 'This deployment has no right sidebar; fell back to the floating panel',
176
+ 'dock.openFailed': 'Could not open the side-chat tab in the right sidebar',
157
177
  'insert.direct': 'Insert directly',
158
178
  'insert.summarize': 'Summarize & insert',
159
179
  'insert.summarizing': 'Summarizing…',