@keith9681/dsh-sidebar 0.15.2
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/LICENSE +21 -0
- package/README.md +0 -0
- package/README_EN.md +0 -0
- package/cordis.patch.yml +49 -0
- package/lib/client-editor.js +34651 -0
- package/lib/client-mermaid.js +200629 -0
- package/lib/client-registry.js +13388 -0
- package/lib/client-terminal.js +8904 -0
- package/lib/client.js +13388 -0
- package/lib/index.js +3959 -0
- package/lib/invariant.js +22 -0
- package/lib/types/agent-pty.d.ts +226 -0
- package/lib/types/browser-probe.d.ts +15 -0
- package/lib/types/bundle-route.d.ts +12 -0
- package/lib/types/client/BrowserView.d.ts +22 -0
- package/lib/types/client/DiffTab.d.ts +6 -0
- package/lib/types/client/DiffView.d.ts +51 -0
- package/lib/types/client/EditorHost.d.ts +12 -0
- package/lib/types/client/FileTree.d.ts +36 -0
- package/lib/types/client/GitView.d.ts +8 -0
- package/lib/types/client/OrphanedTab.d.ts +11 -0
- package/lib/types/client/PdfView.d.ts +6 -0
- package/lib/types/client/RenderBoundary.d.ts +32 -0
- package/lib/types/client/SandboxStatusBar.d.ts +10 -0
- package/lib/types/client/SideCardSection.d.ts +74 -0
- package/lib/types/client/SideChatView.d.ts +16 -0
- package/lib/types/client/Sidebar.d.ts +6 -0
- package/lib/types/client/SubagentView.d.ts +16 -0
- package/lib/types/client/TabBar.d.ts +43 -0
- package/lib/types/client/TerminalView.d.ts +24 -0
- package/lib/types/client/TextEditor.d.ts +10 -0
- package/lib/types/client/TreePanel.d.ts +23 -0
- package/lib/types/client/UploadOverlay.d.ts +20 -0
- package/lib/types/client/add-plugin-modal.d.ts +15 -0
- package/lib/types/client/api.d.ts +268 -0
- package/lib/types/client/binary-download.d.ts +12 -0
- package/lib/types/client/breakpoints.d.ts +12 -0
- package/lib/types/client/browser.d.ts +50 -0
- package/lib/types/client/builtins/index.d.ts +18 -0
- package/lib/types/client/builtins/tabs.d.ts +11 -0
- package/lib/types/client/builtins/viewers.d.ts +3 -0
- package/lib/types/client/chunk-loader.d.ts +124 -0
- package/lib/types/client/chunks/editor.d.ts +9 -0
- package/lib/types/client/chunks/mermaid.d.ts +10 -0
- package/lib/types/client/chunks/terminal.d.ts +9 -0
- package/lib/types/client/cm-themes.d.ts +24 -0
- package/lib/types/client/conversation-draft.d.ts +14 -0
- package/lib/types/client/desktop-env.d.ts +35 -0
- package/lib/types/client/editable-state.d.ts +27 -0
- package/lib/types/client/editor-load.d.ts +66 -0
- package/lib/types/client/frame-batcher.d.ts +24 -0
- package/lib/types/client/icons.d.ts +57 -0
- package/lib/types/client/image-types.d.ts +3 -0
- package/lib/types/client/ime-guard.d.ts +36 -0
- package/lib/types/client/index.d.ts +19 -0
- package/lib/types/client/intercept.d.ts +33 -0
- package/lib/types/client/lang.d.ts +12 -0
- package/lib/types/client/lazy-chunk.d.ts +25 -0
- package/lib/types/client/link-intercept.d.ts +42 -0
- package/lib/types/client/locales.d.ts +366 -0
- package/lib/types/client/mermaid-blocks.d.ts +41 -0
- package/lib/types/client/mermaid-sanitize.d.ts +2 -0
- package/lib/types/client/mermaid.d.ts +12 -0
- package/lib/types/client/open-when-sized.d.ts +20 -0
- package/lib/types/client/open-with-settings.d.ts +4 -0
- package/lib/types/client/open-with.d.ts +87 -0
- package/lib/types/client/openpath-intercept.d.ts +41 -0
- package/lib/types/client/paths.d.ts +35 -0
- package/lib/types/client/pdf-types.d.ts +2 -0
- package/lib/types/client/plugin-settings.d.ts +8 -0
- package/lib/types/client/plugins-shared.d.ts +27 -0
- package/lib/types/client/plugins-tabs.d.ts +3 -0
- package/lib/types/client/plugins-viewers.d.ts +3 -0
- package/lib/types/client/prefs.d.ts +41 -0
- package/lib/types/client/produced-files.d.ts +23 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +440 -0
- package/lib/types/client/settings-nav-icon.d.ts +19 -0
- package/lib/types/client/shell-presets.d.ts +48 -0
- package/lib/types/client/sidechat-transcript.d.ts +103 -0
- package/lib/types/client/split-pane.d.ts +30 -0
- package/lib/types/client/state.d.ts +359 -0
- package/lib/types/client/subagent-detect.d.ts +54 -0
- package/lib/types/client/subagent-jobs.d.ts +63 -0
- package/lib/types/client/tab-content-memo.d.ts +33 -0
- package/lib/types/client/terminal-font.d.ts +20 -0
- package/lib/types/client/theme.d.ts +45 -0
- package/lib/types/client/titlebar-strip.d.ts +23 -0
- package/lib/types/client/upload.d.ts +69 -0
- package/lib/types/client/wco.d.ts +47 -0
- package/lib/types/config.d.ts +64 -0
- package/lib/types/context-types.d.ts +603 -0
- package/lib/types/fs-operations.d.ts +28 -0
- package/lib/types/fs-search.d.ts +24 -0
- package/lib/types/fs-tree.d.ts +51 -0
- package/lib/types/git.d.ts +82 -0
- package/lib/types/html-route.d.ts +59 -0
- package/lib/types/index.d.ts +47 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/jobs-routes.d.ts +45 -0
- package/lib/types/open-external.d.ts +24 -0
- package/lib/types/prefs-shared.d.ts +225 -0
- package/lib/types/pty-deps.d.ts +78 -0
- package/lib/types/pty-manager.d.ts +168 -0
- package/lib/types/sidechat-core.d.ts +172 -0
- package/lib/types/sidechat-routes.d.ts +30 -0
- package/lib/types/subagent-activity.d.ts +44 -0
- package/lib/types/subagent-live-route.d.ts +44 -0
- package/lib/types/tools.d.ts +31 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +173 -0
- package/scripts/install.ps1 +289 -0
- package/scripts/install.sh +287 -0
- package/src/agent-pty.ts +527 -0
- package/src/browser-probe.ts +26 -0
- package/src/bundle-route.ts +129 -0
- package/src/client/BrowserView.tsx +253 -0
- package/src/client/DiffTab.tsx +110 -0
- package/src/client/DiffView.tsx +305 -0
- package/src/client/EditorHost.tsx +525 -0
- package/src/client/FileTree.tsx +653 -0
- package/src/client/GitView.tsx +558 -0
- package/src/client/OrphanedTab.tsx +27 -0
- package/src/client/PdfView.tsx +110 -0
- package/src/client/RenderBoundary.tsx +49 -0
- package/src/client/SandboxStatusBar.tsx +60 -0
- package/src/client/SideCardSection.module.css +960 -0
- package/src/client/SideCardSection.tsx +1152 -0
- package/src/client/SideChatView.module.css +437 -0
- package/src/client/SideChatView.tsx +644 -0
- package/src/client/Sidebar.tsx +1148 -0
- package/src/client/SubagentView.module.css +547 -0
- package/src/client/SubagentView.tsx +883 -0
- package/src/client/TabBar.tsx +305 -0
- package/src/client/TerminalView.tsx +383 -0
- package/src/client/TextEditor.tsx +500 -0
- package/src/client/TreePanel.tsx +273 -0
- package/src/client/UploadOverlay.tsx +62 -0
- package/src/client/add-plugin-modal.tsx +199 -0
- package/src/client/api.ts +335 -0
- package/src/client/binary-download.tsx +23 -0
- package/src/client/breakpoints.ts +51 -0
- package/src/client/browser.ts +119 -0
- package/src/client/builtins/index.ts +37 -0
- package/src/client/builtins/tabs.tsx +331 -0
- package/src/client/builtins/viewers.tsx +125 -0
- package/src/client/chunk-loader.ts +351 -0
- package/src/client/chunks/editor.tsx +9 -0
- package/src/client/chunks/mermaid.tsx +10 -0
- package/src/client/chunks/terminal.tsx +9 -0
- package/src/client/cm-themes.ts +129 -0
- package/src/client/conversation-draft.ts +29 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/desktop-env.ts +72 -0
- package/src/client/editable-state.ts +70 -0
- package/src/client/editor-load.ts +92 -0
- package/src/client/frame-batcher.ts +56 -0
- package/src/client/icons.tsx +161 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +369 -0
- package/src/client/intercept.tsx +110 -0
- package/src/client/lang.ts +103 -0
- package/src/client/layout.css +94 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +73 -0
- package/src/client/locales.ts +734 -0
- package/src/client/mermaid-blocks.ts +110 -0
- package/src/client/mermaid-sanitize.ts +93 -0
- package/src/client/mermaid.tsx +401 -0
- package/src/client/open-when-sized.ts +43 -0
- package/src/client/open-with-settings.tsx +135 -0
- package/src/client/open-with.ts +221 -0
- package/src/client/openpath-intercept.ts +62 -0
- package/src/client/paths.ts +47 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/plugin-settings.ts +43 -0
- package/src/client/plugins-shared.ts +29 -0
- package/src/client/plugins-tabs.ts +87 -0
- package/src/client/plugins-viewers.ts +30 -0
- package/src/client/prefs.ts +207 -0
- package/src/client/produced-files.ts +91 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +838 -0
- package/src/client/settings-nav-icon.ts +45 -0
- package/src/client/shell-presets.ts +83 -0
- package/src/client/sidebar.module.css +2577 -0
- package/src/client/sidechat-transcript.ts +321 -0
- package/src/client/split-pane.tsx +322 -0
- package/src/client/state.ts +1175 -0
- package/src/client/subagent-detect.ts +137 -0
- package/src/client/subagent-jobs.ts +161 -0
- package/src/client/tab-content-memo.ts +46 -0
- package/src/client/terminal-font.ts +28 -0
- package/src/client/theme.ts +110 -0
- package/src/client/titlebar-strip.ts +37 -0
- package/src/client/upload.ts +187 -0
- package/src/client/wco.ts +120 -0
- package/src/config.ts +155 -0
- package/src/context-types.ts +610 -0
- package/src/fs-operations.ts +99 -0
- package/src/fs-search.ts +86 -0
- package/src/fs-tree.ts +158 -0
- package/src/git.ts +244 -0
- package/src/html-route.ts +106 -0
- package/src/index.ts +1132 -0
- package/src/invariant.ts +32 -0
- package/src/jobs-routes.ts +257 -0
- package/src/open-external.ts +90 -0
- package/src/prefs-shared.ts +268 -0
- package/src/pty-deps.ts +240 -0
- package/src/pty-manager.ts +376 -0
- package/src/sidechat-core.ts +468 -0
- package/src/sidechat-routes.ts +344 -0
- package/src/subagent-activity.ts +91 -0
- package/src/subagent-live-route.ts +93 -0
- package/src/tools.ts +479 -0
- package/src/trust-fence.ts +77 -0
- package/src/wire.ts +100 -0
|
@@ -0,0 +1,305 @@
|
|
|
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. Right-clicking a
|
|
7
|
+
* tab opens the tab context menu (close / close others / close to the left /
|
|
8
|
+
* close to the right, all scoped to this pane).
|
|
9
|
+
*/
|
|
10
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
11
|
+
import clsx from 'clsx'
|
|
12
|
+
import {
|
|
13
|
+
IconCloseFill14, IconPlusOutline16, Menu,
|
|
14
|
+
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
15
|
+
import type { SidebarTab } from './state.ts'
|
|
16
|
+
import { t } from './locales.ts'
|
|
17
|
+
import css from './sidebar.module.css'
|
|
18
|
+
|
|
19
|
+
/** One + menu option. */
|
|
20
|
+
export interface NewTabOption {
|
|
21
|
+
id: string
|
|
22
|
+
label: string
|
|
23
|
+
disabled?: boolean
|
|
24
|
+
/** Leading icon (Menu row). */
|
|
25
|
+
icon?: ReactNode
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Drag payload for tab moves (HTML5 DnD dataTransfer). */
|
|
29
|
+
export const TAB_DRAG_TYPE = 'application/x-dsh-tab'
|
|
30
|
+
|
|
31
|
+
export interface TabDragPayload {
|
|
32
|
+
tabId: string
|
|
33
|
+
paneId: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function serializeDrag(payload: TabDragPayload): string {
|
|
37
|
+
return JSON.stringify(payload)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function parseDrag(raw: string): TabDragPayload | null {
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(raw) as TabDragPayload
|
|
43
|
+
if (typeof parsed.tabId === 'string' && typeof parsed.paneId === 'string') return parsed
|
|
44
|
+
return null
|
|
45
|
+
} catch {
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Global tab-drag flag: PDF iframes become non-interactive synchronously. */
|
|
51
|
+
function setTabDragging(active: boolean): void {
|
|
52
|
+
if (active) document.body.setAttribute('data-dsh-tab-dragging', '')
|
|
53
|
+
else document.body.removeAttribute('data-dsh-tab-dragging')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function TabBar(props: {
|
|
57
|
+
paneId: string
|
|
58
|
+
tabs: SidebarTab[]
|
|
59
|
+
active: string | null
|
|
60
|
+
onActivate: (tabId: string) => void
|
|
61
|
+
onClose: (tabId: string) => void
|
|
62
|
+
onNewTab: (optionId: string) => void
|
|
63
|
+
newTabOptions: NewTabOption[]
|
|
64
|
+
/** Drop of a tab from any pane: (payload, insertBeforeTabId | null). */
|
|
65
|
+
onDropTab: (payload: TabDragPayload, before: string | null) => void
|
|
66
|
+
/** Icon resolver for tab labels (reads from the tab descriptor registry). */
|
|
67
|
+
getTabIcon?: (tab: SidebarTab) => ReactNode
|
|
68
|
+
/** Badge resolver for tab labels (reads the descriptor's `badge`; the
|
|
69
|
+
* resolver returns the rendered pill or null). */
|
|
70
|
+
getTabBadge?: (tab: SidebarTab) => ReactNode
|
|
71
|
+
}) {
|
|
72
|
+
const {
|
|
73
|
+
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon, getTabBadge,
|
|
74
|
+
} = props
|
|
75
|
+
const [menuOpen, setMenuOpen] = useState(false)
|
|
76
|
+
// The tab right-click context menu: the target tab plus the cursor
|
|
77
|
+
// position (the portaled Menu anchors there, following GitView/FileTree).
|
|
78
|
+
const [tabMenu, setTabMenu] = useState<{ tabId: string; x: number; y: number } | null>(null)
|
|
79
|
+
const [dragOver, setDragOver] = useState(false)
|
|
80
|
+
const listRef = useRef<HTMLDivElement>(null)
|
|
81
|
+
// The context target's index in the render-time tab snapshot; -1 when the
|
|
82
|
+
// tab disappeared since the menu opened (the menu hides then).
|
|
83
|
+
const tabMenuIndex = tabMenu === null ? -1 : tabs.findIndex(tab => tab.id === tabMenu.tabId)
|
|
84
|
+
|
|
85
|
+
// Middle-click close: the press target is recorded on middle mousedown
|
|
86
|
+
// (preventDefaulted to disarm Chrome's middle-click autoscroll — its
|
|
87
|
+
// indicator is inert here because the strip hides its scrollbar and only
|
|
88
|
+
// the wheel handler scrolls) and the close settles on the first middle
|
|
89
|
+
// mouseup OVER that same tab. Release-position semantics match VS Code
|
|
90
|
+
// (microsoft/vscode#101028) and what users expect from Chrome tabs
|
|
91
|
+
// (crbug/40679924): pressing on a tab and releasing elsewhere cancels the
|
|
92
|
+
// close. The browser dispatches auxclick to the nearest common ancestor of
|
|
93
|
+
// the press/release targets when they differ, so any drift, autoscroll
|
|
94
|
+
// scroll, or tab-list reflow between press and release would otherwise
|
|
95
|
+
// swallow the close; settling on the recorded press target at mouseup
|
|
96
|
+
// keeps release semantics without depending on auxclick delivery.
|
|
97
|
+
const onCloseRef = useRef(onClose)
|
|
98
|
+
const middlePressed = useRef<{ id: string; node: HTMLElement } | null>(null)
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
onCloseRef.current = onClose
|
|
101
|
+
})
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
const onMouseUp = (event: MouseEvent): void => {
|
|
104
|
+
if (event.button !== 1) return
|
|
105
|
+
const pressed = middlePressed.current
|
|
106
|
+
middlePressed.current = null
|
|
107
|
+
// Close only when the release lands on the pressed tab; a drag-away
|
|
108
|
+
// release cancels the press (one-shot per press).
|
|
109
|
+
if (pressed !== null && pressed.node.isConnected && pressed.node.contains(event.target as Node)) {
|
|
110
|
+
onCloseRef.current(pressed.id)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
window.addEventListener('mouseup', onMouseUp)
|
|
114
|
+
return () => { window.removeEventListener('mouseup', onMouseUp) }
|
|
115
|
+
}, [])
|
|
116
|
+
|
|
117
|
+
// Wheel over the strip scrolls the tab row horizontally (a plain mouse
|
|
118
|
+
// wheel emits deltaY, which overflow-x alone never consumes). Bound as a
|
|
119
|
+
// native NON-passive listener: React registers onWheel passively at the
|
|
120
|
+
// root, where preventDefault() is a no-op. Modifier keys keep their native
|
|
121
|
+
// meaning (shift = horizontal scroll, ctrl/cmd = zoom), and a strip that
|
|
122
|
+
// does not overflow leaves the event alone so the page scrolls normally.
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
const el = listRef.current
|
|
125
|
+
if (el === null) return
|
|
126
|
+
const onWheel = (event: WheelEvent): void => {
|
|
127
|
+
if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return
|
|
128
|
+
if (el.scrollWidth <= el.clientWidth) return
|
|
129
|
+
event.preventDefault()
|
|
130
|
+
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? el.clientWidth : 1
|
|
131
|
+
el.scrollLeft += (event.deltaX + event.deltaY) * unit
|
|
132
|
+
}
|
|
133
|
+
el.addEventListener('wheel', onWheel, { passive: false })
|
|
134
|
+
return () => { el.removeEventListener('wheel', onWheel) }
|
|
135
|
+
}, [])
|
|
136
|
+
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
const clear = (): void => { setTabDragging(false); setDragOver(false) }
|
|
139
|
+
window.addEventListener('dragend', clear, true)
|
|
140
|
+
window.addEventListener('drop', clear, true)
|
|
141
|
+
window.addEventListener('blur', clear)
|
|
142
|
+
return () => {
|
|
143
|
+
window.removeEventListener('dragend', clear, true)
|
|
144
|
+
window.removeEventListener('drop', clear, true)
|
|
145
|
+
window.removeEventListener('blur', clear)
|
|
146
|
+
}
|
|
147
|
+
}, [])
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div
|
|
151
|
+
className={clsx(css.tabBar, dragOver && css.tabBarDrop)}
|
|
152
|
+
onDragOver={(event) => {
|
|
153
|
+
// The strip owns drops on itself (merge into this pane); stopping
|
|
154
|
+
// propagation keeps the pane root from also running its edge-zone
|
|
155
|
+
// handler on the same drop.
|
|
156
|
+
event.preventDefault()
|
|
157
|
+
event.stopPropagation()
|
|
158
|
+
setDragOver(true)
|
|
159
|
+
}}
|
|
160
|
+
onDragLeave={() => { setDragOver(false) }}
|
|
161
|
+
onDrop={(event) => {
|
|
162
|
+
event.preventDefault()
|
|
163
|
+
event.stopPropagation()
|
|
164
|
+
setDragOver(false)
|
|
165
|
+
setTabDragging(false)
|
|
166
|
+
const raw = event.dataTransfer.getData(TAB_DRAG_TYPE)
|
|
167
|
+
const payload = parseDrag(raw)
|
|
168
|
+
if (payload !== null) onDropTab(payload, null)
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
<div ref={listRef} className={css.tabList}>
|
|
172
|
+
{tabs.map(tab => (
|
|
173
|
+
<div
|
|
174
|
+
key={tab.id}
|
|
175
|
+
className={clsx(css.tab, active === tab.id && css.tabActive)}
|
|
176
|
+
title={tab.title}
|
|
177
|
+
draggable
|
|
178
|
+
onDragStart={(event) => {
|
|
179
|
+
setTabDragging(true)
|
|
180
|
+
event.dataTransfer.setData(TAB_DRAG_TYPE, serializeDrag({ tabId: tab.id, paneId }))
|
|
181
|
+
event.dataTransfer.effectAllowed = 'move'
|
|
182
|
+
}}
|
|
183
|
+
onDragEnd={() => { setTabDragging(false); setDragOver(false) }}
|
|
184
|
+
onDragOver={(event) => { event.preventDefault(); event.stopPropagation() }}
|
|
185
|
+
onDrop={(event) => {
|
|
186
|
+
event.preventDefault()
|
|
187
|
+
event.stopPropagation()
|
|
188
|
+
setTabDragging(false)
|
|
189
|
+
const raw = event.dataTransfer.getData(TAB_DRAG_TYPE)
|
|
190
|
+
const payload = parseDrag(raw)
|
|
191
|
+
if (payload !== null) onDropTab(payload, tab.id)
|
|
192
|
+
}}
|
|
193
|
+
onClick={() => { onActivate(tab.id) }}
|
|
194
|
+
onMouseDown={(event) => {
|
|
195
|
+
// Middle-click close: record the press target and disarm
|
|
196
|
+
// Chrome's middle-click autoscroll (its indicator is inert
|
|
197
|
+
// here — the strip scrolls via the wheel handler only). The
|
|
198
|
+
// close itself settles on the first middle mouseup over this
|
|
199
|
+
// same tab (window-level), keeping release semantics.
|
|
200
|
+
if (event.button === 1) {
|
|
201
|
+
event.preventDefault()
|
|
202
|
+
middlePressed.current = { id: tab.id, node: event.currentTarget }
|
|
203
|
+
}
|
|
204
|
+
}}
|
|
205
|
+
onContextMenu={(event) => {
|
|
206
|
+
// Take over the browser menu: the tab context menu offers the
|
|
207
|
+
// close operations for this pane. Opening it also dismisses
|
|
208
|
+
// the + menu (only one menu at a time).
|
|
209
|
+
event.preventDefault()
|
|
210
|
+
setMenuOpen(false)
|
|
211
|
+
setTabMenu({ tabId: tab.id, x: event.clientX, y: event.clientY })
|
|
212
|
+
}}
|
|
213
|
+
>
|
|
214
|
+
{getTabIcon?.(tab) ?? null}
|
|
215
|
+
{getTabBadge?.(tab) ?? null}
|
|
216
|
+
<span className={css.tabTitle}>{tab.title}</span>
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
className={css.tabClose}
|
|
220
|
+
aria-label={t('close')}
|
|
221
|
+
onClick={(event) => {
|
|
222
|
+
event.stopPropagation()
|
|
223
|
+
onClose(tab.id)
|
|
224
|
+
}}
|
|
225
|
+
>
|
|
226
|
+
<IconCloseFill14 />
|
|
227
|
+
</button>
|
|
228
|
+
</div>
|
|
229
|
+
))}
|
|
230
|
+
{/*
|
|
231
|
+
The + sits immediately after the rightmost tab (sticky at the
|
|
232
|
+
right edge of the scrollport when the tabs overflow, so it stays
|
|
233
|
+
reachable no matter how many tabs are open).
|
|
234
|
+
*/}
|
|
235
|
+
<Menu
|
|
236
|
+
open={menuOpen}
|
|
237
|
+
onClose={() => { setMenuOpen(false) }}
|
|
238
|
+
items={newTabOptions.map(option => ({
|
|
239
|
+
id: option.id,
|
|
240
|
+
label: option.label,
|
|
241
|
+
...(option.disabled === true ? { disabled: true } : {}),
|
|
242
|
+
...(option.icon !== undefined ? { icon: option.icon } : {}),
|
|
243
|
+
}))}
|
|
244
|
+
onSelect={(id) => {
|
|
245
|
+
onNewTab(id)
|
|
246
|
+
setMenuOpen(false)
|
|
247
|
+
}}
|
|
248
|
+
portal
|
|
249
|
+
align="end"
|
|
250
|
+
anchor={(
|
|
251
|
+
<button
|
|
252
|
+
type="button"
|
|
253
|
+
className={css.tabBarPlus}
|
|
254
|
+
aria-label={t('newTab')}
|
|
255
|
+
title={t('newTab')}
|
|
256
|
+
onClick={() => { setMenuOpen(v => !v); setTabMenu(null) }}
|
|
257
|
+
>
|
|
258
|
+
<IconPlusOutline16 />
|
|
259
|
+
</button>
|
|
260
|
+
)}
|
|
261
|
+
/>
|
|
262
|
+
{/*
|
|
263
|
+
The tab context menu, positioned at the right-click cursor (portal
|
|
264
|
+
so the panel's overflow clip cannot crop it). Close operations are
|
|
265
|
+
scoped to THIS pane: "close others/left/right" walk the render-time
|
|
266
|
+
tab snapshot and reuse the per-tab onClose path (which routes
|
|
267
|
+
through the service and releases terminals), so the target tab is
|
|
268
|
+
never closed and the pane never empties mid-loop.
|
|
269
|
+
*/}
|
|
270
|
+
<Menu
|
|
271
|
+
open={tabMenu !== null && tabMenuIndex >= 0}
|
|
272
|
+
onClose={() => { setTabMenu(null) }}
|
|
273
|
+
items={[
|
|
274
|
+
{ id: 'close', label: t('close') },
|
|
275
|
+
{ id: 'closeOthers', label: t('closeOtherTabs'), ...(tabs.length <= 1 ? { disabled: true } : {}) },
|
|
276
|
+
{ id: 'closeLeft', label: t('closeLeftTabs'), ...(tabMenuIndex <= 0 ? { disabled: true } : {}) },
|
|
277
|
+
{ id: 'closeRight', label: t('closeRightTabs'), ...(tabMenuIndex >= tabs.length - 1 ? { disabled: true } : {}) },
|
|
278
|
+
]}
|
|
279
|
+
onSelect={(id) => {
|
|
280
|
+
const target = tabMenu
|
|
281
|
+
if (target === null) return
|
|
282
|
+
setTabMenu(null)
|
|
283
|
+
const index = tabs.findIndex(tab => tab.id === target.tabId)
|
|
284
|
+
if (index < 0) return
|
|
285
|
+
if (id === 'close') {
|
|
286
|
+
onClose(target.tabId)
|
|
287
|
+
} else if (id === 'closeOthers') {
|
|
288
|
+
for (const tab of tabs) {
|
|
289
|
+
if (tab.id !== target.tabId) onClose(tab.id)
|
|
290
|
+
}
|
|
291
|
+
} else if (id === 'closeLeft') {
|
|
292
|
+
for (const tab of tabs.slice(0, index)) onClose(tab.id)
|
|
293
|
+
} else if (id === 'closeRight') {
|
|
294
|
+
for (const tab of tabs.slice(index + 1)) onClose(tab.id)
|
|
295
|
+
}
|
|
296
|
+
}}
|
|
297
|
+
portal
|
|
298
|
+
align="start"
|
|
299
|
+
getAnchorRect={() => (tabMenu === null ? null : new DOMRect(tabMenu.x, tabMenu.y, 0, 0))}
|
|
300
|
+
anchor={<span />}
|
|
301
|
+
/>
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
)
|
|
305
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
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
|
+
* Three control frames shape the pty lifecycle on unmount:
|
|
12
|
+
* - `{type:'close'}` — the user closed the tab. The host kills the pty
|
|
13
|
+
* immediately (quota released).
|
|
14
|
+
* - `{type:'park'}` — the user switched to another conversation. The tab is
|
|
15
|
+
* still open in its session's persisted state but its view unmounted; the
|
|
16
|
+
* host keeps the pty alive indefinitely (no grace countdown), so switching
|
|
17
|
+
* back reattaches the same shell instead of respawning one.
|
|
18
|
+
* - bare socket drop (no frame) — page refresh, crash, plugin teardown, or a
|
|
19
|
+
* same-session re-render. The host's reconnect grace keeps the shell alive
|
|
20
|
+
* for a quick reconnect.
|
|
21
|
+
*
|
|
22
|
+
* Two attach modes share one upgrade endpoint:
|
|
23
|
+
* - `tabId` starting with `agent:` is an agent-owned terminal (created by
|
|
24
|
+
* the `terminal_create` tool). The uuid is the suffix after `agent:`; the
|
|
25
|
+
* view connects with `?uuid=...`. A close frame kills the pty (the agent's
|
|
26
|
+
* terminal closes when the user closes the tab); a bare socket drop
|
|
27
|
+
* leaves the pty alive (the agent owns the lifetime) — agent terminals
|
|
28
|
+
* never send park (their lifetime is already indefinite on bare drop).
|
|
29
|
+
* - Any other `tabId` is a UI-tab terminal (the user created it from the +
|
|
30
|
+
* menu). The view connects with `?tab=...&sessionId=...&cwd=...`. A close
|
|
31
|
+
* frame schedules a 0-ms close; a park frame marks the pty as parked; a
|
|
32
|
+
* bare socket drop gets the host's reconnect grace.
|
|
33
|
+
*/
|
|
34
|
+
import { useEffect, useRef, useState } from 'react'
|
|
35
|
+
import { Terminal, type ITheme } from '@xterm/xterm'
|
|
36
|
+
import { FitAddon } from '@xterm/addon-fit'
|
|
37
|
+
import { writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
38
|
+
import '@xterm/xterm/css/xterm.css'
|
|
39
|
+
import { t } from './locales.ts'
|
|
40
|
+
import { openWhenSized } from './open-when-sized.ts'
|
|
41
|
+
import { api, type SessionScope, type TerminalDepsStatus } from './api.ts'
|
|
42
|
+
import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts'
|
|
43
|
+
import { isDarkScheme, subscribeColorScheme, effectiveTokenValue, tokenValue } from './theme.ts'
|
|
44
|
+
import { resolveTerminalFont } from './terminal-font.ts'
|
|
45
|
+
import css from './sidebar.module.css'
|
|
46
|
+
|
|
47
|
+
/** How many consecutive unreasoned failures before showing the error banner. */
|
|
48
|
+
const FAILURE_LIMIT = 3
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The WS close-code-1011 reason the host sends when node-pty is unavailable
|
|
52
|
+
* (mirror of the host's PTY_DEPS_MISSING; the value is a wire contract, so
|
|
53
|
+
* the two sides keep the literal in lockstep). The view then fetches the
|
|
54
|
+
* full repair details from /sidebar/api/terminal.deps.
|
|
55
|
+
*/
|
|
56
|
+
const PTY_DEPS_MISSING = 'pty-deps-missing'
|
|
57
|
+
|
|
58
|
+
/** The degraded-mode payload rendered by {@link TerminalDepsBanner}. */
|
|
59
|
+
type TerminalDepsInfo = Extract<TerminalDepsStatus, { ok: false }>
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Curated ANSI palettes for the terminal. The surface colors (background,
|
|
63
|
+
* foreground, cursor, selection) ride the theme tokens so the terminal
|
|
64
|
+
* blends with the panel in both schemes; the 16 ANSI colors are the same
|
|
65
|
+
* designed palettes the app's code surfaces use (one-dark family for dark,
|
|
66
|
+
* one-light family for light), read live so a scheme flip re-themes in
|
|
67
|
+
* place.
|
|
68
|
+
*/
|
|
69
|
+
const ANSI_DARK: Record<string, string> = {
|
|
70
|
+
black: '#282c34', red: '#e06c75', green: '#98c379', yellow: '#e5c07b',
|
|
71
|
+
blue: '#61afef', magenta: '#c678dd', cyan: '#56b6c2', white: '#abb2bf',
|
|
72
|
+
brightBlack: '#5c6370', brightRed: '#e06c75', brightGreen: '#98c379',
|
|
73
|
+
brightYellow: '#e5c07b', brightBlue: '#61afef', brightMagenta: '#c678dd',
|
|
74
|
+
brightCyan: '#56b6c2', brightWhite: '#ffffff',
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const ANSI_LIGHT: Record<string, string> = {
|
|
78
|
+
black: '#383a42', red: '#e45649', green: '#50a14f', yellow: '#c18401',
|
|
79
|
+
blue: '#0184bc', magenta: '#a626a4', cyan: '#0997b3', white: '#a0a1a7',
|
|
80
|
+
brightBlack: '#4f525e', brightRed: '#e45649', brightGreen: '#50a14f',
|
|
81
|
+
brightYellow: '#c18401', brightBlue: '#0184bc', brightMagenta: '#a626a4',
|
|
82
|
+
brightCyan: '#0997b3', brightWhite: '#fafafa',
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The xterm theme for the current scheme (surface from tokens, ANSI curated). */
|
|
86
|
+
function xtermTheme(): ITheme {
|
|
87
|
+
const dark = isDarkScheme()
|
|
88
|
+
// Skin systems set --dsw-alias-bg-base to `transparent` or translucent
|
|
89
|
+
// glass values (the dsh-web-ui skins use rgba 0.16–0.7); effectiveTokenValue
|
|
90
|
+
// treats those as unset below the opacity floor, so the opaque fallback
|
|
91
|
+
// engages and the terminal never renders see-through over the skin's
|
|
92
|
+
// backdrop (issue #90). Effectively opaque scoped surfaces (e.g. a skin's
|
|
93
|
+
// 0.96 porcelain) pass through — the skin still controls the terminal.
|
|
94
|
+
const background = effectiveTokenValue('--dsw-alias-bg-base') || (dark ? '#111114' : '#ffffff')
|
|
95
|
+
const foreground = effectiveTokenValue('--dsw-alias-label-primary') || (dark ? '#e6e6e6' : '#1a1a1a')
|
|
96
|
+
return {
|
|
97
|
+
background,
|
|
98
|
+
foreground,
|
|
99
|
+
cursor: foreground,
|
|
100
|
+
cursorAccent: background,
|
|
101
|
+
selectionBackground: dark ? 'rgba(255,255,255,0.22)' : 'rgba(0,0,0,0.12)',
|
|
102
|
+
...(dark ? ANSI_DARK : ANSI_LIGHT),
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function TerminalView(props: { scope: SessionScope; tabId: string; store: SidebarStore }) {
|
|
107
|
+
const { scope, tabId, store } = props
|
|
108
|
+
const hostRef = useRef<HTMLDivElement>(null)
|
|
109
|
+
const [connected, setConnected] = useState(false)
|
|
110
|
+
const [fatal, setFatal] = useState<string | null>(null)
|
|
111
|
+
const [depsFatal, setDepsFatal] = useState<TerminalDepsInfo | null>(null)
|
|
112
|
+
const [lastUrl, setLastUrl] = useState<string | null>(null)
|
|
113
|
+
const connectRef = useRef<(() => void) | null>(null)
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const host = hostRef.current
|
|
117
|
+
if (host === null) return
|
|
118
|
+
// The custom font prefs (side card settings, terminal card) resolve at
|
|
119
|
+
// mount; store changes re-apply them live below.
|
|
120
|
+
const font = resolveTerminalFont(store.getPrefs(), tokenValue('--ds-font-family-code'))
|
|
121
|
+
const term = new Terminal({
|
|
122
|
+
cursorBlink: true,
|
|
123
|
+
fontSize: font.fontSize,
|
|
124
|
+
fontFamily: font.fontFamily,
|
|
125
|
+
allowTransparency: true,
|
|
126
|
+
convertEol: false,
|
|
127
|
+
scrollback: 4000,
|
|
128
|
+
theme: xtermTheme(),
|
|
129
|
+
})
|
|
130
|
+
const fit = new FitAddon()
|
|
131
|
+
term.loadAddon(fit)
|
|
132
|
+
// Re-theme in place when the app's scheme flips (tokens + palette).
|
|
133
|
+
const applyTheme = (): void => {
|
|
134
|
+
term.options.theme = xtermTheme()
|
|
135
|
+
term.refresh(0, term.rows - 1)
|
|
136
|
+
}
|
|
137
|
+
const schemeSub = subscribeColorScheme(applyTheme)
|
|
138
|
+
|
|
139
|
+
let socket: WebSocket | null = null
|
|
140
|
+
let closed = false
|
|
141
|
+
let retry: number | undefined
|
|
142
|
+
let failures = 0
|
|
143
|
+
|
|
144
|
+
const wsUrl = (): string => {
|
|
145
|
+
const url = new URL('/sidebar/ws/terminal', location.origin)
|
|
146
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
147
|
+
// Agent terminals attach by uuid (the host looks them up in the agent
|
|
148
|
+
// pty registry); UI-tab terminals attach by sessionId+tab (the host
|
|
149
|
+
// uses the UI-tab pty manager). Same upgrade endpoint, different query.
|
|
150
|
+
if (isAgentTabId(tabId)) {
|
|
151
|
+
url.search = new URLSearchParams({ uuid: agentUuidOf(tabId) }).toString()
|
|
152
|
+
} else {
|
|
153
|
+
const params = new URLSearchParams({ sessionId: scope.sessionId, tab: tabId })
|
|
154
|
+
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
|
|
155
|
+
url.search = params.toString()
|
|
156
|
+
}
|
|
157
|
+
// Same construction the app's own downlink WebSockets use (new URL
|
|
158
|
+
// over location.origin + protocol swap): whatever the environment
|
|
159
|
+
// does to the app's websockets applies identically here.
|
|
160
|
+
return url.toString()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const sendResize = (): void => {
|
|
164
|
+
if (socket !== null && socket.readyState === WebSocket.OPEN) {
|
|
165
|
+
socket.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const connect = (): void => {
|
|
170
|
+
if (closed) return
|
|
171
|
+
const url = wsUrl()
|
|
172
|
+
setLastUrl(url)
|
|
173
|
+
socket = new WebSocket(url)
|
|
174
|
+
socket.onopen = () => {
|
|
175
|
+
failures = 0
|
|
176
|
+
setConnected(true)
|
|
177
|
+
setFatal(null)
|
|
178
|
+
sendResize()
|
|
179
|
+
}
|
|
180
|
+
socket.onmessage = (event) => {
|
|
181
|
+
if (typeof event.data === 'string') term.write(event.data)
|
|
182
|
+
}
|
|
183
|
+
socket.onclose = (event) => {
|
|
184
|
+
setConnected(false)
|
|
185
|
+
// node-pty dependency missing/broken (issue #140): the host closed
|
|
186
|
+
// with the short marker. Fetch the full repair details over HTTP —
|
|
187
|
+
// a WS close reason is capped at 123 bytes, too small for the
|
|
188
|
+
// pasteable command. A failed fetch falls back to the plain banner.
|
|
189
|
+
if (event.code === 1011 && event.reason === PTY_DEPS_MISSING) {
|
|
190
|
+
void api.terminalDeps().then((status) => {
|
|
191
|
+
if (status.ok) {
|
|
192
|
+
// The host recovered between the close and the fetch — the
|
|
193
|
+
// plain banner with a retry is the honest state.
|
|
194
|
+
setFatal(t('terminalDepsFailed'))
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
setFatal(null)
|
|
198
|
+
setDepsFatal(status)
|
|
199
|
+
}).catch(() => {
|
|
200
|
+
setFatal(t('terminalDepsFailed'))
|
|
201
|
+
})
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
// A server-side refusal carries a close code + reason; retrying it
|
|
205
|
+
// forever would only spin the banner, so surface it with a retry.
|
|
206
|
+
if (event.code === 1011 && event.reason !== '') {
|
|
207
|
+
setFatal(event.reason)
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
// Unreasoned drops (upgrade rejected, host down, mid-handshake
|
|
211
|
+
// refusal) normally recover on the next attempt; after a few
|
|
212
|
+
// consecutive failures stop spinning and show the close code.
|
|
213
|
+
failures += 1
|
|
214
|
+
if (failures >= FAILURE_LIMIT) {
|
|
215
|
+
const detail = event.reason !== '' ? ` (${event.code}: ${event.reason})` : ` (${event.code})`
|
|
216
|
+
console.error('[dsh-better-sidebar] terminal connection failed:', event.code, event.reason, url)
|
|
217
|
+
setFatal(`${t('terminalConnectFailed')}${detail}`)
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
if (!closed) retry = window.setTimeout(connect, 2000)
|
|
221
|
+
}
|
|
222
|
+
socket.onerror = () => {
|
|
223
|
+
socket?.close()
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
connectRef.current = connect
|
|
227
|
+
|
|
228
|
+
const inputSub = term.onData((data) => {
|
|
229
|
+
if (socket !== null && socket.readyState === WebSocket.OPEN) socket.send(data)
|
|
230
|
+
})
|
|
231
|
+
const observer = new ResizeObserver(() => {
|
|
232
|
+
try {
|
|
233
|
+
fit.fit()
|
|
234
|
+
sendResize()
|
|
235
|
+
} catch {
|
|
236
|
+
// The terminal may be mid-dispose; ignore.
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
observer.observe(host)
|
|
240
|
+
|
|
241
|
+
// Custom font prefs (the terminal card's secondary settings) apply LIVE:
|
|
242
|
+
// on any store change re-resolve and diff the two options, re-fitting
|
|
243
|
+
// when they moved (the grid dimensions may change with the font). The
|
|
244
|
+
// subscribe fires on every store change (tabs, panels…), so the diff is
|
|
245
|
+
// what keeps this cheap.
|
|
246
|
+
const fontSub = store.subscribe(() => {
|
|
247
|
+
const next = resolveTerminalFont(store.getPrefs(), tokenValue('--ds-font-family-code'))
|
|
248
|
+
if (next.fontFamily !== term.options.fontFamily || next.fontSize !== term.options.fontSize) {
|
|
249
|
+
term.options.fontFamily = next.fontFamily
|
|
250
|
+
term.options.fontSize = next.fontSize
|
|
251
|
+
try {
|
|
252
|
+
fit.fit()
|
|
253
|
+
sendResize()
|
|
254
|
+
} catch {
|
|
255
|
+
// The terminal may be mid-dispose; ignore.
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
// The terminal must not be opened in a zero-size container: xterm's
|
|
261
|
+
// renderer creation fails there and the next Viewport refresh crashes
|
|
262
|
+
// reading `.dimensions` off the undefined renderer (blank terminal on
|
|
263
|
+
// WKWebView when the bottom panel's expand slide leaves the host at
|
|
264
|
+
// height 0; any display:none-hidden ancestor does the same). Defer
|
|
265
|
+
// open+fit until the host has a real size — writes arriving meanwhile
|
|
266
|
+
// are buffered by xterm's WriteBuffer and render once open, and
|
|
267
|
+
// FitAddon.fit() is a safe no-op before open. sendResize() here covers
|
|
268
|
+
// the deferred path where the socket may already be open with the
|
|
269
|
+
// default 80x24 dims.
|
|
270
|
+
const cancelOpen = openWhenSized(host, () => {
|
|
271
|
+
try {
|
|
272
|
+
term.open(host)
|
|
273
|
+
fit.fit()
|
|
274
|
+
sendResize()
|
|
275
|
+
} catch (error) {
|
|
276
|
+
console.error('[dsh-better-sidebar] xterm open failed:', error)
|
|
277
|
+
}
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
connect()
|
|
281
|
+
return () => {
|
|
282
|
+
closed = true
|
|
283
|
+
cancelOpen()
|
|
284
|
+
window.clearTimeout(retry)
|
|
285
|
+
observer.disconnect()
|
|
286
|
+
fontSub()
|
|
287
|
+
schemeSub()
|
|
288
|
+
inputSub.dispose()
|
|
289
|
+
// Three unmount cases, distinguished by the store's tab/open state and
|
|
290
|
+
// the active session id:
|
|
291
|
+
// 1. The tab was closed by the user (NOT in its session's state): send
|
|
292
|
+
// `{type:'close'}` — the host releases the pty immediately.
|
|
293
|
+
// 2. The user switched to another conversation (the tab IS still open
|
|
294
|
+
// in scope.sessionId's state, but the active session is now a
|
|
295
|
+
// different one): send `{type:'park'}` — the host keeps the pty
|
|
296
|
+
// alive indefinitely (no grace countdown), so switching back
|
|
297
|
+
// reattaches the SAME shell. Without this, the bare socket drop
|
|
298
|
+
// would start the 30s reconnect-grace countdown and kill the shell
|
|
299
|
+
// while the user is still actively working in the other session.
|
|
300
|
+
// 3. A same-session unmount (page refresh, crash, plugin teardown, a
|
|
301
|
+
// re-render that re-mounts the view): bare socket drop — the host's
|
|
302
|
+
// reconnect grace keeps the shell alive for a quick reconnect.
|
|
303
|
+
// Agent terminals follow the close-frame rule; their lifetime is owned
|
|
304
|
+
// by the agent, so a bare drop (case 3) already leaves them alive
|
|
305
|
+
// indefinitely — no park frame needed.
|
|
306
|
+
const tabStillOpen = store.tabOpen(scope.sessionId, tabId)
|
|
307
|
+
const sessionSwitched = store.getSnapshot().sessionId !== scope.sessionId
|
|
308
|
+
if (!tabStillOpen
|
|
309
|
+
&& socket !== null && socket.readyState === WebSocket.OPEN) {
|
|
310
|
+
socket.send(JSON.stringify({ type: 'close' }))
|
|
311
|
+
} else if (tabStillOpen && sessionSwitched && !isAgentTabId(tabId)
|
|
312
|
+
&& socket !== null && socket.readyState === WebSocket.OPEN) {
|
|
313
|
+
socket.send(JSON.stringify({ type: 'park' }))
|
|
314
|
+
}
|
|
315
|
+
socket?.close()
|
|
316
|
+
term.dispose()
|
|
317
|
+
connectRef.current = null
|
|
318
|
+
}
|
|
319
|
+
}, [scope.sessionId, scope.cwd, tabId, store])
|
|
320
|
+
|
|
321
|
+
return (
|
|
322
|
+
<div className={css.terminalWrap}>
|
|
323
|
+
{depsFatal !== null && (
|
|
324
|
+
<TerminalDepsBanner deps={depsFatal} onRetry={() => { setDepsFatal(null); connectRef.current?.() }} />
|
|
325
|
+
)}
|
|
326
|
+
{fatal !== null && (
|
|
327
|
+
<div className={css.terminalBanner}>
|
|
328
|
+
{t('terminalError')}: {fatal}
|
|
329
|
+
{lastUrl !== null && <div className={css.terminalBannerUrl}>{lastUrl}</div>}
|
|
330
|
+
<button
|
|
331
|
+
type="button"
|
|
332
|
+
className={css.terminalRetry}
|
|
333
|
+
onClick={() => { setFatal(null); connectRef.current?.() }}
|
|
334
|
+
>
|
|
335
|
+
{t('terminalRetry')}
|
|
336
|
+
</button>
|
|
337
|
+
</div>
|
|
338
|
+
)}
|
|
339
|
+
{fatal === null && depsFatal === null && !connected && <div className={css.terminalBanner}>{t('disconnected')}</div>}
|
|
340
|
+
<div ref={hostRef} className={css.terminal} />
|
|
341
|
+
</div>
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The node-pty dependency failure banner (issue #140): explains that the
|
|
347
|
+
* terminal's native dependency failed to load and shows the PASTEABLE repair
|
|
348
|
+
* command (bash / cmd / PowerShell) with a copy button — the user pastes it
|
|
349
|
+
* into a terminal where their DSH profile lives and runs it, then retries.
|
|
350
|
+
* Extracted as a standalone component for direct testing.
|
|
351
|
+
*/
|
|
352
|
+
export function TerminalDepsBanner(props: { deps: TerminalDepsInfo; onRetry: () => void }) {
|
|
353
|
+
const { deps, onRetry } = props
|
|
354
|
+
const [copied, setCopied] = useState(false)
|
|
355
|
+
const copy = async (): Promise<void> => {
|
|
356
|
+
const written = await writeClipboard(deps.command)
|
|
357
|
+
if (written) {
|
|
358
|
+
setCopied(true)
|
|
359
|
+
window.setTimeout(() => setCopied(false), 2000)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return (
|
|
363
|
+
<div className={css.terminalDepsBanner}>
|
|
364
|
+
<div className={css.terminalDepsTitle}>{t('terminalDepsFailed')}</div>
|
|
365
|
+
<div className={css.terminalDepsHint}>
|
|
366
|
+
{t('terminalDepsHint')}
|
|
367
|
+
{deps.profile !== null ? t('terminalDepsProfile', { profile: deps.profile }) : ''}
|
|
368
|
+
</div>
|
|
369
|
+
<div className={css.terminalDepsCommandRow}>
|
|
370
|
+
<pre className={css.terminalRepairCommand}>{deps.command}</pre>
|
|
371
|
+
<button type="button" className={css.terminalRetry} onClick={() => { void copy() }} aria-label={t('copy')}>
|
|
372
|
+
{copied ? t('copied') : t('copy')}
|
|
373
|
+
</button>
|
|
374
|
+
</div>
|
|
375
|
+
{deps.note !== undefined && <div className={css.terminalDepsNote}>{deps.note}</div>}
|
|
376
|
+
<div className={css.terminalDepsActions}>
|
|
377
|
+
<button type="button" className={css.terminalRetry} onClick={onRetry}>
|
|
378
|
+
{t('terminalRetry')}
|
|
379
|
+
</button>
|
|
380
|
+
</div>
|
|
381
|
+
</div>
|
|
382
|
+
)
|
|
383
|
+
}
|