dsh-better-sidebar 0.10.0

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.
Files changed (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -0
  3. package/README_EN.md +139 -0
  4. package/lib/client-docx.js +7649 -0
  5. package/lib/client-editor.js +32677 -0
  6. package/lib/client-pptx.js +63438 -0
  7. package/lib/client-registry.js +8021 -0
  8. package/lib/client-terminal.js +8060 -0
  9. package/lib/client-xlsx.js +359755 -0
  10. package/lib/client.js +8021 -0
  11. package/lib/index.js +2604 -0
  12. package/lib/invariant.js +22 -0
  13. package/lib/types/agent-pty.d.ts +220 -0
  14. package/lib/types/browser-probe.d.ts +15 -0
  15. package/lib/types/bundle-route.d.ts +13 -0
  16. package/lib/types/client/BrowserView.d.ts +22 -0
  17. package/lib/types/client/DiffTab.d.ts +6 -0
  18. package/lib/types/client/DiffView.d.ts +51 -0
  19. package/lib/types/client/EditorHost.d.ts +10 -0
  20. package/lib/types/client/ExplorerView.d.ts +9 -0
  21. package/lib/types/client/GitView.d.ts +8 -0
  22. package/lib/types/client/OrphanedTab.d.ts +11 -0
  23. package/lib/types/client/PdfView.d.ts +6 -0
  24. package/lib/types/client/PptxView.d.ts +6 -0
  25. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  26. package/lib/types/client/SideCardSection.d.ts +27 -0
  27. package/lib/types/client/Sidebar.d.ts +6 -0
  28. package/lib/types/client/SubagentView.d.ts +16 -0
  29. package/lib/types/client/TabBar.d.ts +38 -0
  30. package/lib/types/client/TerminalView.d.ts +8 -0
  31. package/lib/types/client/TextEditor.d.ts +10 -0
  32. package/lib/types/client/api.d.ts +170 -0
  33. package/lib/types/client/binary-download.d.ts +12 -0
  34. package/lib/types/client/breakpoints.d.ts +12 -0
  35. package/lib/types/client/browser.d.ts +50 -0
  36. package/lib/types/client/builtins/index.d.ts +17 -0
  37. package/lib/types/client/builtins/tabs.d.ts +6 -0
  38. package/lib/types/client/builtins/viewers.d.ts +3 -0
  39. package/lib/types/client/chunk-loader.d.ts +74 -0
  40. package/lib/types/client/chunks/docx.d.ts +8 -0
  41. package/lib/types/client/chunks/editor.d.ts +9 -0
  42. package/lib/types/client/chunks/pptx.d.ts +8 -0
  43. package/lib/types/client/chunks/terminal.d.ts +9 -0
  44. package/lib/types/client/chunks/xlsx.d.ts +9 -0
  45. package/lib/types/client/cm-themes.d.ts +24 -0
  46. package/lib/types/client/conversation-draft.d.ts +14 -0
  47. package/lib/types/client/docx-view.d.ts +8 -0
  48. package/lib/types/client/editor-load.d.ts +66 -0
  49. package/lib/types/client/icons.d.ts +47 -0
  50. package/lib/types/client/image-types.d.ts +3 -0
  51. package/lib/types/client/ime-guard.d.ts +36 -0
  52. package/lib/types/client/index.d.ts +10 -0
  53. package/lib/types/client/intercept.d.ts +20 -0
  54. package/lib/types/client/lang.d.ts +12 -0
  55. package/lib/types/client/lazy-chunk.d.ts +25 -0
  56. package/lib/types/client/link-intercept.d.ts +35 -0
  57. package/lib/types/client/locales.d.ts +237 -0
  58. package/lib/types/client/office-shared.d.ts +33 -0
  59. package/lib/types/client/office-types.d.ts +36 -0
  60. package/lib/types/client/office-view.d.ts +12 -0
  61. package/lib/types/client/openpath-intercept.d.ts +41 -0
  62. package/lib/types/client/paths.d.ts +19 -0
  63. package/lib/types/client/pdf-types.d.ts +2 -0
  64. package/lib/types/client/prefs.d.ts +30 -0
  65. package/lib/types/client/produced-files.d.ts +27 -0
  66. package/lib/types/client/selection-payload.d.ts +27 -0
  67. package/lib/types/client/service.d.ts +214 -0
  68. package/lib/types/client/split-pane.d.ts +29 -0
  69. package/lib/types/client/state.d.ts +322 -0
  70. package/lib/types/client/subagent-activity.d.ts +35 -0
  71. package/lib/types/client/subagent-detect.d.ts +47 -0
  72. package/lib/types/client/subagent-jobs.d.ts +63 -0
  73. package/lib/types/client/theme.d.ts +28 -0
  74. package/lib/types/client/xlsx-to-univer.d.ts +33 -0
  75. package/lib/types/client/xlsx-view.d.ts +10 -0
  76. package/lib/types/config.d.ts +41 -0
  77. package/lib/types/context-types.d.ts +431 -0
  78. package/lib/types/fs-tree.d.ts +41 -0
  79. package/lib/types/git.d.ts +75 -0
  80. package/lib/types/html-route.d.ts +48 -0
  81. package/lib/types/index.d.ts +39 -0
  82. package/lib/types/invariant.d.ts +15 -0
  83. package/lib/types/jobs-routes.d.ts +45 -0
  84. package/lib/types/prefs-shared.d.ts +106 -0
  85. package/lib/types/pty-manager.d.ts +76 -0
  86. package/lib/types/tools.d.ts +28 -0
  87. package/lib/types/trust-fence.d.ts +24 -0
  88. package/lib/types/wire.d.ts +38 -0
  89. package/package.json +168 -0
  90. package/src/agent-pty.ts +519 -0
  91. package/src/browser-probe.ts +26 -0
  92. package/src/bundle-route.ts +130 -0
  93. package/src/client/BrowserView.tsx +253 -0
  94. package/src/client/DiffTab.tsx +110 -0
  95. package/src/client/DiffView.tsx +266 -0
  96. package/src/client/EditorHost.tsx +101 -0
  97. package/src/client/ExplorerView.tsx +283 -0
  98. package/src/client/GitView.tsx +540 -0
  99. package/src/client/OrphanedTab.tsx +27 -0
  100. package/src/client/PdfView.tsx +110 -0
  101. package/src/client/PptxView.tsx +131 -0
  102. package/src/client/SandboxStatusBar.tsx +60 -0
  103. package/src/client/SideCardSection.module.css +271 -0
  104. package/src/client/SideCardSection.tsx +426 -0
  105. package/src/client/Sidebar.tsx +861 -0
  106. package/src/client/SubagentView.module.css +547 -0
  107. package/src/client/SubagentView.tsx +866 -0
  108. package/src/client/TabBar.tsx +188 -0
  109. package/src/client/TerminalView.tsx +233 -0
  110. package/src/client/TextEditor.tsx +382 -0
  111. package/src/client/api.ts +215 -0
  112. package/src/client/binary-download.tsx +23 -0
  113. package/src/client/breakpoints.ts +51 -0
  114. package/src/client/browser.ts +119 -0
  115. package/src/client/builtins/index.ts +33 -0
  116. package/src/client/builtins/tabs.tsx +211 -0
  117. package/src/client/builtins/viewers.tsx +151 -0
  118. package/src/client/chunk-loader.ts +196 -0
  119. package/src/client/chunks/docx.tsx +8 -0
  120. package/src/client/chunks/editor.tsx +9 -0
  121. package/src/client/chunks/pptx.tsx +8 -0
  122. package/src/client/chunks/terminal.tsx +9 -0
  123. package/src/client/chunks/xlsx.tsx +9 -0
  124. package/src/client/cm-themes.ts +129 -0
  125. package/src/client/conversation-draft.ts +29 -0
  126. package/src/client/css-modules.d.ts +5 -0
  127. package/src/client/docx-view.tsx +113 -0
  128. package/src/client/editor-load.ts +92 -0
  129. package/src/client/icons.tsx +137 -0
  130. package/src/client/image-types.ts +8 -0
  131. package/src/client/ime-guard.ts +47 -0
  132. package/src/client/index.tsx +238 -0
  133. package/src/client/intercept.tsx +94 -0
  134. package/src/client/lang.ts +95 -0
  135. package/src/client/layout.css +54 -0
  136. package/src/client/lazy-chunk.tsx +89 -0
  137. package/src/client/link-intercept.ts +66 -0
  138. package/src/client/locales.ts +476 -0
  139. package/src/client/office-shared.tsx +40 -0
  140. package/src/client/office-types.ts +48 -0
  141. package/src/client/office-view.tsx +12 -0
  142. package/src/client/openpath-intercept.ts +62 -0
  143. package/src/client/paths.ts +28 -0
  144. package/src/client/pdf-types.ts +4 -0
  145. package/src/client/prefs.ts +100 -0
  146. package/src/client/produced-files.ts +87 -0
  147. package/src/client/selection-payload.ts +86 -0
  148. package/src/client/service.ts +434 -0
  149. package/src/client/sidebar.module.css +1917 -0
  150. package/src/client/split-pane.tsx +292 -0
  151. package/src/client/state.ts +1048 -0
  152. package/src/client/subagent-activity.ts +67 -0
  153. package/src/client/subagent-detect.ts +125 -0
  154. package/src/client/subagent-jobs.ts +161 -0
  155. package/src/client/theme.ts +44 -0
  156. package/src/client/xlsx-to-univer.ts +260 -0
  157. package/src/client/xlsx-view.tsx +116 -0
  158. package/src/config.ts +95 -0
  159. package/src/context-types.ts +445 -0
  160. package/src/fs-tree.ts +115 -0
  161. package/src/git.ts +236 -0
  162. package/src/html-route.ts +70 -0
  163. package/src/index.ts +897 -0
  164. package/src/invariant.ts +32 -0
  165. package/src/jobs-routes.ts +257 -0
  166. package/src/prefs-shared.ts +127 -0
  167. package/src/pty-manager.ts +199 -0
  168. package/src/tools.ts +477 -0
  169. package/src/trust-fence.ts +77 -0
  170. package/src/wire.ts +94 -0
@@ -0,0 +1,188 @@
1
+ /**
2
+ * The tab strip of one pane: tabs capped at TAB_MAX_WIDTH (ellipsized),
3
+ * overflow scrolls horizontally, a close button per tab, a four-way split
4
+ * button cluster, and the + menu that opens new tabs (explorer / git /
5
+ * terminal). Tabs are draggable; dropping onto another tab inserts before it,
6
+ * dropping on the strip background appends to this pane.
7
+ */
8
+ import { useEffect, useState, type ReactNode } from 'react'
9
+ import clsx from 'clsx'
10
+ import {
11
+ IconCloseFill14, IconPlusOutline16, Menu,
12
+ } from '@deepseek-ai/dsh-client-ui-primitives'
13
+ import type { SidebarTab } from './state.ts'
14
+ import { t } from './locales.ts'
15
+ import css from './sidebar.module.css'
16
+
17
+ /** One + menu option. */
18
+ export interface NewTabOption {
19
+ id: string
20
+ label: string
21
+ disabled?: boolean
22
+ /** Leading icon (Menu row). */
23
+ icon?: ReactNode
24
+ }
25
+
26
+ /** Drag payload for tab moves (HTML5 DnD dataTransfer). */
27
+ export const TAB_DRAG_TYPE = 'application/x-dsh-tab'
28
+
29
+ export interface TabDragPayload {
30
+ tabId: string
31
+ paneId: string
32
+ }
33
+
34
+ export function serializeDrag(payload: TabDragPayload): string {
35
+ return JSON.stringify(payload)
36
+ }
37
+
38
+ export function parseDrag(raw: string): TabDragPayload | null {
39
+ try {
40
+ const parsed = JSON.parse(raw) as TabDragPayload
41
+ if (typeof parsed.tabId === 'string' && typeof parsed.paneId === 'string') return parsed
42
+ return null
43
+ } catch {
44
+ return null
45
+ }
46
+ }
47
+
48
+ /** Global tab-drag flag: PDF iframes become non-interactive synchronously. */
49
+ function setTabDragging(active: boolean): void {
50
+ if (active) document.body.setAttribute('data-dsh-tab-dragging', '')
51
+ else document.body.removeAttribute('data-dsh-tab-dragging')
52
+ }
53
+
54
+ export function TabBar(props: {
55
+ paneId: string
56
+ tabs: SidebarTab[]
57
+ active: string | null
58
+ onActivate: (tabId: string) => void
59
+ onClose: (tabId: string) => void
60
+ onNewTab: (optionId: string) => void
61
+ newTabOptions: NewTabOption[]
62
+ /** Drop of a tab from any pane: (payload, insertBeforeTabId | null). */
63
+ onDropTab: (payload: TabDragPayload, before: string | null) => void
64
+ /** Icon resolver for tab labels (reads from the tab descriptor registry). */
65
+ getTabIcon?: (tab: SidebarTab) => ReactNode
66
+ }) {
67
+ const {
68
+ paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon,
69
+ } = props
70
+ const [menuOpen, setMenuOpen] = useState(false)
71
+ const [dragOver, setDragOver] = useState(false)
72
+
73
+ useEffect(() => {
74
+ const clear = (): void => { setTabDragging(false); setDragOver(false) }
75
+ window.addEventListener('dragend', clear, true)
76
+ window.addEventListener('drop', clear, true)
77
+ window.addEventListener('blur', clear)
78
+ return () => {
79
+ window.removeEventListener('dragend', clear, true)
80
+ window.removeEventListener('drop', clear, true)
81
+ window.removeEventListener('blur', clear)
82
+ }
83
+ }, [])
84
+
85
+ return (
86
+ <div
87
+ className={clsx(css.tabBar, dragOver && css.tabBarDrop)}
88
+ onDragOver={(event) => {
89
+ // The strip owns drops on itself (merge into this pane); stopping
90
+ // propagation keeps the pane root from also running its edge-zone
91
+ // handler on the same drop.
92
+ event.preventDefault()
93
+ event.stopPropagation()
94
+ setDragOver(true)
95
+ }}
96
+ onDragLeave={() => { setDragOver(false) }}
97
+ onDrop={(event) => {
98
+ event.preventDefault()
99
+ event.stopPropagation()
100
+ setDragOver(false)
101
+ setTabDragging(false)
102
+ const raw = event.dataTransfer.getData(TAB_DRAG_TYPE)
103
+ const payload = parseDrag(raw)
104
+ if (payload !== null) onDropTab(payload, null)
105
+ }}
106
+ >
107
+ <div className={css.tabList}>
108
+ {tabs.map(tab => (
109
+ <div
110
+ key={tab.id}
111
+ className={clsx(css.tab, active === tab.id && css.tabActive)}
112
+ title={tab.title}
113
+ draggable
114
+ onDragStart={(event) => {
115
+ setTabDragging(true)
116
+ event.dataTransfer.setData(TAB_DRAG_TYPE, serializeDrag({ tabId: tab.id, paneId }))
117
+ event.dataTransfer.effectAllowed = 'move'
118
+ }}
119
+ onDragEnd={() => { setTabDragging(false); setDragOver(false) }}
120
+ onDragOver={(event) => { event.preventDefault(); event.stopPropagation() }}
121
+ onDrop={(event) => {
122
+ event.preventDefault()
123
+ event.stopPropagation()
124
+ setTabDragging(false)
125
+ const raw = event.dataTransfer.getData(TAB_DRAG_TYPE)
126
+ const payload = parseDrag(raw)
127
+ if (payload !== null) onDropTab(payload, tab.id)
128
+ }}
129
+ onClick={() => { onActivate(tab.id) }}
130
+ onAuxClick={(event) => {
131
+ // Middle-click closes the tab (and suppresses autoscroll).
132
+ if (event.button === 1) {
133
+ event.preventDefault()
134
+ onClose(tab.id)
135
+ }
136
+ }}
137
+ >
138
+ {getTabIcon?.(tab) ?? null}
139
+ <span className={css.tabTitle}>{tab.title}</span>
140
+ <button
141
+ type="button"
142
+ className={css.tabClose}
143
+ aria-label={t('close')}
144
+ onClick={(event) => {
145
+ event.stopPropagation()
146
+ onClose(tab.id)
147
+ }}
148
+ >
149
+ <IconCloseFill14 />
150
+ </button>
151
+ </div>
152
+ ))}
153
+ {/*
154
+ The + sits immediately after the rightmost tab (sticky at the
155
+ right edge of the scrollport when the tabs overflow, so it stays
156
+ reachable no matter how many tabs are open).
157
+ */}
158
+ <Menu
159
+ open={menuOpen}
160
+ onClose={() => { setMenuOpen(false) }}
161
+ items={newTabOptions.map(option => ({
162
+ id: option.id,
163
+ label: option.label,
164
+ ...(option.disabled === true ? { disabled: true } : {}),
165
+ ...(option.icon !== undefined ? { icon: option.icon } : {}),
166
+ }))}
167
+ onSelect={(id) => {
168
+ onNewTab(id)
169
+ setMenuOpen(false)
170
+ }}
171
+ portal
172
+ align="end"
173
+ anchor={(
174
+ <button
175
+ type="button"
176
+ className={css.tabBarPlus}
177
+ aria-label={t('newTab')}
178
+ title={t('newTab')}
179
+ onClick={() => { setMenuOpen(v => !v) }}
180
+ >
181
+ <IconPlusOutline16 />
182
+ </button>
183
+ )}
184
+ />
185
+ </div>
186
+ </div>
187
+ )
188
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * The interactive terminal: xterm.js over a WebSocket to the host pty.
3
+ * The host replays the session's transcript on connect, then streams live
4
+ * output; input frames are raw text, resize frames are JSON with
5
+ * type:"resize". Transient disconnects (page refresh, host restart) reconnect
6
+ * automatically; a server-side refusal (close code 1011 with a reason, e.g.
7
+ * a failed pty spawn) stops the loop and shows the reason with a manual
8
+ * retry, and repeated unreasoned failures surface the close code after three
9
+ * attempts, so the banner never spins forever.
10
+ *
11
+ * Two attach modes share one upgrade endpoint:
12
+ * - `tabId` starting with `agent:` is an agent-owned terminal (created by
13
+ * the `terminal_create` tool). The uuid is the suffix after `agent:`; the
14
+ * view connects with `?uuid=...`. A close frame kills the pty (the agent's
15
+ * terminal closes when the user closes the tab); a bare socket drop
16
+ * leaves the pty alive (the agent owns the lifetime).
17
+ * - Any other `tabId` is a UI-tab terminal (the user created it from the +
18
+ * menu). The view connects with `?tab=...&sessionId=...&cwd=...`. A close
19
+ * frame schedules a 0-ms close; a bare socket drop gets the host's
20
+ * reconnect grace.
21
+ */
22
+ import { useEffect, useRef, useState } from 'react'
23
+ import { Terminal, type ITheme } from 'xterm'
24
+ import { FitAddon } from '@xterm/addon-fit'
25
+ import 'xterm/css/xterm.css'
26
+ import { t } from './locales.ts'
27
+ import type { SessionScope } from './api.ts'
28
+ import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts'
29
+ import { isDarkScheme, subscribeColorScheme, tokenValue } from './theme.ts'
30
+ import css from './sidebar.module.css'
31
+
32
+ /** How many consecutive unreasoned failures before showing the error banner. */
33
+ const FAILURE_LIMIT = 3
34
+
35
+ /**
36
+ * Curated ANSI palettes for the terminal. The surface colors (background,
37
+ * foreground, cursor, selection) ride the theme tokens so the terminal
38
+ * blends with the panel in both schemes; the 16 ANSI colors are the same
39
+ * designed palettes the app's code surfaces use (one-dark family for dark,
40
+ * one-light family for light), read live so a scheme flip re-themes in
41
+ * place.
42
+ */
43
+ const ANSI_DARK: Record<string, string> = {
44
+ black: '#282c34', red: '#e06c75', green: '#98c379', yellow: '#e5c07b',
45
+ blue: '#61afef', magenta: '#c678dd', cyan: '#56b6c2', white: '#abb2bf',
46
+ brightBlack: '#5c6370', brightRed: '#e06c75', brightGreen: '#98c379',
47
+ brightYellow: '#e5c07b', brightBlue: '#61afef', brightMagenta: '#c678dd',
48
+ brightCyan: '#56b6c2', brightWhite: '#ffffff',
49
+ }
50
+
51
+ const ANSI_LIGHT: Record<string, string> = {
52
+ black: '#383a42', red: '#e45649', green: '#50a14f', yellow: '#c18401',
53
+ blue: '#0184bc', magenta: '#a626a4', cyan: '#0997b3', white: '#a0a1a7',
54
+ brightBlack: '#4f525e', brightRed: '#e45649', brightGreen: '#50a14f',
55
+ brightYellow: '#c18401', brightBlue: '#0184bc', brightMagenta: '#a626a4',
56
+ brightCyan: '#0997b3', brightWhite: '#fafafa',
57
+ }
58
+
59
+ /** The xterm theme for the current scheme (surface from tokens, ANSI curated). */
60
+ function xtermTheme(): ITheme {
61
+ const dark = isDarkScheme()
62
+ const background = tokenValue('--dsw-alias-bg-base') || (dark ? '#111114' : '#ffffff')
63
+ const foreground = tokenValue('--dsw-alias-label-primary') || (dark ? '#e6e6e6' : '#1a1a1a')
64
+ return {
65
+ background,
66
+ foreground,
67
+ cursor: foreground,
68
+ cursorAccent: background,
69
+ selectionBackground: dark ? 'rgba(255,255,255,0.22)' : 'rgba(0,0,0,0.12)',
70
+ ...(dark ? ANSI_DARK : ANSI_LIGHT),
71
+ }
72
+ }
73
+
74
+ export function TerminalView(props: { scope: SessionScope; tabId: string; store: SidebarStore }) {
75
+ const { scope, tabId, store } = props
76
+ const hostRef = useRef<HTMLDivElement>(null)
77
+ const [connected, setConnected] = useState(false)
78
+ const [fatal, setFatal] = useState<string | null>(null)
79
+ const [lastUrl, setLastUrl] = useState<string | null>(null)
80
+ const connectRef = useRef<(() => void) | null>(null)
81
+
82
+ useEffect(() => {
83
+ const host = hostRef.current
84
+ if (host === null) return
85
+ const term = new Terminal({
86
+ cursorBlink: true,
87
+ fontSize: 13,
88
+ fontFamily: tokenValue('--ds-font-family-code') || '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
89
+ allowTransparency: true,
90
+ convertEol: false,
91
+ scrollback: 4000,
92
+ theme: xtermTheme(),
93
+ })
94
+ const fit = new FitAddon()
95
+ term.loadAddon(fit)
96
+ term.open(host)
97
+ fit.fit()
98
+ // Re-theme in place when the app's scheme flips (tokens + palette).
99
+ const applyTheme = (): void => {
100
+ term.options.theme = xtermTheme()
101
+ term.refresh(0, term.rows - 1)
102
+ }
103
+ const schemeSub = subscribeColorScheme(applyTheme)
104
+
105
+ let socket: WebSocket | null = null
106
+ let closed = false
107
+ let retry: number | undefined
108
+ let failures = 0
109
+
110
+ const wsUrl = (): string => {
111
+ const url = new URL('/sidebar/ws/terminal', location.origin)
112
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
113
+ // Agent terminals attach by uuid (the host looks them up in the agent
114
+ // pty registry); UI-tab terminals attach by sessionId+tab (the host
115
+ // uses the UI-tab pty manager). Same upgrade endpoint, different query.
116
+ if (isAgentTabId(tabId)) {
117
+ url.search = new URLSearchParams({ uuid: agentUuidOf(tabId) }).toString()
118
+ } else {
119
+ const params = new URLSearchParams({ sessionId: scope.sessionId, tab: tabId })
120
+ if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
121
+ url.search = params.toString()
122
+ }
123
+ // Same construction the app's own downlink WebSockets use (new URL
124
+ // over location.origin + protocol swap): whatever the environment
125
+ // does to the app's websockets applies identically here.
126
+ return url.toString()
127
+ }
128
+
129
+ const sendResize = (): void => {
130
+ if (socket !== null && socket.readyState === WebSocket.OPEN) {
131
+ socket.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
132
+ }
133
+ }
134
+
135
+ const connect = (): void => {
136
+ if (closed) return
137
+ const url = wsUrl()
138
+ setLastUrl(url)
139
+ socket = new WebSocket(url)
140
+ socket.onopen = () => {
141
+ failures = 0
142
+ setConnected(true)
143
+ setFatal(null)
144
+ sendResize()
145
+ }
146
+ socket.onmessage = (event) => {
147
+ if (typeof event.data === 'string') term.write(event.data)
148
+ }
149
+ socket.onclose = (event) => {
150
+ setConnected(false)
151
+ // A server-side refusal carries a close code + reason; retrying it
152
+ // forever would only spin the banner, so surface it with a retry.
153
+ if (event.code === 1011 && event.reason !== '') {
154
+ setFatal(event.reason)
155
+ return
156
+ }
157
+ // Unreasoned drops (upgrade rejected, host down, mid-handshake
158
+ // refusal) normally recover on the next attempt; after a few
159
+ // consecutive failures stop spinning and show the close code.
160
+ failures += 1
161
+ if (failures >= FAILURE_LIMIT) {
162
+ const detail = event.reason !== '' ? ` (${event.code}: ${event.reason})` : ` (${event.code})`
163
+ console.error('[dsh-better-sidebar] terminal connection failed:', event.code, event.reason, url)
164
+ setFatal(`${t('terminalConnectFailed')}${detail}`)
165
+ return
166
+ }
167
+ if (!closed) retry = window.setTimeout(connect, 2000)
168
+ }
169
+ socket.onerror = () => {
170
+ socket?.close()
171
+ }
172
+ }
173
+ connectRef.current = connect
174
+
175
+ const inputSub = term.onData((data) => {
176
+ if (socket !== null && socket.readyState === WebSocket.OPEN) socket.send(data)
177
+ })
178
+ const observer = new ResizeObserver(() => {
179
+ try {
180
+ fit.fit()
181
+ sendResize()
182
+ } catch {
183
+ // The terminal may be mid-dispose; ignore.
184
+ }
185
+ })
186
+ observer.observe(host)
187
+
188
+ connect()
189
+ return () => {
190
+ closed = true
191
+ window.clearTimeout(retry)
192
+ observer.disconnect()
193
+ schemeSub()
194
+ inputSub.dispose()
195
+ // The close frame tells the host the owning tab is GONE (immediate
196
+ // pty release). A bare unmount — conversation switch, re-render,
197
+ // page unload — leaves the tab open, so the socket drop alone hands
198
+ // the process to the host's reconnect grace: switching back or
199
+ // refreshing reattaches the SAME shell instead of respawning one.
200
+ // (The host respawns on its own when the authoritative cwd changed.)
201
+ // Agent terminals follow the same rule: a close frame kills the pty
202
+ // (the user closed the sidebar tab); a bare socket drop leaves it
203
+ // alive (the agent owns the lifetime).
204
+ if (!store.tabOpen(scope.sessionId, tabId)
205
+ && socket !== null && socket.readyState === WebSocket.OPEN) {
206
+ socket.send(JSON.stringify({ type: 'close' }))
207
+ }
208
+ socket?.close()
209
+ term.dispose()
210
+ connectRef.current = null
211
+ }
212
+ }, [scope.sessionId, scope.cwd, tabId, store])
213
+
214
+ return (
215
+ <div className={css.terminalWrap}>
216
+ {fatal !== null && (
217
+ <div className={css.terminalBanner}>
218
+ {t('terminalError')}: {fatal}
219
+ {lastUrl !== null && <div className={css.terminalBannerUrl}>{lastUrl}</div>}
220
+ <button
221
+ type="button"
222
+ className={css.terminalRetry}
223
+ onClick={() => { setFatal(null); connectRef.current?.() }}
224
+ >
225
+ {t('terminalRetry')}
226
+ </button>
227
+ </div>
228
+ )}
229
+ {fatal === null && !connected && <div className={css.terminalBanner}>{t('disconnected')}</div>}
230
+ <div ref={hostRef} className={css.terminal} />
231
+ </div>
232
+ )
233
+ }