dsh-coding-sidebar 1.0.7 → 1.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/lib/client-editor.js +485 -228
- package/lib/client-registry.js +1238 -7583
- package/lib/client-terminal.js +345 -189
- package/lib/client.js +1213 -7558
- package/lib/index.js +518 -69
- package/lib/types/agent-pty.d.ts +62 -4
- package/lib/types/bundle-route.d.ts +1 -1
- package/lib/types/client/DiffView.d.ts +33 -1
- package/lib/types/client/EditorHost.d.ts +4 -1
- package/lib/types/client/FileTree.d.ts +6 -2
- package/lib/types/client/TerminalWaitBanner.d.ts +6 -0
- package/lib/types/client/TreePanel.d.ts +4 -1
- package/lib/types/client/api.d.ts +26 -0
- package/lib/types/client/chunk-loader.d.ts +1 -1
- package/lib/types/client/chunks/locale.d.ts +3 -0
- package/lib/types/client/conversation-draft.d.ts +85 -4
- package/lib/types/client/locales.d.ts +13 -20
- package/lib/types/client/selection-popup.d.ts +58 -0
- package/lib/types/client/service.d.ts +1 -1
- package/lib/types/client/state.d.ts +15 -0
- package/lib/types/client/terminal-font.d.ts +25 -2
- package/lib/types/context-types.d.ts +3 -1
- package/lib/types/fs-operations.d.ts +42 -0
- package/lib/types/git.d.ts +15 -0
- package/lib/types/index.d.ts +9 -0
- package/lib/types/prefs-shared.d.ts +7 -5
- package/lib/types/pty-manager.d.ts +53 -0
- package/lib/types/wire.d.ts +6 -2
- package/package.json +1 -1
- package/src/agent-pty.ts +221 -33
- package/src/bundle-route.ts +1 -1
- package/src/client/DiffTab.tsx +10 -1
- package/src/client/DiffView.tsx +174 -19
- package/src/client/EditorHost.tsx +9 -2
- package/src/client/FileTree.tsx +151 -8
- package/src/client/Sidebar.tsx +95 -25
- package/src/client/TerminalView.tsx +41 -0
- package/src/client/TerminalWaitBanner.tsx +32 -0
- package/src/client/TextEditor.tsx +27 -45
- package/src/client/TreePanel.tsx +22 -2
- package/src/client/api.ts +20 -0
- package/src/client/chunk-loader.ts +1 -1
- package/src/client/chunks/locale.tsx +60 -0
- package/src/client/conversation-draft.ts +233 -7
- package/src/client/index.tsx +19 -8
- package/src/client/locales-ar.ts +17 -4
- package/src/client/locales-de.ts +17 -4
- package/src/client/locales-fr.ts +17 -4
- package/src/client/locales-hi.ts +17 -4
- package/src/client/locales-id.ts +17 -4
- package/src/client/locales-it.ts +17 -4
- package/src/client/locales-ja.ts +17 -4
- package/src/client/locales-ko.ts +17 -4
- package/src/client/locales-nl.ts +17 -4
- package/src/client/locales-pl.ts +17 -4
- package/src/client/locales-pt.ts +17 -4
- package/src/client/locales-ru.ts +17 -4
- package/src/client/locales-sv.ts +17 -4
- package/src/client/locales-th.ts +17 -4
- package/src/client/locales-tr.ts +17 -4
- package/src/client/locales-vi.ts +17 -4
- package/src/client/locales-zh-HK.ts +17 -4
- package/src/client/locales-zh-MO.ts +17 -4
- package/src/client/locales-zh-TW.ts +17 -4
- package/src/client/locales.ts +41 -51
- package/src/client/selection-popup.ts +155 -0
- package/src/client/service.ts +1 -1
- package/src/client/sidebar.module.css +68 -0
- package/src/client/state.ts +56 -10
- package/src/client/terminal-font.ts +34 -3
- package/src/context-types.ts +3 -2
- package/src/fs-operations.ts +126 -4
- package/src/git.ts +56 -3
- package/src/index.ts +82 -7
- package/src/open-external.ts +3 -4
- package/src/prefs-shared.ts +7 -5
- package/src/pty-manager.ts +176 -5
- package/src/sidechat-routes.ts +13 -1
- package/src/tools.ts +24 -6
- package/src/wire.ts +3 -0
package/src/client/locales.ts
CHANGED
|
@@ -95,6 +95,7 @@ export const zh = {
|
|
|
95
95
|
terminalDepsFailed: '终端依赖 node-pty 加载失败',
|
|
96
96
|
terminalDepsHint: '在 DSH 所在环境的终端或 cmd 中执行以下命令修复,然后点重试(node-pty 与 DSH 核心保持同一版本):',
|
|
97
97
|
terminalDepsProfile: '(检测到 profile:{profile})',
|
|
98
|
+
terminalShellNotFound: '未找到配置的 Shell:{name},请到 设置 → 侧边卡片 → 终端 检查 Shell 路径',
|
|
98
99
|
refresh: '刷新',
|
|
99
100
|
refreshUnsavedConfirm: '文件已在磁盘更新,刷新将丢弃未保存编辑。继续吗?',
|
|
100
101
|
save: '保存',
|
|
@@ -159,6 +160,18 @@ export const zh = {
|
|
|
159
160
|
producedOpen: '在侧边栏中打开',
|
|
160
161
|
showInFolder: '在文件夹中显示',
|
|
161
162
|
disconnected: '终端连接断开,重连中…',
|
|
163
|
+
terminalWaitBanner: 'Agent 正在等待 {needle}',
|
|
164
|
+
terminalSkipWait: '跳过等待',
|
|
165
|
+
gitFoldExpand: '展开 {count} 行上下文',
|
|
166
|
+
gitFoldLoading: '展开中…',
|
|
167
|
+
gitFoldFailed: '展开失败',
|
|
168
|
+
rename: '重命名',
|
|
169
|
+
renameInvalid: '名称不能为空,且不能包含路径分隔符。',
|
|
170
|
+
delete: '删除',
|
|
171
|
+
deleteTitle: '删除「{name}」?',
|
|
172
|
+
deleteDescFile: '将永久删除该文件,此操作不可撤销。',
|
|
173
|
+
deleteDescDir: '将永久删除该目录及其全部内容,此操作不可撤销。',
|
|
174
|
+
dismiss: '关闭',
|
|
162
175
|
exited: '终端进程已退出',
|
|
163
176
|
noSession: '选择一个会话以使用侧边栏',
|
|
164
177
|
pluginNotLoaded: '插件未加载,标签页暂不可用:',
|
|
@@ -218,10 +231,10 @@ export const zh = {
|
|
|
218
231
|
settingsConflict: '设置已被其他窗口修改,请重试',
|
|
219
232
|
binaryNoPreview: '此文件类型不支持预览',
|
|
220
233
|
downloadToView: '下载查看',
|
|
221
|
-
settingsSubagentTitle: '
|
|
222
|
-
settingsSubagentDesc: '
|
|
223
|
-
settingsJobsTitle: '
|
|
224
|
-
settingsJobsDesc: '
|
|
234
|
+
settingsSubagentTitle: '检测到子代理时自动激活任务管理页',
|
|
235
|
+
settingsSubagentDesc: '当前会话产生新的子代理时,自动激活任务管理页;宽屏同时展开侧边栏,窄屏不强制展开全屏抽屉;关闭后需手动打开',
|
|
236
|
+
settingsJobsTitle: '有新后台任务时自动激活后台任务页',
|
|
237
|
+
settingsJobsDesc: '当前会话出现新的后台任务时,自动激活后台任务页(每个新任务都会触发);宽屏同时展开侧边栏,窄屏不强制展开全屏抽屉;关闭后需手动打开',
|
|
225
238
|
settingsToolsTitle: '为模型注入终端工具',
|
|
226
239
|
settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)',
|
|
227
240
|
settingsFontFamilyTitle: '终端字体',
|
|
@@ -431,6 +444,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
431
444
|
terminalDepsFailed: 'Terminal dependency node-pty failed to load',
|
|
432
445
|
terminalDepsHint: 'Run the command below in a terminal or cmd on the DSH machine to repair it, then retry (node-pty stays in sync with the DSH core version):',
|
|
433
446
|
terminalDepsProfile: ' (detected profile: {profile})',
|
|
447
|
+
terminalShellNotFound: 'Configured shell not found: {name} — check the shell path under Settings → Side card → Terminal',
|
|
434
448
|
refresh: 'Refresh',
|
|
435
449
|
refreshUnsavedConfirm: 'The file changed on disk. Refreshing will discard unsaved edits. Continue?',
|
|
436
450
|
save: 'Save',
|
|
@@ -495,6 +509,18 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
495
509
|
producedOpen: 'Open in sidebar',
|
|
496
510
|
showInFolder: 'Show in folder',
|
|
497
511
|
disconnected: 'Terminal disconnected, reconnecting…',
|
|
512
|
+
terminalWaitBanner: 'Agent is waiting for {needle}',
|
|
513
|
+
terminalSkipWait: 'Skip wait',
|
|
514
|
+
gitFoldExpand: 'Expand {count} context lines',
|
|
515
|
+
gitFoldLoading: 'Expanding…',
|
|
516
|
+
gitFoldFailed: 'Failed to expand',
|
|
517
|
+
rename: 'Rename',
|
|
518
|
+
renameInvalid: 'The name cannot be empty or contain path separators.',
|
|
519
|
+
delete: 'Delete',
|
|
520
|
+
deleteTitle: 'Delete "{name}"?',
|
|
521
|
+
deleteDescFile: 'This permanently deletes the file. This cannot be undone.',
|
|
522
|
+
deleteDescDir: 'This permanently deletes the directory and everything inside it. This cannot be undone.',
|
|
523
|
+
dismiss: 'Dismiss',
|
|
498
524
|
exited: 'Terminal process exited',
|
|
499
525
|
noSession: 'Select a conversation to use the sidebar',
|
|
500
526
|
pluginNotLoaded: 'Plugin not loaded; tab unavailable:',
|
|
@@ -554,10 +580,10 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
554
580
|
settingsConflict: 'The setting changed in another window — please retry',
|
|
555
581
|
binaryNoPreview: 'This file type cannot be previewed',
|
|
556
582
|
downloadToView: 'Download to view',
|
|
557
|
-
settingsSubagentTitle: 'Auto-
|
|
558
|
-
settingsSubagentDesc: '
|
|
559
|
-
settingsJobsTitle: 'Auto-
|
|
560
|
-
settingsJobsDesc: '
|
|
583
|
+
settingsSubagentTitle: 'Auto-activate the Tasks page when a subagent appears',
|
|
584
|
+
settingsSubagentDesc: 'Activate the Tasks page when the current conversation spawns a new subagent; on wide screens the side card expands with it, narrow screens never force the full-screen drawer; turn off to open it manually',
|
|
585
|
+
settingsJobsTitle: 'Auto-activate the Jobs page on a new background job',
|
|
586
|
+
settingsJobsDesc: 'Activate the Jobs page whenever a new background job appears for the current conversation (every new job triggers); on wide screens the side card expands with it, narrow screens never force the full-screen drawer; turn off to open it manually',
|
|
561
587
|
settingsToolsTitle: 'Inject terminal tools for the model',
|
|
562
588
|
settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)',
|
|
563
589
|
settingsFontFamilyTitle: 'Terminal font family',
|
|
@@ -699,49 +725,13 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
699
725
|
*/
|
|
700
726
|
export const LOCALE_NS = 'betterSidebar'
|
|
701
727
|
|
|
702
|
-
// The
|
|
703
|
-
//
|
|
704
|
-
//
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
import { ko as koDict } from './locales-ko.ts'
|
|
710
|
-
import { ar as arDict } from './locales-ar.ts'
|
|
711
|
-
import { hi as hiDict } from './locales-hi.ts'
|
|
712
|
-
import { id as idDict } from './locales-id.ts'
|
|
713
|
-
import { tr as trDict } from './locales-tr.ts'
|
|
714
|
-
import { vi as viDict } from './locales-vi.ts'
|
|
715
|
-
import { th as thDict } from './locales-th.ts'
|
|
716
|
-
import { ru as ruDict } from './locales-ru.ts'
|
|
717
|
-
import { it as itDict } from './locales-it.ts'
|
|
718
|
-
import { nl as nlDict } from './locales-nl.ts'
|
|
719
|
-
import { sv as svDict } from './locales-sv.ts'
|
|
720
|
-
import { pl as plDict } from './locales-pl.ts'
|
|
721
|
-
import { zhHK as zhHKDict } from './locales-zh-HK.ts'
|
|
722
|
-
import { zhTW as zhTWDict } from './locales-zh-TW.ts'
|
|
723
|
-
import { zhMO as zhMODict } from './locales-zh-MO.ts'
|
|
724
|
-
|
|
725
|
-
/** The ja dictionary (key-set-equal to zh, enforced by the type annotation). */
|
|
726
|
-
export const ja: Record<keyof typeof zh, string> = jaDict as Record<keyof typeof zh, string>
|
|
727
|
-
export const de: Record<keyof typeof zh, string> = deDict as Record<keyof typeof zh, string>
|
|
728
|
-
export const fr: Record<keyof typeof zh, string> = frDict as Record<keyof typeof zh, string>
|
|
729
|
-
export const pt: Record<keyof typeof zh, string> = ptDict as Record<keyof typeof zh, string>
|
|
730
|
-
export const ko: Record<keyof typeof zh, string> = koDict as Record<keyof typeof zh, string>
|
|
731
|
-
export const ar: Record<keyof typeof zh, string> = arDict as Record<keyof typeof zh, string>
|
|
732
|
-
export const hi: Record<keyof typeof zh, string> = hiDict as Record<keyof typeof zh, string>
|
|
733
|
-
export const id: Record<keyof typeof zh, string> = idDict as Record<keyof typeof zh, string>
|
|
734
|
-
export const tr: Record<keyof typeof zh, string> = trDict as Record<keyof typeof zh, string>
|
|
735
|
-
export const vi: Record<keyof typeof zh, string> = viDict as Record<keyof typeof zh, string>
|
|
736
|
-
export const th: Record<keyof typeof zh, string> = thDict as Record<keyof typeof zh, string>
|
|
737
|
-
export const ru: Record<keyof typeof zh, string> = ruDict as Record<keyof typeof zh, string>
|
|
738
|
-
export const it: Record<keyof typeof zh, string> = itDict as Record<keyof typeof zh, string>
|
|
739
|
-
export const nl: Record<keyof typeof zh, string> = nlDict as Record<keyof typeof zh, string>
|
|
740
|
-
export const sv: Record<keyof typeof zh, string> = svDict as Record<keyof typeof zh, string>
|
|
741
|
-
export const pl: Record<keyof typeof zh, string> = plDict as Record<keyof typeof zh, string>
|
|
742
|
-
export const zhHK: Record<keyof typeof zh, string> = zhHKDict as Record<keyof typeof zh, string>
|
|
743
|
-
export const zhTW: Record<keyof typeof zh, string> = zhTWDict as Record<keyof typeof zh, string>
|
|
744
|
-
export const zhMO: Record<keyof typeof zh, string> = zhMODict as Record<keyof typeof zh, string>
|
|
728
|
+
// The 19 non-zh/en dictionaries live in the LAZY `locale` chunk
|
|
729
|
+
// (src/client/chunks/locale.tsx → lib/client-locale.js): `t()` only ever
|
|
730
|
+
// consults zh/en plus the better-locale override store, so those dicts are
|
|
731
|
+
// needed only when better-locale is installed — the client apply fetches the
|
|
732
|
+
// chunk there and registers its `localeDicts` into the override store. They
|
|
733
|
+
// used to sit statically in the core bundle (≈ half its weight) for a code
|
|
734
|
+
// path most sessions never enter.
|
|
745
735
|
|
|
746
736
|
/** The DSH locale service attached by the client apply (absent → browser detection). */
|
|
747
737
|
let localeService: { getSnapshot(): { active: string } } | undefined
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The floating "add selection to conversation" popup shared by the text
|
|
3
|
+
* viewer: a viewport-anchored button portaled to `document.body`, kept alive
|
|
4
|
+
* across the selection gesture and committed on click.
|
|
5
|
+
*
|
|
6
|
+
* Dismissal contract (upstream issue #425): the popup must never outlive its
|
|
7
|
+
* editor surface. The sidebar keeps every tab MOUNTED — switching tabs only
|
|
8
|
+
* flips the pane cell to `display:none` and collapsing the panel translates
|
|
9
|
+
* it off-screen — while the portaled `position:fixed` button stays pinned to
|
|
10
|
+
* its viewport anchor, ignoring both. The caller already hides on surface
|
|
11
|
+
* scrolls, selection collapse and content swaps; this hook adds the global
|
|
12
|
+
* dismissal that covers everything else:
|
|
13
|
+
*
|
|
14
|
+
* - any `mousedown` outside the button (tab bar, composer, another pane,
|
|
15
|
+
* the collapse toggle, …) closes it;
|
|
16
|
+
* - `Escape` closes it;
|
|
17
|
+
* - the document going hidden (`visibilitychange`) or the window losing
|
|
18
|
+
* focus closes it;
|
|
19
|
+
* - an `IntersectionObserver` on the editor surface closes it as soon as the
|
|
20
|
+
* surface leaves the viewport — the tab-switch (`display:none`) and
|
|
21
|
+
* panel-collapse (translated off-screen) paths have no DOM events of their
|
|
22
|
+
* own, so the geometry signal is the only reliable one.
|
|
23
|
+
*
|
|
24
|
+
* The button's own `mousedown` is never treated as an outside click: the
|
|
25
|
+
* caller preventDefaults it to keep the selection/caret alive until the
|
|
26
|
+
* click commits (the hook's capture-phase listener runs first and must not
|
|
27
|
+
* hide for it).
|
|
28
|
+
*/
|
|
29
|
+
import { useEffect, useRef, useState, type RefObject } from 'react'
|
|
30
|
+
|
|
31
|
+
/** The floating "add to conversation" action: payload + viewport anchor. */
|
|
32
|
+
export interface SelectionPopup {
|
|
33
|
+
insert: string
|
|
34
|
+
left: number
|
|
35
|
+
top: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SelectionPopupOptions {
|
|
39
|
+
/** Commit the payload into the composer draft (button click). */
|
|
40
|
+
onCommit(insert: string): void
|
|
41
|
+
/**
|
|
42
|
+
* The DOM surface that must stay on screen for the popup to live: the
|
|
43
|
+
* CodeMirror host element. Called lazily (refs are null until the content
|
|
44
|
+
* loads).
|
|
45
|
+
*/
|
|
46
|
+
getSurface(): HTMLElement | null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SelectionPopupControls {
|
|
50
|
+
/** The current popup (null = hidden). */
|
|
51
|
+
popup: SelectionPopup | null
|
|
52
|
+
/** Attach to the portaled button element. */
|
|
53
|
+
buttonRef: RefObject<HTMLButtonElement>
|
|
54
|
+
/** Anchor the popup above a selection (viewport-clamped). */
|
|
55
|
+
show(insert: string, left: number, top: number): void
|
|
56
|
+
/** Hide the popup (idempotent). */
|
|
57
|
+
hide(): void
|
|
58
|
+
/** The button's click: commit the stored payload, then hide. */
|
|
59
|
+
commit(): void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function useSelectionPopup(options: SelectionPopupOptions): SelectionPopupControls {
|
|
63
|
+
// Latest-callback refs: the dismissal listeners live for the mount's
|
|
64
|
+
// lifetime, so they must not capture stale closures across renders.
|
|
65
|
+
const onCommitRef = useRef(options.onCommit)
|
|
66
|
+
const getSurfaceRef = useRef(options.getSurface)
|
|
67
|
+
onCommitRef.current = options.onCommit
|
|
68
|
+
getSurfaceRef.current = options.getSurface
|
|
69
|
+
|
|
70
|
+
const [popup, setPopup] = useState<SelectionPopup | null>(null)
|
|
71
|
+
/** Live mirror for click/event-time reads (no re-render race). */
|
|
72
|
+
const popupRef = useRef<SelectionPopup | null>(null)
|
|
73
|
+
/** The portaled button itself (for the outside-click guard). */
|
|
74
|
+
const buttonRef = useRef<HTMLButtonElement>(null)
|
|
75
|
+
/** The surface visibility observer (created lazily on open). */
|
|
76
|
+
const observerRef = useRef<IntersectionObserver | null>(null)
|
|
77
|
+
|
|
78
|
+
const show = (insert: string, left: number, top: number): void => {
|
|
79
|
+
const next: SelectionPopup = {
|
|
80
|
+
insert,
|
|
81
|
+
left: Math.min(Math.max(left, 80), window.innerWidth - 80),
|
|
82
|
+
top,
|
|
83
|
+
}
|
|
84
|
+
popupRef.current = next
|
|
85
|
+
setPopup(next)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const hide = (): void => {
|
|
89
|
+
popupRef.current = null
|
|
90
|
+
setPopup(null)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const commit = (): void => {
|
|
94
|
+
const current = popupRef.current
|
|
95
|
+
if (current === null) return
|
|
96
|
+
onCommitRef.current(current.insert)
|
|
97
|
+
hide()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Global dismissal listeners, registered once; every handler reads the
|
|
101
|
+
// live popup ref so it no-ops while no popup is open.
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
const onMouseDown = (event: MouseEvent): void => {
|
|
104
|
+
if (popupRef.current === null) return
|
|
105
|
+
const button = buttonRef.current
|
|
106
|
+
if (button !== null && (button === event.target || button.contains(event.target as Node))) return
|
|
107
|
+
hide()
|
|
108
|
+
}
|
|
109
|
+
const onKeyDown = (event: KeyboardEvent): void => {
|
|
110
|
+
if (event.key === 'Escape' && popupRef.current !== null) hide()
|
|
111
|
+
}
|
|
112
|
+
const onVisibilityChange = (): void => {
|
|
113
|
+
if (document.hidden && popupRef.current !== null) hide()
|
|
114
|
+
}
|
|
115
|
+
const onWindowBlur = (): void => {
|
|
116
|
+
if (popupRef.current !== null) hide()
|
|
117
|
+
}
|
|
118
|
+
document.addEventListener('mousedown', onMouseDown, true)
|
|
119
|
+
document.addEventListener('keydown', onKeyDown, true)
|
|
120
|
+
document.addEventListener('visibilitychange', onVisibilityChange)
|
|
121
|
+
window.addEventListener('blur', onWindowBlur)
|
|
122
|
+
return () => {
|
|
123
|
+
document.removeEventListener('mousedown', onMouseDown, true)
|
|
124
|
+
document.removeEventListener('keydown', onKeyDown, true)
|
|
125
|
+
document.removeEventListener('visibilitychange', onVisibilityChange)
|
|
126
|
+
window.removeEventListener('blur', onWindowBlur)
|
|
127
|
+
observerRef.current?.disconnect()
|
|
128
|
+
observerRef.current = null
|
|
129
|
+
}
|
|
130
|
+
}, [])
|
|
131
|
+
|
|
132
|
+
// Re-target the visibility observer whenever a popup opens: the surface
|
|
133
|
+
// refs may have been null at mount (content loads async), so the surface
|
|
134
|
+
// is only trustworthy at open time.
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (popup === null) return
|
|
137
|
+
observerRef.current?.disconnect()
|
|
138
|
+
observerRef.current = null
|
|
139
|
+
if (typeof IntersectionObserver === 'undefined') return
|
|
140
|
+
const surface = getSurfaceRef.current()
|
|
141
|
+
if (surface === null) return
|
|
142
|
+
const observer = new IntersectionObserver((entries) => {
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
if (!entry.isIntersecting) hide()
|
|
145
|
+
}
|
|
146
|
+
}, { threshold: 0 })
|
|
147
|
+
observerRef.current = observer
|
|
148
|
+
observer.observe(surface)
|
|
149
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- the boolean
|
|
150
|
+
// flip is the only thing that must re-run this; an anchor move while
|
|
151
|
+
// open keeps the same surface.
|
|
152
|
+
}, [popup !== null])
|
|
153
|
+
|
|
154
|
+
return { popup, buttonRef, show, hide, commit }
|
|
155
|
+
}
|
package/src/client/service.ts
CHANGED
|
@@ -150,7 +150,7 @@ export interface TabComponentProps {
|
|
|
150
150
|
/** The explorer's reveal-highlight set (ExplorerView; "Show in folder" targets). */
|
|
151
151
|
revealed?: string[]
|
|
152
152
|
onToggleDir?: (path: string) => void
|
|
153
|
-
onReferenceFile?: (path: string) => void
|
|
153
|
+
onReferenceFile?: (path: string, isDir: boolean) => void
|
|
154
154
|
onOpenFile?: (path: string) => void
|
|
155
155
|
onOpenDiff?: (tab: SidebarTab) => void
|
|
156
156
|
onSubagentJump?: (childSessionId: string) => void
|
|
@@ -1651,6 +1651,74 @@ body[data-dsh-sidebar-dragging] .panel {
|
|
|
1651
1651
|
color: var(--dsw-alias-label-primary);
|
|
1652
1652
|
}
|
|
1653
1653
|
|
|
1654
|
+
/* A hunk-gap fold in a diff: the "expand hidden context" chip between two
|
|
1655
|
+
hunks (loading is inert, failure degrades to a static marker). */
|
|
1656
|
+
.gitFoldRow {
|
|
1657
|
+
display: block;
|
|
1658
|
+
width: 100%;
|
|
1659
|
+
box-sizing: border-box;
|
|
1660
|
+
padding: 2px 12px;
|
|
1661
|
+
border: none;
|
|
1662
|
+
background: var(--dsw-alias-bg-layer-1);
|
|
1663
|
+
color: var(--dsw-alias-label-tertiary);
|
|
1664
|
+
font: var(--dsw-font-xxxs-11);
|
|
1665
|
+
text-align: center;
|
|
1666
|
+
cursor: pointer;
|
|
1667
|
+
outline: none;
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
.gitFoldRow:hover {
|
|
1671
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
1672
|
+
color: var(--dsw-alias-label-secondary);
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
.gitFoldRow:disabled {
|
|
1676
|
+
cursor: default;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
.gitFoldRowFailed {
|
|
1680
|
+
color: var(--dsw-alias-state-error-primary);
|
|
1681
|
+
opacity: 0.7;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
/* The tree row's inline rename editor: fills the row's name slot, keeps the
|
|
1685
|
+
row's metrics so the tree does not jump when editing starts. */
|
|
1686
|
+
.explorerRenameInput {
|
|
1687
|
+
flex: 1;
|
|
1688
|
+
min-width: 0;
|
|
1689
|
+
height: 20px;
|
|
1690
|
+
padding: 0 4px;
|
|
1691
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
1692
|
+
border-radius: 4px;
|
|
1693
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
1694
|
+
color: var(--dsw-alias-label-primary);
|
|
1695
|
+
font: var(--dsw-font-xxs-12);
|
|
1696
|
+
outline: none;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
/* The "Agent 正在等待 {needle}" wait banner: a warn-toned strip above the
|
|
1700
|
+
terminal surface while the model blocks in terminal_wait_for; the needle
|
|
1701
|
+
ellipsizes inline and the full text rides the title tooltip. */
|
|
1702
|
+
.terminalWaitBanner {
|
|
1703
|
+
flex: none;
|
|
1704
|
+
display: flex;
|
|
1705
|
+
align-items: center;
|
|
1706
|
+
gap: 8px;
|
|
1707
|
+
padding: 3px 10px;
|
|
1708
|
+
font: var(--dsw-font-xxxs-11);
|
|
1709
|
+
color: var(--dsw-alias-state-warn-label);
|
|
1710
|
+
background: var(--dsw-alias-state-warn-tertiary);
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
.terminalWaitNeedle {
|
|
1714
|
+
flex: 1;
|
|
1715
|
+
min-width: 0;
|
|
1716
|
+
overflow: hidden;
|
|
1717
|
+
text-overflow: ellipsis;
|
|
1718
|
+
white-space: nowrap;
|
|
1719
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1654
1722
|
/* The node-pty dependency failure banner (issue #140): a taller warn block
|
|
1655
1723
|
carrying the pasteable repair command (bash / cmd / PowerShell). */
|
|
1656
1724
|
.terminalDepsBanner {
|
package/src/client/state.ts
CHANGED
|
@@ -104,6 +104,14 @@ export interface SidebarState {
|
|
|
104
104
|
splits: SplitNode
|
|
105
105
|
/** Free windows (tabs dragged out onto the conversation area). */
|
|
106
106
|
floats: FloatWindow[]
|
|
107
|
+
/**
|
|
108
|
+
* Live agent-terminal wait state (uuid → the wait the model currently
|
|
109
|
+
* blocks on in `terminal_wait_for`), mirrored from the host's
|
|
110
|
+
* agent-terminals push. Transient by design: sanitizeState never restores
|
|
111
|
+
* it, so a reload starts clean and the next push (sent immediately on WS
|
|
112
|
+
* attach) repopulates it.
|
|
113
|
+
*/
|
|
114
|
+
agentWaits: Record<string, { needle: string; since: number }>
|
|
107
115
|
}
|
|
108
116
|
|
|
109
117
|
export const PANEL_MIN = 280
|
|
@@ -137,7 +145,7 @@ export function mintTabId(): string {
|
|
|
137
145
|
|
|
138
146
|
/**
|
|
139
147
|
* The largest numeric suffix across a raw persisted state's counter ids
|
|
140
|
-
* (`pane:N` / `tab:N` / `split:N`). The uid counter is module-global and
|
|
148
|
+
* (`pane:N` / `tab:N` / `split:N` / `float:N`). The uid counter is module-global and
|
|
141
149
|
* resets on every reload, so a split minted AFTER a reload would collide
|
|
142
150
|
* with the persisted ids (a fresh "pane:1" beside the persisted "pane:1");
|
|
143
151
|
* mapLeaf would then visit BOTH leaves and every open would land in both
|
|
@@ -148,7 +156,7 @@ function maxCounterId(parsed: unknown): number {
|
|
|
148
156
|
let max = 0
|
|
149
157
|
const consider = (id: unknown): void => {
|
|
150
158
|
if (typeof id !== 'string') return
|
|
151
|
-
const match = /^(?:pane|tab|split):(\d+)$/.exec(id)
|
|
159
|
+
const match = /^(?:pane|tab|split|float):(\d+)$/.exec(id)
|
|
152
160
|
if (match !== null) max = Math.max(max, Number(match[1]))
|
|
153
161
|
}
|
|
154
162
|
const walk = (node: unknown): void => {
|
|
@@ -209,6 +217,7 @@ export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seed:
|
|
|
209
217
|
revealed: [],
|
|
210
218
|
splits: leaf,
|
|
211
219
|
floats: [],
|
|
220
|
+
agentWaits: {},
|
|
212
221
|
}
|
|
213
222
|
}
|
|
214
223
|
|
|
@@ -854,6 +863,23 @@ export function agentTabId(uuid: string): string {
|
|
|
854
863
|
return `${AGENT_TAB_PREFIX}${uuid}`
|
|
855
864
|
}
|
|
856
865
|
|
|
866
|
+
/** Shallow equality of two agent-wait maps (same keys, same needle+since). */
|
|
867
|
+
function sameAgentWaits(
|
|
868
|
+
a: SidebarState['agentWaits'] | undefined,
|
|
869
|
+
b: Record<string, { needle: string; since: number }>,
|
|
870
|
+
): boolean {
|
|
871
|
+
if (a === undefined) return Object.keys(b).length === 0
|
|
872
|
+
const aKeys = Object.keys(a)
|
|
873
|
+
if (aKeys.length !== Object.keys(b).length) return false
|
|
874
|
+
for (const key of aKeys) {
|
|
875
|
+
const av = a[key]
|
|
876
|
+
const bv = b[key]
|
|
877
|
+
if (av === undefined || bv === undefined) return false
|
|
878
|
+
if (av.needle !== bv.needle || av.since !== bv.since) return false
|
|
879
|
+
}
|
|
880
|
+
return true
|
|
881
|
+
}
|
|
882
|
+
|
|
857
883
|
/**
|
|
858
884
|
* Reconcile the sidebar's agent-terminal tabs with the host's live list.
|
|
859
885
|
* The host pushes the current list of agent terminals (created by the model
|
|
@@ -868,7 +894,7 @@ export function agentTabId(uuid: string): string {
|
|
|
868
894
|
*/
|
|
869
895
|
export function reconcileAgentTerminals(
|
|
870
896
|
state: SidebarState,
|
|
871
|
-
agentTerminals: ReadonlyArray<{ uuid: string; title: string }>,
|
|
897
|
+
agentTerminals: ReadonlyArray<{ uuid: string; title: string; waiting?: { needle: string; since: number } | null }>,
|
|
872
898
|
): SidebarState {
|
|
873
899
|
const existingTabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
|
|
874
900
|
.concat(state.floats.map(float => float.tab))
|
|
@@ -883,7 +909,17 @@ export function reconcileAgentTerminals(
|
|
|
883
909
|
// convergence: no title suffix, no meta write — the tab keeps its uuid
|
|
884
910
|
// so a later reconcile push revives it if the agent reopens the same one).
|
|
885
911
|
const toRemove = existingAgentTabs.filter(tab => !serverUuids.has(agentUuidOf(tab.id)) && tab.pin === undefined)
|
|
886
|
-
|
|
912
|
+
// Mirror the live wait state from the push (authoritative: a vanished
|
|
913
|
+
// waiting field simply drops the entry). A waits-only change must still
|
|
914
|
+
// produce a new state — the tab add/remove no-change check alone would
|
|
915
|
+
// swallow banner updates.
|
|
916
|
+
const serverWaits: Record<string, { needle: string; since: number }> = {}
|
|
917
|
+
for (const terminal of agentTerminals) {
|
|
918
|
+
if (terminal.waiting !== undefined && terminal.waiting !== null) {
|
|
919
|
+
serverWaits[terminal.uuid] = { needle: terminal.waiting.needle, since: terminal.waiting.since }
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
if (toAdd.length === 0 && toRemove.length === 0 && sameAgentWaits(state.agentWaits, serverWaits)) return state
|
|
887
923
|
// Remove tabs whose uuids vanished from the server list (the agent closed
|
|
888
924
|
// them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup;
|
|
889
925
|
// a FLOATED agent terminal leaves with its window.
|
|
@@ -900,7 +936,7 @@ export function reconcileAgentTerminals(
|
|
|
900
936
|
}
|
|
901
937
|
// Add tabs for new uuids (the agent created a terminal). They land in the
|
|
902
938
|
// active pane via openTabInActivePane; the next reconcile is a no-op for them.
|
|
903
|
-
let next: SidebarState = { ...state, splits, floats }
|
|
939
|
+
let next: SidebarState = { ...state, splits, floats, agentWaits: serverWaits }
|
|
904
940
|
for (const terminal of toAdd) {
|
|
905
941
|
const tab: SidebarTab = {
|
|
906
942
|
id: agentTabId(terminal.uuid),
|
|
@@ -994,6 +1030,7 @@ function resetRequested(): boolean {
|
|
|
994
1030
|
|
|
995
1031
|
function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
|
|
996
1032
|
const reset = resetRequested()
|
|
1033
|
+
const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
|
|
997
1034
|
if (reset) {
|
|
998
1035
|
try {
|
|
999
1036
|
localStorage.removeItem(`${STORAGE_PREFIX}:${sessionId}`)
|
|
@@ -1016,7 +1053,15 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
|
|
|
1016
1053
|
nextIdCounter = maxCounterId(parsed)
|
|
1017
1054
|
const sanitized = sanitizeState(parsed)
|
|
1018
1055
|
if (sanitized !== undefined) {
|
|
1019
|
-
|
|
1056
|
+
const restored = globalWidth === undefined ? sanitized : { ...sanitized, width: globalWidth }
|
|
1057
|
+
// A narrow panel is a full-screen drawer, so every page/session load
|
|
1058
|
+
// starts it closed even when the prior desktop/mobile state persisted
|
|
1059
|
+
// `panelOpen: true`. Preserve the entire restored workbench (panes,
|
|
1060
|
+
// tabs, and free windows); visibility is the sole viewport-derived
|
|
1061
|
+
// override.
|
|
1062
|
+
return viewport !== undefined && isNarrowWidth(viewport) && restored.panelOpen
|
|
1063
|
+
? { ...restored, panelOpen: false }
|
|
1064
|
+
: restored
|
|
1020
1065
|
}
|
|
1021
1066
|
}
|
|
1022
1067
|
} catch {
|
|
@@ -1031,10 +1076,8 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
|
|
|
1031
1076
|
// disabled editor type seeds nothing. On a NARROW viewport a brand-new
|
|
1032
1077
|
// session starts collapsed instead — the panel is a full-screen drawer
|
|
1033
1078
|
// there, and auto-opening it on first paint would cover the conversation
|
|
1034
|
-
// before the user asked.
|
|
1035
|
-
//
|
|
1036
|
-
// state.
|
|
1037
|
-
const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
|
|
1079
|
+
// before the user asked. Persisted layouts follow the same narrow-load
|
|
1080
|
+
// visibility rule above, while their workbench contents remain intact.
|
|
1038
1081
|
const width = globalWidth ?? (viewport === undefined
|
|
1039
1082
|
? PANEL_DEFAULT
|
|
1040
1083
|
: defaultWidthFor(viewport, prefs.defaultWidthPercent))
|
|
@@ -1143,6 +1186,9 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
|
|
|
1143
1186
|
revealed: [],
|
|
1144
1187
|
splits: migratedSplits,
|
|
1145
1188
|
floats,
|
|
1189
|
+
// Live wait state is transient: a reload starts clean; the next
|
|
1190
|
+
// agent-terminals push (sent on WS attach) repopulates it.
|
|
1191
|
+
agentWaits: {},
|
|
1146
1192
|
}
|
|
1147
1193
|
}
|
|
1148
1194
|
|
|
@@ -198,12 +198,43 @@ function usableBase(value: string | undefined): string {
|
|
|
198
198
|
return trimmed
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Guarantee the stack ends in a generic family, so a font the browser cannot
|
|
203
|
+
* resolve degrades to a monospace one.
|
|
204
|
+
*
|
|
205
|
+
* Without this a stack of unresolvable names (a misspelled family, or one only
|
|
206
|
+
* installed on the machine running the shell rather than the one running the
|
|
207
|
+
* browser) falls through to the browser's *standard* font, which is
|
|
208
|
+
* proportional — xterm then measures its cell from a proportional advance and
|
|
209
|
+
* the whole grid breaks, rather than merely losing the requested typeface.
|
|
210
|
+
*
|
|
211
|
+
* This does not sniff whether the named families exist (an explicit non-goal
|
|
212
|
+
* of the terminal font design): it only terminates the stack. A stack that
|
|
213
|
+
* already names a generic family is returned untouched, so a user who
|
|
214
|
+
* deliberately wrote one keeps it. Generics are matched unquoted only — a
|
|
215
|
+
* quoted `"monospace"` is a family *name*, not the keyword.
|
|
216
|
+
*
|
|
217
|
+
* @param stack - the resolved font-family value.
|
|
218
|
+
* @returns the stack, ending in a generic family.
|
|
219
|
+
*/
|
|
220
|
+
export function withMonospaceFallback(stack: string): string {
|
|
221
|
+
const families = splitFamilies(stack)
|
|
222
|
+
if (families.length === 0) return DEFAULT_TERMINAL_FONT_FAMILY
|
|
223
|
+
const only = families.length === 1 ? families[0] : undefined
|
|
224
|
+
if (only !== undefined && CSS_WIDE_KEYWORDS.has(only.toLowerCase())) return DEFAULT_TERMINAL_FONT_FAMILY
|
|
225
|
+
if (families.some((family) => GENERIC_FAMILIES.has(family.toLowerCase()))) return families.join(', ')
|
|
226
|
+
return `${families.join(', ')}, monospace`
|
|
227
|
+
}
|
|
228
|
+
|
|
201
229
|
/**
|
|
202
230
|
* Resolve the xterm font options for the given prefs.
|
|
203
231
|
*
|
|
204
232
|
* The base family keeps its existing precedence — user pref > theme code
|
|
205
|
-
* font > built-in stack
|
|
206
|
-
*
|
|
233
|
+
* font > built-in stack. {@link withMonospaceFallback} then terminates the
|
|
234
|
+
* stack with a generic family so an unresolvable name degrades to a
|
|
235
|
+
* monospace instead of the proportional standard font, and finally
|
|
236
|
+
* {@link withIconFontFallbacks} tops it up so prompt icons resolve
|
|
237
|
+
* regardless of which base won.
|
|
207
238
|
*
|
|
208
239
|
* @param prefs - the current side card preferences.
|
|
209
240
|
* @param themeFontFamily - the app's theme code font (`--ds-font-family-code`
|
|
@@ -218,7 +249,7 @@ export function resolveTerminalFont(
|
|
|
218
249
|
|| usableBase(themeFontFamily)
|
|
219
250
|
|| DEFAULT_TERMINAL_FONT_FAMILY
|
|
220
251
|
return {
|
|
221
|
-
fontFamily: withIconFontFallbacks(base),
|
|
252
|
+
fontFamily: withIconFontFallbacks(withMonospaceFallback(base)),
|
|
222
253
|
fontSize: clampTerminalFontSize(prefs.terminalFontSize),
|
|
223
254
|
}
|
|
224
255
|
}
|
package/src/context-types.ts
CHANGED
|
@@ -428,9 +428,10 @@ export interface SidebarLocaleService {
|
|
|
428
428
|
|
|
429
429
|
/** The composer draft face the sidebar reaches through `ctx.conversation.input`. */
|
|
430
430
|
export interface SidebarSessionInput {
|
|
431
|
-
/** The live input store (draft read for append).
|
|
431
|
+
/** The live input store (draft read for append). `draftRev` is the machine's
|
|
432
|
+
* span-CAS revision — required to mint a structured file-reference chip. */
|
|
432
433
|
state: {
|
|
433
|
-
getSnapshot(): { draft: string }
|
|
434
|
+
getSnapshot(): { draft: string; draftRev?: number }
|
|
434
435
|
}
|
|
435
436
|
/** Replace the draft text (the input machine's single public write path). */
|
|
436
437
|
setDraft(text: string): void
|