@chatcode/cco-better-sidebar 0.18.0-alpha.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.
- package/LICENSE +21 -0
- package/README.md +554 -0
- package/README_EN.md +563 -0
- package/assets/logo.svg +1 -0
- package/cordis.patch.yml +79 -0
- package/lib/client-editor.js +55744 -0
- package/lib/client-mermaid.js +208256 -0
- package/lib/client-registry.js +30378 -0
- package/lib/client-terminal.js +16894 -0
- package/lib/client.js +30378 -0
- package/lib/index.js +6897 -0
- package/lib/invariant.js +22 -0
- package/lib/types/agent-opens.d.ts +65 -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/chatcode-update.d.ts +47 -0
- package/lib/types/client/AccountMenu.d.ts +1 -0
- package/lib/types/client/AiRatioSection.d.ts +3 -0
- package/lib/types/client/BrowserView.d.ts +39 -0
- package/lib/types/client/ChatcodeGate.d.ts +6 -0
- package/lib/types/client/CodingPlanModelsSettings.d.ts +9 -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 +13 -0
- package/lib/types/client/FenceErrorNotice.d.ts +5 -0
- package/lib/types/client/FileTree.d.ts +41 -0
- package/lib/types/client/FreeWindow.d.ts +29 -0
- package/lib/types/client/GitView.d.ts +12 -0
- package/lib/types/client/MarkdownHtml.d.ts +32 -0
- package/lib/types/client/OrphanedTab.d.ts +11 -0
- package/lib/types/client/PdfView.d.ts +6 -0
- package/lib/types/client/ReleaseNotesDialog.d.ts +11 -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/Sidebar.d.ts +8 -0
- package/lib/types/client/TabBar.d.ts +54 -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 +27 -0
- package/lib/types/client/UpdateDialog.d.ts +10 -0
- package/lib/types/client/UpdateSettingsPanel.d.ts +1 -0
- package/lib/types/client/UpdateSettingsSection.d.ts +10 -0
- package/lib/types/client/UploadOverlay.d.ts +20 -0
- package/lib/types/client/UsageHeatmapSection.d.ts +4 -0
- package/lib/types/client/add-plugin-modal.d.ts +15 -0
- package/lib/types/client/api.d.ts +345 -0
- package/lib/types/client/binary-download.d.ts +12 -0
- package/lib/types/client/breakpoints.d.ts +17 -0
- package/lib/types/client/browser.d.ts +60 -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/chat-presentation.d.ts +13 -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/editor-load.d.ts +66 -0
- package/lib/types/client/frame-batcher.d.ts +24 -0
- package/lib/types/client/icons.d.ts +66 -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 +44 -0
- package/lib/types/client/lang.d.ts +14 -0
- package/lib/types/client/layout-push.d.ts +15 -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-ar.d.ts +18 -0
- package/lib/types/client/locales-de.d.ts +4 -0
- package/lib/types/client/locales-fr.d.ts +10 -0
- package/lib/types/client/locales-hi.d.ts +17 -0
- package/lib/types/client/locales-id.d.ts +15 -0
- package/lib/types/client/locales-it.d.ts +8 -0
- package/lib/types/client/locales-ja.d.ts +17 -0
- package/lib/types/client/locales-ko.d.ts +9 -0
- package/lib/types/client/locales-nl.d.ts +16 -0
- package/lib/types/client/locales-pl.d.ts +19 -0
- package/lib/types/client/locales-pt.d.ts +1 -0
- package/lib/types/client/locales-ru.d.ts +13 -0
- package/lib/types/client/locales-sv.d.ts +1 -0
- package/lib/types/client/locales-th.d.ts +17 -0
- package/lib/types/client/locales-tr.d.ts +17 -0
- package/lib/types/client/locales-vi.d.ts +17 -0
- package/lib/types/client/locales-zh-HK.d.ts +32 -0
- package/lib/types/client/locales-zh-MO.d.ts +32 -0
- package/lib/types/client/locales-zh-TW.d.ts +32 -0
- package/lib/types/client/locales.d.ts +571 -0
- package/lib/types/client/markdown-html.d.ts +96 -0
- package/lib/types/client/markdown-images.d.ts +45 -0
- package/lib/types/client/markdown-labels.d.ts +21 -0
- package/lib/types/client/md-toc.d.ts +4 -0
- package/lib/types/client/mermaid-blocks.d.ts +47 -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 +52 -0
- package/lib/types/client/paths.d.ts +43 -0
- package/lib/types/client/pdf-types.d.ts +2 -0
- package/lib/types/client/pinned.d.ts +86 -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 +29 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +445 -0
- package/lib/types/client/session-pins.d.ts +14 -0
- package/lib/types/client/settings-nav-icon.d.ts +27 -0
- package/lib/types/client/shell-presets.d.ts +48 -0
- package/lib/types/client/split-pane.d.ts +38 -0
- package/lib/types/client/state.d.ts +480 -0
- package/lib/types/client/tab-content-memo.d.ts +40 -0
- package/lib/types/client/terminal-font.d.ts +78 -0
- package/lib/types/client/terminal-links.d.ts +119 -0
- package/lib/types/client/theme.d.ts +45 -0
- package/lib/types/client/titlebar-strip.d.ts +23 -0
- package/lib/types/client/ui-tweaks/appicons.d.ts +16 -0
- package/lib/types/client/ui-tweaks/archive.d.ts +42 -0
- package/lib/types/client/ui-tweaks/branding.d.ts +45 -0
- package/lib/types/client/ui-tweaks/cachehit.d.ts +41 -0
- package/lib/types/client/ui-tweaks/gitbar.d.ts +103 -0
- package/lib/types/client/ui-tweaks/index.d.ts +279 -0
- package/lib/types/client/ui-tweaks/mcp.d.ts +28 -0
- package/lib/types/client/ui-tweaks/notifier.d.ts +125 -0
- package/lib/types/client/ui-tweaks/whale.d.ts +55 -0
- package/lib/types/client/upload.d.ts +69 -0
- package/lib/types/client/useChatcodeAccount.d.ts +10 -0
- package/lib/types/client/wco.d.ts +47 -0
- package/lib/types/codingplan.d.ts +37 -0
- package/lib/types/config.d.ts +95 -0
- package/lib/types/context-types.d.ts +540 -0
- package/lib/types/fs-operations.d.ts +30 -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 +124 -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/login-route.d.ts +2 -0
- package/lib/types/open-external.d.ts +24 -0
- package/lib/types/path-security.d.ts +26 -0
- package/lib/types/prefs-shared.d.ts +257 -0
- package/lib/types/pty-deps.d.ts +79 -0
- package/lib/types/pty-manager.d.ts +168 -0
- package/lib/types/tools.d.ts +34 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/ui-tweaks/archive.d.ts +71 -0
- package/lib/types/ui-tweaks/config.d.ts +186 -0
- package/lib/types/ui-tweaks/git-web.d.ts +43 -0
- package/lib/types/ui-tweaks/git.d.ts +311 -0
- package/lib/types/ui-tweaks/index.d.ts +17 -0
- package/lib/types/ui-tweaks/mcp.d.ts +103 -0
- package/lib/types/ui-tweaks/turn-outcome.d.ts +57 -0
- package/lib/types/ui-tweaks/web.d.ts +55 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +213 -0
- package/scripts/install.ps1 +310 -0
- package/scripts/install.sh +287 -0
- package/src/agent-opens.ts +265 -0
- package/src/agent-pty.ts +527 -0
- package/src/browser-probe.ts +26 -0
- package/src/bundle-route.ts +129 -0
- package/src/chatcode-update.ts +193 -0
- package/src/client/AccountMenu.tsx +177 -0
- package/src/client/AiRatioSection.module.css +329 -0
- package/src/client/AiRatioSection.tsx +292 -0
- package/src/client/BrowserView.tsx +289 -0
- package/src/client/ChatcodeGate.tsx +35 -0
- package/src/client/CodingPlanModelsSettings.module.css +118 -0
- package/src/client/CodingPlanModelsSettings.tsx +101 -0
- package/src/client/DiffTab.tsx +114 -0
- package/src/client/DiffView.tsx +305 -0
- package/src/client/EditorHost.tsx +563 -0
- package/src/client/FenceErrorNotice.tsx +40 -0
- package/src/client/FileTree.tsx +701 -0
- package/src/client/FreeWindow.tsx +337 -0
- package/src/client/GitView.tsx +722 -0
- package/src/client/MarkdownHtml.tsx +296 -0
- package/src/client/OrphanedTab.tsx +27 -0
- package/src/client/PdfView.tsx +110 -0
- package/src/client/ReleaseNotesDialog.tsx +91 -0
- package/src/client/RenderBoundary.tsx +49 -0
- package/src/client/SandboxStatusBar.tsx +60 -0
- package/src/client/SideCardSection.module.css +932 -0
- package/src/client/SideCardSection.tsx +1169 -0
- package/src/client/Sidebar.tsx +1857 -0
- package/src/client/TabBar.tsx +365 -0
- package/src/client/TerminalView.tsx +424 -0
- package/src/client/TextEditor.tsx +468 -0
- package/src/client/TreePanel.tsx +279 -0
- package/src/client/UpdateDialog.tsx +186 -0
- package/src/client/UpdateSettingsPanel.tsx +29 -0
- package/src/client/UpdateSettingsSection.tsx +22 -0
- package/src/client/UploadOverlay.tsx +62 -0
- package/src/client/UsageHeatmapSection.module.css +440 -0
- package/src/client/UsageHeatmapSection.tsx +301 -0
- package/src/client/add-plugin-modal.tsx +199 -0
- package/src/client/api.ts +433 -0
- package/src/client/binary-download.tsx +23 -0
- package/src/client/breakpoints.ts +61 -0
- package/src/client/browser.ts +167 -0
- package/src/client/builtins/index.ts +37 -0
- package/src/client/builtins/tabs.tsx +275 -0
- package/src/client/builtins/viewers.tsx +125 -0
- package/src/client/chat-presentation.ts +95 -0
- package/src/client/chunk-loader.ts +350 -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/editor-load.ts +92 -0
- package/src/client/frame-batcher.ts +56 -0
- package/src/client/icons.tsx +170 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +501 -0
- package/src/client/intercept.tsx +172 -0
- package/src/client/lang.ts +225 -0
- package/src/client/layout-push.ts +37 -0
- package/src/client/layout.css +156 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +73 -0
- package/src/client/locales-ar.ts +387 -0
- package/src/client/locales-de.ts +372 -0
- package/src/client/locales-fr.ts +379 -0
- package/src/client/locales-hi.ts +386 -0
- package/src/client/locales-id.ts +384 -0
- package/src/client/locales-it.ts +377 -0
- package/src/client/locales-ja.ts +486 -0
- package/src/client/locales-ko.ts +378 -0
- package/src/client/locales-nl.ts +384 -0
- package/src/client/locales-pl.ts +388 -0
- package/src/client/locales-pt.ts +369 -0
- package/src/client/locales-ru.ts +384 -0
- package/src/client/locales-sv.ts +369 -0
- package/src/client/locales-th.ts +386 -0
- package/src/client/locales-tr.ts +386 -0
- package/src/client/locales-vi.ts +386 -0
- package/src/client/locales-zh-HK.ts +401 -0
- package/src/client/locales-zh-MO.ts +401 -0
- package/src/client/locales-zh-TW.ts +401 -0
- package/src/client/locales.ts +1229 -0
- package/src/client/markdown-html.ts +331 -0
- package/src/client/markdown-images.ts +137 -0
- package/src/client/markdown-labels.tsx +31 -0
- package/src/client/md-toc.tsx +127 -0
- package/src/client/mermaid-blocks.ts +110 -0
- package/src/client/mermaid-sanitize.ts +93 -0
- package/src/client/mermaid.tsx +406 -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 +79 -0
- package/src/client/paths.ts +63 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/pinned.ts +184 -0
- package/src/client/plugin-settings.ts +43 -0
- package/src/client/plugins-shared.ts +29 -0
- package/src/client/plugins-tabs.ts +153 -0
- package/src/client/plugins-viewers.ts +44 -0
- package/src/client/prefs.ts +216 -0
- package/src/client/produced-files.ts +120 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +873 -0
- package/src/client/session-pins.tsx +334 -0
- package/src/client/settings-nav-icon.ts +55 -0
- package/src/client/shell-presets.ts +83 -0
- package/src/client/sidebar.module.css +3607 -0
- package/src/client/split-pane.tsx +333 -0
- package/src/client/state.ts +1646 -0
- package/src/client/tab-content-memo.ts +55 -0
- package/src/client/terminal-font.ts +224 -0
- package/src/client/terminal-links.ts +194 -0
- package/src/client/theme.ts +110 -0
- package/src/client/titlebar-strip.ts +37 -0
- package/src/client/ui-tweaks/appicons.ts +24 -0
- package/src/client/ui-tweaks/archive.tsx +281 -0
- package/src/client/ui-tweaks/branding.tsx +232 -0
- package/src/client/ui-tweaks/cachehit.tsx +122 -0
- package/src/client/ui-tweaks/gitbar.tsx +2559 -0
- package/src/client/ui-tweaks/index.tsx +1696 -0
- package/src/client/ui-tweaks/mcp.tsx +582 -0
- package/src/client/ui-tweaks/notifier.ts +514 -0
- package/src/client/ui-tweaks/whale.tsx +171 -0
- package/src/client/upload.ts +187 -0
- package/src/client/useChatcodeAccount.ts +103 -0
- package/src/client/wco.ts +120 -0
- package/src/codingplan.ts +146 -0
- package/src/config.ts +207 -0
- package/src/context-types.ts +559 -0
- package/src/fs-operations.ts +97 -0
- package/src/fs-search.ts +113 -0
- package/src/fs-tree.ts +158 -0
- package/src/git.ts +446 -0
- package/src/html-route.ts +106 -0
- package/src/index.ts +1524 -0
- package/src/invariant.ts +32 -0
- package/src/login-route.ts +26 -0
- package/src/open-external.ts +90 -0
- package/src/path-security.ts +81 -0
- package/src/prefs-shared.ts +303 -0
- package/src/pty-deps.ts +241 -0
- package/src/pty-manager.ts +376 -0
- package/src/tools.ts +482 -0
- package/src/trust-fence.ts +84 -0
- package/src/ui-tweaks/archive.ts +293 -0
- package/src/ui-tweaks/config.ts +257 -0
- package/src/ui-tweaks/git-web.ts +421 -0
- package/src/ui-tweaks/git.ts +1524 -0
- package/src/ui-tweaks/index.ts +61 -0
- package/src/ui-tweaks/mcp.ts +535 -0
- package/src/ui-tweaks/turn-outcome.ts +105 -0
- package/src/ui-tweaks/web.ts +225 -0
- package/src/wire.ts +110 -0
- package/vendor/addon-fit.js +2 -0
- package/vendor/xterm.css +218 -0
- package/vendor/xterm.js +5 -0
|
@@ -0,0 +1,1857 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sidebar shell: panels mounted inside the unified panel host — a
|
|
3
|
+
* fixed, viewport-sized containing block ([data-dsh-panel-host]) appended
|
|
4
|
+
* to document.body — instead of individual fixed-position elements, so a
|
|
5
|
+
* desktop shell's intermediate wrapper transforms can never hijack the
|
|
6
|
+
* panels' fixed containing block (the core AppFrame owns the left sidebar /
|
|
7
|
+
* center / details columns and has no right-side hole for plugins). The
|
|
8
|
+
* right panel hosts the original workbench; the bottom panel hosts a
|
|
9
|
+
* second, independent workbench. The bottom panel squeezes ONLY the center
|
|
10
|
+
* column (the agent output area): it spans from the app shell's own left
|
|
11
|
+
* sidebar to the right panel's left edge, so neither sidebar gives up any
|
|
12
|
+
* position (the right panel keeps its full height). A persistent cluster at
|
|
13
|
+
* the top-right corner toggles the bottom panel; the host's native rightbar
|
|
14
|
+
* button toggles this plugin's right panel (with a plugin fallback on older
|
|
15
|
+
* hosts). The right panel's width drags from its left edge, the bottom
|
|
16
|
+
* panel's height from its top edge, and the shared corner drags both at once.
|
|
17
|
+
* Current DSH hosts receive one integrated Session-header control group for
|
|
18
|
+
* the bottom panel, right panel, and manual; older hosts keep the viewport
|
|
19
|
+
* fallback. The whole layout lives in the per-session store, so switching
|
|
20
|
+
* conversations swaps the sidebar.
|
|
21
|
+
*
|
|
22
|
+
* The shell binds the workbench actions to the store and dispatches tab
|
|
23
|
+
* content to the views. New tabs come from the + menu (explorer / git /
|
|
24
|
+
* terminal; editors open from the explorer). Tabs live in one tree only —
|
|
25
|
+
* they never cross panels; only the panel sizes drag against each other.
|
|
26
|
+
*
|
|
27
|
+
* Narrow (mobile, <768px) viewports show ONLY the right sidebar: entering
|
|
28
|
+
* narrow migrates the bottom panel's tabs INTO the right tree
|
|
29
|
+
* (migrateBottomTabs) — one workbench, the bottom tabs thrown into its
|
|
30
|
+
* strips. The right panel becomes a full-width drawer, the bottom panel
|
|
31
|
+
* and its toggle button disappear, and the layout push is disabled (the
|
|
32
|
+
* drawer floats). Widening does not migrate back: the tabs keep living in
|
|
33
|
+
* the right tree.
|
|
34
|
+
*/
|
|
35
|
+
import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type ReactNode } from 'react'
|
|
36
|
+
import { useSyncExternalStore } from 'react'
|
|
37
|
+
import { createPortal } from 'react-dom'
|
|
38
|
+
import clsx from 'clsx'
|
|
39
|
+
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
40
|
+
import type { Context } from '../context-types.ts'
|
|
41
|
+
import { appendToDraft } from './conversation-draft.ts'
|
|
42
|
+
import {
|
|
43
|
+
BOTTOM_MIN, PANEL_MIN, activateTab, agentUuidOf, allLeaves, closeFloatByTab, closeTab, dockFloat, firstLeaf, floatTab,
|
|
44
|
+
floatWithTab, isAgentTabId, leafWithTab, migrateBottomTabs,
|
|
45
|
+
moveFloat, moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, raiseFloat, reconcileAgentTerminals,
|
|
46
|
+
resizeFloat, resizeSplitIn, setBottomHeight, setTabPin, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
|
|
47
|
+
type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
|
|
48
|
+
} from './state.ts'
|
|
49
|
+
import { collectPinnedTabs, createPinnedVirtualTab, getPinnedHomeScope, injectPinnedIntoTree, isPinnedVirtualId, isPinnedVirtualTab, parsePinnedVirtualId, type PinnedTabEntry } from './pinned.ts'
|
|
50
|
+
import { IconBookOutline16, IconPinOutline16 } from './icons.tsx'
|
|
51
|
+
import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
|
|
52
|
+
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
|
|
53
|
+
import { isNarrowWidth, useViewportSize } from './breakpoints.ts'
|
|
54
|
+
import { layoutPushSize } from './layout-push.ts'
|
|
55
|
+
import { parseDesktopEnv } from './desktop-env.ts'
|
|
56
|
+
import { getWcoSnapshot, subscribeWco } from './wco.ts'
|
|
57
|
+
import { getShellPreset } from './shell-presets.ts'
|
|
58
|
+
import { computeTitleBarStrip } from './titlebar-strip.ts'
|
|
59
|
+
import type { NewTabOption } from './TabBar.tsx'
|
|
60
|
+
import { TAB_DRAG_TYPE, parseDrag, type TabDragPayload } from './TabBar.tsx'
|
|
61
|
+
import { FreeWindow } from './FreeWindow.tsx'
|
|
62
|
+
import { relativeTo } from './paths.ts'
|
|
63
|
+
import { OrphanedTab } from './OrphanedTab.tsx'
|
|
64
|
+
import { RenderBoundary } from './RenderBoundary.tsx'
|
|
65
|
+
import { tabContentCompare, type TabContentMemoKey } from './tab-content-memo.ts'
|
|
66
|
+
import { t } from './locales.ts'
|
|
67
|
+
import { api, type SessionScope } from './api.ts'
|
|
68
|
+
import { AccountMenu } from './AccountMenu.tsx'
|
|
69
|
+
import css from './sidebar.module.css'
|
|
70
|
+
|
|
71
|
+
/** How many consecutive reconnect failures stop the agent-terminals push loop
|
|
72
|
+
* (mirror of the terminal view's own cap; the loop restarts on session switch). */
|
|
73
|
+
const FAILURE_LIMIT = 3
|
|
74
|
+
|
|
75
|
+
/** Public CLI documentation opened by the persistent top-right manual action. */
|
|
76
|
+
export const USER_MANUAL_URL = 'https://chatcode.chinaunicom.cn/doc/cli/'
|
|
77
|
+
|
|
78
|
+
/** Stable host affordance contributed by DSH's native right sidebar. */
|
|
79
|
+
const NATIVE_SIDEBAR_TOGGLE = 'button[data-sidebar-right-expand]'
|
|
80
|
+
|
|
81
|
+
/** Stable container left behind when DSH's native right sidebar is open and
|
|
82
|
+
* its expand button is therefore absent. The utilities row immediately before
|
|
83
|
+
* it owns Session actions such as the log-download menu. */
|
|
84
|
+
const NATIVE_HEADER_CORNER = '[data-conversation-header-corner]'
|
|
85
|
+
|
|
86
|
+
/** Resolve the shared Session-actions row on both native-sidebar states.
|
|
87
|
+
* Current DSH keeps the corner seat mounted while its expand control comes and
|
|
88
|
+
* goes; older hosts fall back to the native button's own parent. */
|
|
89
|
+
function nativeToolbarHostOf(nativeButton: HTMLButtonElement | null): HTMLElement | null {
|
|
90
|
+
const corner = nativeButton?.closest<HTMLElement>(NATIVE_HEADER_CORNER)
|
|
91
|
+
?? document.querySelector<HTMLElement>(NATIVE_HEADER_CORNER)
|
|
92
|
+
const utilities = corner?.previousElementSibling
|
|
93
|
+
if (utilities instanceof HTMLElement && utilities.parentElement === corner?.parentElement) return utilities
|
|
94
|
+
return nativeButton?.parentElement ?? null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function UserManualButton() {
|
|
98
|
+
const label = t('userManual')
|
|
99
|
+
return (
|
|
100
|
+
<Tooltip label={label} side="bottom" delayMs={500}>
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
className={css.manualButton}
|
|
104
|
+
aria-label={label}
|
|
105
|
+
onClick={() => { window.open(USER_MANUAL_URL, '_blank', 'noopener,noreferrer') }}
|
|
106
|
+
>
|
|
107
|
+
<IconBookOutline16 />
|
|
108
|
+
<span>{label}</span>
|
|
109
|
+
</button>
|
|
110
|
+
</Tooltip>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* OS file drags over the sidebar belong to the sidebar, not to the chat:
|
|
116
|
+
* DSH's composer (InputBar) listens for file drags on the DOCUMENT and
|
|
117
|
+
* answers with a full-screen "drop image here" mask plus image intake on
|
|
118
|
+
* drop. Both panel-host render sites swallow the whole event quartet —
|
|
119
|
+
* enter/over/leave/drop — so the region is a black hole to that document
|
|
120
|
+
* listener. All four must be stopped: InputBar keeps an enter/leave depth
|
|
121
|
+
* counter, and a leave that escapes without its matching enter unbalances
|
|
122
|
+
* the count (this was the full-screen mask flickering over the sidebar).
|
|
123
|
+
* The conversation column keeps DSH's native overlay and intake untouched;
|
|
124
|
+
* gated on the 'Files' type so in-app drags (tab reorder, split zones)
|
|
125
|
+
* propagate exactly as before.
|
|
126
|
+
*/
|
|
127
|
+
const swallowOsFileDrag = (event: ReactDragEvent): void => {
|
|
128
|
+
if (!(event.dataTransfer?.types.includes('Files') ?? false)) return
|
|
129
|
+
event.preventDefault()
|
|
130
|
+
event.stopPropagation()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The four drag events a file drag must never carry past the panel host. */
|
|
134
|
+
const osFileDragShield = {
|
|
135
|
+
onDragEnter: swallowOsFileDrag,
|
|
136
|
+
onDragOver: swallowOsFileDrag,
|
|
137
|
+
onDragLeave: swallowOsFileDrag,
|
|
138
|
+
onDrop: swallowOsFileDrag,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Append one user-space stylesheet (preset or custom CSS) as a tagged
|
|
143
|
+
* `<style>` element. The tag attribute carries the source identity so the
|
|
144
|
+
* running configuration is inspectable in DevTools; the returned tag is
|
|
145
|
+
* removed by the caller's effect cleanup.
|
|
146
|
+
*/
|
|
147
|
+
function injectUserCss(attr: string, id: string, cssText: string): HTMLStyleElement {
|
|
148
|
+
const tag = document.createElement('style')
|
|
149
|
+
tag.setAttribute(attr, id)
|
|
150
|
+
tag.textContent = cssText
|
|
151
|
+
document.head.appendChild(tag)
|
|
152
|
+
return tag
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Props of one tab's content cell = the memo key (tab-content-memo.ts) plus
|
|
156
|
+
* the runtime objects/callbacks the cell renders with. The memo comparator
|
|
157
|
+
* is the pure `tabContentCompare`; anything in the key decides a re-render
|
|
158
|
+
* must propagate, anything outside it must be a stable object (ctx/store)
|
|
159
|
+
* or covered by a compared field (paneId covers onOpenDiff's captured
|
|
160
|
+
* pane; sessionId/cwd cover onReferenceFile). */
|
|
161
|
+
interface TabContentProps extends TabContentMemoKey {
|
|
162
|
+
onToggleDir: (path: string) => void
|
|
163
|
+
onReferenceFile: (path: string) => void
|
|
164
|
+
ctx: Context
|
|
165
|
+
store: SidebarStore
|
|
166
|
+
/** Fired before a topology node jumps to its child session (see Sidebar). */
|
|
167
|
+
onSubagentJump: (childSessionId: string) => void
|
|
168
|
+
/** Open a diff tab from the git panel (placement handled by the store). */
|
|
169
|
+
onOpenDiff: (tab: SidebarTab) => void
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Render the content of one tab (dispatched by type). */
|
|
173
|
+
const TabContent = memo(function TabContent(props: TabContentProps) {
|
|
174
|
+
const { tab, effectiveTabId, sessionId, cwd, expanded, revealed, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props
|
|
175
|
+
const scope = { sessionId, cwd }
|
|
176
|
+
const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
|
|
177
|
+
if (descriptor === undefined) {
|
|
178
|
+
return <OrphanedTab ctx={ctx} store={store} scope={scope} tab={tab} visible={visible} />
|
|
179
|
+
}
|
|
180
|
+
// For pinned virtual tabs, the tab descriptor's component (e.g. TerminalView)
|
|
181
|
+
// must receive the ORIGINAL tab id so it connects to the home session's PTY.
|
|
182
|
+
// The virtual tab's own id is a unique display key (prefixed); effectiveTabId
|
|
183
|
+
// restores the real id at the component boundary.
|
|
184
|
+
const componentTab = effectiveTabId !== undefined ? { ...tab, id: effectiveTabId } : tab
|
|
185
|
+
return createElement(
|
|
186
|
+
RenderBoundary,
|
|
187
|
+
{ className: css.tabBoundaryError },
|
|
188
|
+
createElement(descriptor.component, {
|
|
189
|
+
ctx, store, scope, tab: componentTab, visible, expanded, revealed,
|
|
190
|
+
onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump,
|
|
191
|
+
}),
|
|
192
|
+
)
|
|
193
|
+
}, tabContentCompare)
|
|
194
|
+
|
|
195
|
+
/** The + menu options for the current state, driven by the tab registry.
|
|
196
|
+
* Hidden tabs (editor/diff) never show; `available` returning false shows
|
|
197
|
+
* a disabled row (e.g. terminal at capacity) instead of hiding the option.
|
|
198
|
+
* Tabs the user disabled in the side card settings are filtered out
|
|
199
|
+
* entirely — re-enabling them is the settings page's job. */
|
|
200
|
+
function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionScope): NewTabOption[] {
|
|
201
|
+
const service = ctx.get('betterSidebar')
|
|
202
|
+
if (service === undefined) return []
|
|
203
|
+
return service.getTabs()
|
|
204
|
+
.filter(d => !d.hidden && service.isTabEnabled(d.id))
|
|
205
|
+
.sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
|
|
206
|
+
.map(d => ({
|
|
207
|
+
id: d.id,
|
|
208
|
+
label: typeof d.title === 'function' ? d.title() : d.title,
|
|
209
|
+
disabled: !(d.available?.(ctx, scope, state) ?? true),
|
|
210
|
+
icon: typeof d.icon === 'function' ? d.icon(16) : d.icon,
|
|
211
|
+
}))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|
215
|
+
const { ctx, store } = props
|
|
216
|
+
|
|
217
|
+
// Copy freshness: re-render the whole tree when the DSH locale switches.
|
|
218
|
+
// The module-level t() reads the active locale at call time, so a root
|
|
219
|
+
// re-render alone re-localizes every panel (no memo barriers below).
|
|
220
|
+
const localeRevision = useSyncExternalStore(
|
|
221
|
+
useMemo(() => (callback: () => void) => ctx.locale.subscribe(callback), [ctx]),
|
|
222
|
+
useCallback(() => ctx.locale.getSnapshot().active, [ctx]),
|
|
223
|
+
)
|
|
224
|
+
void localeRevision
|
|
225
|
+
|
|
226
|
+
// better-locale override freshness: when @huanlin/dsh-plugin-better-locale
|
|
227
|
+
// is installed and the user picks an override language (e.g. ja), the
|
|
228
|
+
// store's `active` changes but the DSH locale's `active` does NOT —
|
|
229
|
+
// better-locale keeps the dsh active value (zh/en) unchanged and only
|
|
230
|
+
// patches `LocaleRuntime.prototype.lookup`. The localeRevision uSES
|
|
231
|
+
// above reads `getSnapshot().active`, so it sees no change and skips
|
|
232
|
+
// re-render. This second uSES reads the better-locale store's `active`
|
|
233
|
+
// directly, so an override switch fires a full re-render and t() picks
|
|
234
|
+
// up the new override text. Optional: ctx.get returns undefined when
|
|
235
|
+
// better-locale is absent (or when ctx is a minimal test mock without
|
|
236
|
+
// a `get` method), in which case this is a no-op uSES.
|
|
237
|
+
type BetterLocaleStore = {
|
|
238
|
+
readonly active: string | undefined
|
|
239
|
+
subscribe(listener: () => void): () => void
|
|
240
|
+
}
|
|
241
|
+
const betterLocaleStore = typeof ctx.get === 'function'
|
|
242
|
+
? (ctx as unknown as {
|
|
243
|
+
get(name: 'betterLocale'): BetterLocaleStore | undefined
|
|
244
|
+
}).get('betterLocale')
|
|
245
|
+
: undefined
|
|
246
|
+
const betterLocaleActive = useSyncExternalStore(
|
|
247
|
+
useMemo(() => {
|
|
248
|
+
const store = betterLocaleStore
|
|
249
|
+
if (store === undefined) return (_cb: () => void) => () => {}
|
|
250
|
+
return (callback: () => void) => store.subscribe(callback)
|
|
251
|
+
}, [betterLocaleStore]),
|
|
252
|
+
useMemo(() => {
|
|
253
|
+
const store = betterLocaleStore
|
|
254
|
+
if (store === undefined) return () => undefined
|
|
255
|
+
return () => store.active
|
|
256
|
+
}, [betterLocaleStore]),
|
|
257
|
+
)
|
|
258
|
+
void betterLocaleActive
|
|
259
|
+
|
|
260
|
+
// Tab-registry revision: TabContent memo cells must pick up a descriptor
|
|
261
|
+
// a plugin registers/disposes after mount (the + menu / icons already read
|
|
262
|
+
// the registry at render). Rare events (plugin (un)mount), so one full
|
|
263
|
+
// re-render per change is fine — this is what keeps the memoized cells
|
|
264
|
+
// from going stale, mirroring the localeRevision mechanism above.
|
|
265
|
+
const [tabsVersion, setTabsVersion] = useState(0)
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
const service = ctx.get('betterSidebar')
|
|
268
|
+
if (service === undefined) return
|
|
269
|
+
return service.subscribe(() => setTabsVersion(version => version + 1))
|
|
270
|
+
}, [ctx])
|
|
271
|
+
|
|
272
|
+
// Narrow (mobile) viewports collapse the two panels into one: the right
|
|
273
|
+
// panel becomes a full-width drawer holding BOTH workbenches, the bottom
|
|
274
|
+
// panel (and its toggle button) disappears, and the layout push is
|
|
275
|
+
// disabled (the drawer floats over the app shell). Entering narrow
|
|
276
|
+
// MIGRATES the bottom tree's tabs into the right tree (migrateBottomTabs)
|
|
277
|
+
// — the merged display is the right sidebar alone, the bottom tabs thrown
|
|
278
|
+
// into its strips. Widening never rewrites the migrated state: the tabs
|
|
279
|
+
// keep living in the right tree.
|
|
280
|
+
const viewport = useViewportSize()
|
|
281
|
+
const narrow = isNarrowWidth(viewport.width)
|
|
282
|
+
|
|
283
|
+
// On-screen keyboard / visual-viewport inset (mobile, split-screen, …):
|
|
284
|
+
// when the visual viewport shrinks below the layout viewport, bottom-
|
|
285
|
+
// anchored panels would hide under the keyboard. Track the inset and
|
|
286
|
+
// offset the bottom-anchored surfaces by it. The obscured bottom strip is
|
|
287
|
+
// innerHeight − (vv.height + vv.offsetTop): offsetTop is nonzero while
|
|
288
|
+
// the visual viewport is scrolled/zoomed under browser chrome, so
|
|
289
|
+
// omitting it would over-lift the panels (CR #232 P2). offsetTop changes
|
|
290
|
+
// through the viewport's scroll event too, so both events are listened.
|
|
291
|
+
// Guarded: browsers without visualViewport (older WebViews, jsdom) stay
|
|
292
|
+
// at 0. rAF-throttled, same pattern as useNarrowViewport.
|
|
293
|
+
const [keyboardInset, setKeyboardInset] = useState(0)
|
|
294
|
+
const [visualViewportHeight, setVisualViewportHeight] = useState<number | null>(null)
|
|
295
|
+
useEffect(() => {
|
|
296
|
+
const vv = window.visualViewport
|
|
297
|
+
if (vv === null || vv === undefined) return
|
|
298
|
+
let frame: number | null = null
|
|
299
|
+
const measure = (): void => {
|
|
300
|
+
frame = null
|
|
301
|
+
const inset = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop))
|
|
302
|
+
setKeyboardInset(inset > 1 ? Math.round(inset) : 0)
|
|
303
|
+
setVisualViewportHeight(Math.max(0, Math.round(vv.height)))
|
|
304
|
+
}
|
|
305
|
+
const onResize = (): void => { if (frame === null) frame = requestAnimationFrame(measure) }
|
|
306
|
+
vv.addEventListener('resize', onResize)
|
|
307
|
+
vv.addEventListener('scroll', onResize)
|
|
308
|
+
measure()
|
|
309
|
+
return () => {
|
|
310
|
+
vv.removeEventListener('resize', onResize)
|
|
311
|
+
vv.removeEventListener('scroll', onResize)
|
|
312
|
+
if (frame !== null) cancelAnimationFrame(frame)
|
|
313
|
+
}
|
|
314
|
+
}, [])
|
|
315
|
+
// The bottom panel is offset above the on-screen keyboard. Cap its height
|
|
316
|
+
// against that same visible area, not the taller layout viewport, so the
|
|
317
|
+
// conversation keeps PANEL_MIN even on wide touch devices.
|
|
318
|
+
const layoutViewportHeight = visualViewportHeight ?? viewport.height
|
|
319
|
+
|
|
320
|
+
// Current conversation (the sessions list feed).
|
|
321
|
+
const sessionList = useSyncExternalStore(
|
|
322
|
+
useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]),
|
|
323
|
+
useCallback(() => ctx.sessions.list.getSnapshot(), [ctx]),
|
|
324
|
+
)
|
|
325
|
+
const current = sessionList.current
|
|
326
|
+
|
|
327
|
+
// Per-session sidebar state.
|
|
328
|
+
const snapshot = useSyncExternalStore(
|
|
329
|
+
useCallback((callback: () => void) => store.subscribe(callback), [store]),
|
|
330
|
+
useCallback(() => store.getSnapshot(), [store]),
|
|
331
|
+
)
|
|
332
|
+
useEffect(() => { store.setSession(current) }, [current, store])
|
|
333
|
+
|
|
334
|
+
const state = snapshot.state
|
|
335
|
+
const sessionId = snapshot.sessionId
|
|
336
|
+
const [nativeToolbarHost, setNativeToolbarHost] = useState<HTMLElement | null>(
|
|
337
|
+
() => nativeToolbarHostOf(document.querySelector<HTMLButtonElement>(NATIVE_SIDEBAR_TOGGLE)),
|
|
338
|
+
)
|
|
339
|
+
const summaryCwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd
|
|
340
|
+
const pushedBottomHeight = (bottomOpen: boolean, bottomHeight: number): number => layoutPushSize({
|
|
341
|
+
narrow,
|
|
342
|
+
panelOpen: false,
|
|
343
|
+
bottomOpen,
|
|
344
|
+
width: 0,
|
|
345
|
+
bottomHeight,
|
|
346
|
+
viewportWidth: viewport.width,
|
|
347
|
+
viewportHeight: layoutViewportHeight,
|
|
348
|
+
}).height
|
|
349
|
+
|
|
350
|
+
// The collapsed toggle cluster reclaims the top-right corner, so the DSH
|
|
351
|
+
// session header's right-aligned utilities (the "Session log" download
|
|
352
|
+
// capsule) must yield. layout.css keys off this body attribute to push the
|
|
353
|
+
// header's right padding out past the cluster. Only the CLOSED panel needs
|
|
354
|
+
// it — an open panel already squeezes `#root` left, moving the header clear.
|
|
355
|
+
const collapsed = state === undefined || !state.panelOpen
|
|
356
|
+
useEffect(() => {
|
|
357
|
+
if (collapsed) document.body.setAttribute('data-dsh-sidebar-collapsed', '')
|
|
358
|
+
else document.body.removeAttribute('data-dsh-sidebar-collapsed')
|
|
359
|
+
return () => { document.body.removeAttribute('data-dsh-sidebar-collapsed') }
|
|
360
|
+
}, [collapsed])
|
|
361
|
+
|
|
362
|
+
// DSH already owns a right-sidebar corner seat. Use the adjacent Session
|
|
363
|
+
// utilities row as the persistent integration point: the native expand
|
|
364
|
+
// button disappears while DSH's own sidebar is open, but the seat and its
|
|
365
|
+
// sibling utilities stay mounted. The plugin renders all three controls in
|
|
366
|
+
// that one row and hides the intercepted native glyph, avoiding two sidebar
|
|
367
|
+
// buttons and avoiding the viewport-corner fallback over native content.
|
|
368
|
+
useEffect(() => {
|
|
369
|
+
const root = document.documentElement
|
|
370
|
+
let nativeButton: HTMLButtonElement | null = null
|
|
371
|
+
let toolbarHost: HTMLElement | null = null
|
|
372
|
+
let observedCluster: HTMLElement | null = null
|
|
373
|
+
let resizeObserver: ResizeObserver | undefined
|
|
374
|
+
let frame: number | null = null
|
|
375
|
+
|
|
376
|
+
const measure = (): void => {
|
|
377
|
+
frame = null
|
|
378
|
+
const next = document.querySelector<HTMLButtonElement>(NATIVE_SIDEBAR_TOGGLE)
|
|
379
|
+
if (next !== nativeButton) {
|
|
380
|
+
resizeObserver?.disconnect()
|
|
381
|
+
nativeButton?.removeAttribute('data-dsh-better-sidebar-toggle')
|
|
382
|
+
nativeButton = next
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const nextToolbarHost = nativeToolbarHostOf(nativeButton)
|
|
386
|
+
setNativeToolbarHost(current => current === nextToolbarHost ? current : nextToolbarHost)
|
|
387
|
+
document.body.toggleAttribute('data-dsh-native-sidebar-toggle', nextToolbarHost !== null)
|
|
388
|
+
|
|
389
|
+
const cluster = document.querySelector<HTMLElement>('[data-dsh-toggle-cluster]')
|
|
390
|
+
if (nextToolbarHost !== toolbarHost || cluster !== observedCluster) {
|
|
391
|
+
resizeObserver?.disconnect()
|
|
392
|
+
toolbarHost = nextToolbarHost
|
|
393
|
+
observedCluster = cluster
|
|
394
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
395
|
+
resizeObserver = new ResizeObserver(scheduleMeasure)
|
|
396
|
+
if (toolbarHost !== null) resizeObserver.observe(toolbarHost)
|
|
397
|
+
if (observedCluster !== null) resizeObserver.observe(observedCluster)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (nativeButton !== null) {
|
|
402
|
+
nativeButton.setAttribute('data-dsh-better-sidebar-toggle', '')
|
|
403
|
+
nativeButton.setAttribute('aria-label', state?.panelOpen === true ? t('collapse') : t('expand'))
|
|
404
|
+
const rect = (nextToolbarHost ?? nativeButton).getBoundingClientRect()
|
|
405
|
+
root.style.setProperty('--dsh-native-sidebar-toggle-top', `${Math.round(rect.top)}px`)
|
|
406
|
+
} else {
|
|
407
|
+
root.style.removeProperty('--dsh-native-sidebar-toggle-top')
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (cluster !== null) {
|
|
411
|
+
// Older hosts still use this reserve for the viewport fallback. The
|
|
412
|
+
// current host keeps the cluster in its own flex row and ignores it.
|
|
413
|
+
const reserve = Math.ceil(cluster.getBoundingClientRect().width) + 26
|
|
414
|
+
root.style.setProperty('--dsh-sidebar-toggle-reserve', `${reserve}px`)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function scheduleMeasure(): void {
|
|
418
|
+
if (frame !== null) return
|
|
419
|
+
frame = requestAnimationFrame(measure)
|
|
420
|
+
}
|
|
421
|
+
const interceptNativeClick = (event: MouseEvent): void => {
|
|
422
|
+
const target = event.target instanceof Element
|
|
423
|
+
? event.target.closest<HTMLButtonElement>(NATIVE_SIDEBAR_TOGGLE)
|
|
424
|
+
: null
|
|
425
|
+
if (target === null) return
|
|
426
|
+
event.stopPropagation()
|
|
427
|
+
store.reduce(togglePanel)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
document.addEventListener('click', interceptNativeClick, true)
|
|
431
|
+
window.addEventListener('resize', scheduleMeasure)
|
|
432
|
+
const mutationObserver = new MutationObserver(scheduleMeasure)
|
|
433
|
+
mutationObserver.observe(document.body, { childList: true, subtree: true })
|
|
434
|
+
measure()
|
|
435
|
+
|
|
436
|
+
return () => {
|
|
437
|
+
document.removeEventListener('click', interceptNativeClick, true)
|
|
438
|
+
window.removeEventListener('resize', scheduleMeasure)
|
|
439
|
+
mutationObserver.disconnect()
|
|
440
|
+
resizeObserver?.disconnect()
|
|
441
|
+
if (frame !== null) cancelAnimationFrame(frame)
|
|
442
|
+
nativeButton?.removeAttribute('data-dsh-better-sidebar-toggle')
|
|
443
|
+
document.body.removeAttribute('data-dsh-native-sidebar-toggle')
|
|
444
|
+
root.style.removeProperty('--dsh-native-sidebar-toggle-top')
|
|
445
|
+
root.style.removeProperty('--dsh-sidebar-toggle-reserve')
|
|
446
|
+
}
|
|
447
|
+
}, [state?.panelOpen, store])
|
|
448
|
+
|
|
449
|
+
// Title-bar / shell compatibility (the "位置兼容模式" scheme):
|
|
450
|
+
// auto — CONSERVATIVE: only the standard Window Controls Overlay
|
|
451
|
+
// geometry contributes (the real caption-overlay height,
|
|
452
|
+
// reactive to maximize/restore). No URL stamp, no preset, no
|
|
453
|
+
// guess — plain browsers see zero modification.
|
|
454
|
+
// preset — an opt-in built-in shell preset (shell-presets.ts) adds its
|
|
455
|
+
// per-shell strip as the no-WCO fallback.
|
|
456
|
+
// custom — the user's own CSS (injected below) + the legacy manual
|
|
457
|
+
// strip px.
|
|
458
|
+
// The resolved strip drives the SAME body attribute + CSS variable as the
|
|
459
|
+
// legacy boolean did, so the CSS contract is unchanged (layout.css /
|
|
460
|
+
// sidebar.module.css); only the value source changed. The cleanup removes
|
|
461
|
+
// both on unmount/boundary swap so a crashed sidebar never leaves them
|
|
462
|
+
// behind.
|
|
463
|
+
const desktopEnv = parseDesktopEnv()
|
|
464
|
+
const wco = useSyncExternalStore(
|
|
465
|
+
useMemo(() => subscribeWco, []),
|
|
466
|
+
getWcoSnapshot,
|
|
467
|
+
)
|
|
468
|
+
const scheme = snapshot.prefs.titleBarScheme
|
|
469
|
+
const preset = scheme === 'preset' ? getShellPreset(snapshot.prefs.titleBarPresetId) : undefined
|
|
470
|
+
const titleBarStrip = computeTitleBarStrip(
|
|
471
|
+
desktopEnv, wco, scheme, preset, snapshot.prefs.titleBarStripPx,
|
|
472
|
+
)
|
|
473
|
+
const titleBarCompat = titleBarStrip > 0
|
|
474
|
+
useEffect(() => {
|
|
475
|
+
const root = document.documentElement
|
|
476
|
+
if (titleBarCompat) {
|
|
477
|
+
document.body.setAttribute('data-dsh-title-bar-compat', '')
|
|
478
|
+
root.style.setProperty('--dsh-title-bar-strip', `${titleBarStrip}px`)
|
|
479
|
+
} else {
|
|
480
|
+
document.body.removeAttribute('data-dsh-title-bar-compat')
|
|
481
|
+
root.style.removeProperty('--dsh-title-bar-strip')
|
|
482
|
+
}
|
|
483
|
+
return () => {
|
|
484
|
+
document.body.removeAttribute('data-dsh-title-bar-compat')
|
|
485
|
+
root.style.removeProperty('--dsh-title-bar-strip')
|
|
486
|
+
}
|
|
487
|
+
}, [titleBarCompat, titleBarStrip])
|
|
488
|
+
|
|
489
|
+
// User-space CSS injection (the escape hatch): preset CSS (scheme
|
|
490
|
+
// `preset`) and free-form custom CSS (scheme `custom`) are appended AFTER
|
|
491
|
+
// the plugin's own styles — later in the cascade wins ties, and
|
|
492
|
+
// `!important` can override the JS-written inline strip variable. Each
|
|
493
|
+
// source gets its own tagged <style> so the running configuration stays
|
|
494
|
+
// inspectable; tags are removed on change/unmount so a stale stylesheet
|
|
495
|
+
// never outlives its fiber (HMR-safe).
|
|
496
|
+
const presetCss = scheme === 'preset' ? preset?.css ?? '' : ''
|
|
497
|
+
const customCss = scheme === 'custom' ? snapshot.prefs.customCss : ''
|
|
498
|
+
useEffect(() => {
|
|
499
|
+
const tags: HTMLStyleElement[] = []
|
|
500
|
+
if (presetCss !== '') tags.push(injectUserCss('data-dsh-preset-css', preset?.id ?? '', presetCss))
|
|
501
|
+
if (customCss !== '') tags.push(injectUserCss('data-dsh-custom-css', 'custom', customCss))
|
|
502
|
+
return () => { for (const tag of tags) tag.remove() }
|
|
503
|
+
}, [presetCss, customCss, preset?.id])
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Bottom-panel merge on narrow viewports: whenever a session is current
|
|
507
|
+
* while narrow (mount, session switch, or a desktop→narrow transition),
|
|
508
|
+
* throw the bottom tree's tabs into the right tree. Idempotent — after
|
|
509
|
+
* the first migration the bottom tree is empty and the reducer returns
|
|
510
|
+
* the same reference, so this effect settles immediately.
|
|
511
|
+
*/
|
|
512
|
+
useEffect(() => {
|
|
513
|
+
if (!narrow || sessionId === undefined) return
|
|
514
|
+
store.reduce(migrateBottomTabs)
|
|
515
|
+
}, [narrow, sessionId, store])
|
|
516
|
+
|
|
517
|
+
// While the session's header is still hydrating (or the session is blank),
|
|
518
|
+
// the list summary may carry no cwd; ask the host once (it falls back to
|
|
519
|
+
// the process cwd) so the explorer root and terminal cwd are real from
|
|
520
|
+
// first paint instead of showing "no session".
|
|
521
|
+
const [fetchedCwd, setFetchedCwd] = useState<string | undefined>(undefined)
|
|
522
|
+
useEffect(() => {
|
|
523
|
+
setFetchedCwd(undefined)
|
|
524
|
+
if (sessionId === undefined || summaryCwd !== undefined) return
|
|
525
|
+
let cancelled = false
|
|
526
|
+
api.sessionCwd({ sessionId })
|
|
527
|
+
.then(result => { if (!cancelled) setFetchedCwd(result.cwd) })
|
|
528
|
+
.catch(() => { /* the explorer/git rows surface their own errors */ })
|
|
529
|
+
return () => { cancelled = true }
|
|
530
|
+
}, [sessionId, summaryCwd])
|
|
531
|
+
const cwd = summaryCwd ?? fetchedCwd
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Agent terminals push: subscribe to the host's live list of agent-owned
|
|
535
|
+
* terminals for this session (created by the model through the
|
|
536
|
+
* `terminal_create` tool). The host pushes a JSON array on every
|
|
537
|
+
* create / close / exit; the sidebar reconciles the list into tabs
|
|
538
|
+
* (id `agent:<uuid>`, title from the agent). A disconnected socket
|
|
539
|
+
* retries with a short backoff so a refresh or transient drop reattaches
|
|
540
|
+
* the same shell without losing the agent's work — capped like the
|
|
541
|
+
* terminal view's own reconnect loop, so a refused endpoint never spins
|
|
542
|
+
* forever (the next session switch restarts the loop).
|
|
543
|
+
* While the terminal tab type is disabled in settings, pushes are
|
|
544
|
+
* ignored (no auto-added tabs); re-enabling makes the next push converge.
|
|
545
|
+
*/
|
|
546
|
+
useEffect(() => {
|
|
547
|
+
if (sessionId === undefined) return
|
|
548
|
+
let socket: WebSocket | null = null
|
|
549
|
+
let retry: number | undefined
|
|
550
|
+
let closed = false
|
|
551
|
+
let failures = 0
|
|
552
|
+
const connect = (): void => {
|
|
553
|
+
if (closed) return
|
|
554
|
+
const url = new URL('/sidebar/ws/agent-terminals', location.origin)
|
|
555
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
556
|
+
url.search = new URLSearchParams({ sessionId }).toString()
|
|
557
|
+
socket = new WebSocket(url.toString())
|
|
558
|
+
socket.onmessage = (event) => {
|
|
559
|
+
if (typeof event.data !== 'string') return
|
|
560
|
+
try {
|
|
561
|
+
const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean }>
|
|
562
|
+
if (!Array.isArray(list)) return
|
|
563
|
+
store.reduce(s => ctx.get('betterSidebar')?.isTabEnabled('terminal') === false
|
|
564
|
+
? s
|
|
565
|
+
: reconcileAgentTerminals(s, list))
|
|
566
|
+
} catch {
|
|
567
|
+
// Malformed push: ignore (the next push will reconcile).
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
socket.onclose = () => {
|
|
571
|
+
if (closed) return
|
|
572
|
+
failures += 1
|
|
573
|
+
if (failures >= FAILURE_LIMIT) {
|
|
574
|
+
console.error('[dsh-better-sidebar] agent-terminals connection failed; stopping reconnect loop', sessionId)
|
|
575
|
+
return
|
|
576
|
+
}
|
|
577
|
+
retry = window.setTimeout(connect, 2000)
|
|
578
|
+
}
|
|
579
|
+
socket.onerror = () => { socket?.close() }
|
|
580
|
+
}
|
|
581
|
+
connect()
|
|
582
|
+
return () => {
|
|
583
|
+
closed = true
|
|
584
|
+
window.clearTimeout(retry)
|
|
585
|
+
socket?.close()
|
|
586
|
+
}
|
|
587
|
+
}, [sessionId, store])
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Agent opens push: subscribe to the host's `sidebar_open` requests for
|
|
591
|
+
* this session (the model actively opens a file / folder / HTTP(S) page).
|
|
592
|
+
* The host pushes one JSON request per open; the sidebar routes it to the
|
|
593
|
+
* matching built-in tab: a file opens in the editor (per-path dedupe), a
|
|
594
|
+
* folder opens a file window whose tree is rooted at the folder
|
|
595
|
+
* (`meta.dir`), and a URL opens in the browser tab. A disconnected socket
|
|
596
|
+
* retries with a short backoff (mirror of the agent-terminals loop): the
|
|
597
|
+
* host queue keeps undelivered requests and replays them on the first
|
|
598
|
+
* attach, so a refresh or a session switch lands the opens the model
|
|
599
|
+
* queued while no view was connected.
|
|
600
|
+
* While the side-card setting is off, pushes are ignored as a defensive
|
|
601
|
+
* gate — the host already unregisters the tool and drains the queue.
|
|
602
|
+
*/
|
|
603
|
+
useEffect(() => {
|
|
604
|
+
if (sessionId === undefined) return
|
|
605
|
+
let socket: WebSocket | null = null
|
|
606
|
+
let retry: number | undefined
|
|
607
|
+
let closed = false
|
|
608
|
+
let failures = 0
|
|
609
|
+
const connect = (): void => {
|
|
610
|
+
if (closed) return
|
|
611
|
+
const url = new URL('/sidebar/ws/agent-opens', location.origin)
|
|
612
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
613
|
+
url.search = new URLSearchParams({ sessionId }).toString()
|
|
614
|
+
socket = new WebSocket(url.toString())
|
|
615
|
+
socket.onmessage = (event) => {
|
|
616
|
+
if (typeof event.data !== 'string') return
|
|
617
|
+
try {
|
|
618
|
+
const request = JSON.parse(event.data) as { kind?: unknown; target?: unknown; title?: unknown }
|
|
619
|
+
if (request === null || typeof request !== 'object') return
|
|
620
|
+
if (request.kind !== 'file' && request.kind !== 'folder' && request.kind !== 'url') return
|
|
621
|
+
if (typeof request.target !== 'string' || request.target === '') return
|
|
622
|
+
if (store.getPrefs().agentOpenTools !== true) return
|
|
623
|
+
const scope = { sessionId }
|
|
624
|
+
const title = typeof request.title === 'string' && request.title !== '' ? request.title : undefined
|
|
625
|
+
if (request.kind === 'url') {
|
|
626
|
+
ctx.get('betterSidebar')?.openTab({ type: 'browser', url: request.target, title }, scope)
|
|
627
|
+
} else if (request.kind === 'folder') {
|
|
628
|
+
ctx.get('betterSidebar')?.openTab({
|
|
629
|
+
type: 'editor',
|
|
630
|
+
title,
|
|
631
|
+
path: request.target,
|
|
632
|
+
id: `editor:${request.target}`,
|
|
633
|
+
meta: { dir: true },
|
|
634
|
+
}, scope)
|
|
635
|
+
} else {
|
|
636
|
+
ctx.get('betterSidebar')?.openFile(scope, request.target, title)
|
|
637
|
+
}
|
|
638
|
+
} catch {
|
|
639
|
+
// Malformed push: ignore (the next push carries its own request).
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
socket.onclose = () => {
|
|
643
|
+
if (closed) return
|
|
644
|
+
failures += 1
|
|
645
|
+
if (failures >= FAILURE_LIMIT) {
|
|
646
|
+
console.error('[dsh-better-sidebar] agent-opens connection failed; stopping reconnect loop', sessionId)
|
|
647
|
+
return
|
|
648
|
+
}
|
|
649
|
+
retry = window.setTimeout(connect, 2000)
|
|
650
|
+
}
|
|
651
|
+
socket.onerror = () => { socket?.close() }
|
|
652
|
+
}
|
|
653
|
+
connect()
|
|
654
|
+
return () => {
|
|
655
|
+
closed = true
|
|
656
|
+
window.clearTimeout(retry)
|
|
657
|
+
socket?.close()
|
|
658
|
+
}
|
|
659
|
+
}, [sessionId, store])
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Inline pinned terminals (v0.17.0+): pinned tabs from OTHER sessions
|
|
663
|
+
* inject as VIRTUAL tabs into the first leaf of the right panel's split
|
|
664
|
+
* tree. The virtual tabs have unique ids (prefixed with the home session)
|
|
665
|
+
* and carry the home scope in meta. Clicking a virtual tab sets
|
|
666
|
+
* `activePinnedTabId` — the augmented tree overrides the leaf's `active`
|
|
667
|
+
* so the pinned tab's content renders in-place (TerminalView connects to
|
|
668
|
+
* the home session's PTY via WS, no session jump).
|
|
669
|
+
*
|
|
670
|
+
* Closing/unpinning a virtual tab targets the HOME session via reduceFor
|
|
671
|
+
* (which doesn't notify — targeted opens must not re-render the active
|
|
672
|
+
* session). The `pinnedRevision` state bump forces the pinnedEntries
|
|
673
|
+
* useMemo to recompute after such an action.
|
|
674
|
+
*/
|
|
675
|
+
const [activePinnedTabId, setActivePinnedTabId] = useState<string | null>(null)
|
|
676
|
+
const [pinnedRevision, setPinnedRevision] = useState(0)
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Cross-session pinned-tab collection. Recomputed on every store notify,
|
|
680
|
+
* session-list change, and pinned action (the revision bump covers
|
|
681
|
+
* reduceFor updates that don't notify). Only tabs from OTHER sessions —
|
|
682
|
+
* the viewer's own pinned tabs are already on its tab strip.
|
|
683
|
+
*/
|
|
684
|
+
const pinnedEntries: readonly PinnedTabEntry[] = useMemo(() => {
|
|
685
|
+
if (sessionId === undefined) return []
|
|
686
|
+
return collectPinnedTabs(store.getSessionStates(), { sessionId, cwd })
|
|
687
|
+
}, [store, sessionId, cwd, snapshot, pinnedRevision])
|
|
688
|
+
|
|
689
|
+
/** Virtual SidebarTab objects for the pinned entries (stable references
|
|
690
|
+
* via useMemo so TabContent's memo comparator holds). */
|
|
691
|
+
const pinnedVirtualTabs = useMemo(
|
|
692
|
+
() => pinnedEntries.map(createPinnedVirtualTab),
|
|
693
|
+
[pinnedEntries],
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
/** The right panel's split tree with pinned virtual tabs injected into the
|
|
697
|
+
* first leaf. When `activePinnedTabId` is set, that leaf's `active` is
|
|
698
|
+
* overridden so the pinned tab's content is visible. */
|
|
699
|
+
const augmentedTree = useMemo(
|
|
700
|
+
() => state === undefined ? undefined : injectPinnedIntoTree(state.splits, pinnedVirtualTabs, activePinnedTabId),
|
|
701
|
+
[state, pinnedVirtualTabs, activePinnedTabId],
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
// The app shell's center column: the bottom panel spans ONLY that column
|
|
705
|
+
// ("squeezes the agent output area") — it starts at the app sidebar's
|
|
706
|
+
// right edge and ends at the details column's left edge (the details
|
|
707
|
+
// column sits between the center and the right panel). Measured directly
|
|
708
|
+
// from the AppFrame's center column DOM. Current hosts expose it as the
|
|
709
|
+
// parent of the [data-slot="main"] wrapper; earlier hosts marked the grid
|
|
710
|
+
// item as [data-pane="conversation"] or wrapped [data-slot="conversation"].
|
|
711
|
+
// This matches layout.css's composite anchor so the panel tracks the real
|
|
712
|
+
// horizontal edges — including the animated margin-right push while the
|
|
713
|
+
// right panel opens/closes; a frame that never appears keeps the initial
|
|
714
|
+
// zero-size fallback (the panel renders at 0 width until measured).
|
|
715
|
+
// The rect lives in a REF (not state): the open/close transition resizes
|
|
716
|
+
// the center column EVERY frame for its duration, and reacting per frame
|
|
717
|
+
// with setState re-renders the whole Sidebar (every mounted tab) at
|
|
718
|
+
// animation cadence — the visible toggle jank (#315). measureCenter
|
|
719
|
+
// writes the bottom panel's edges directly (same DOM-write pattern as
|
|
720
|
+
// applyDrag), so the panel still tracks the column per frame with zero
|
|
721
|
+
// React work; `centerMeasured` flips ONCE to gate the hidden→visible
|
|
722
|
+
// first-paint fallback.
|
|
723
|
+
const centerRectRef = useRef({ left: 0, right: 0 })
|
|
724
|
+
const [centerMeasured, setCenterMeasured] = useState(false)
|
|
725
|
+
// Refs keep the measure step stable across renders and let it skip work
|
|
726
|
+
// mid-drag: during a width/corner drag the layout push resizes the center
|
|
727
|
+
// column every frame, and reacting (setCenterRect → re-render) would
|
|
728
|
+
// re-introduce the drag lag this shell deliberately avoids. applyDrag
|
|
729
|
+
// writes the bottom panel's edges directly, so measurement pauses then.
|
|
730
|
+
const centerColRef = useRef<HTMLElement | null>(null)
|
|
731
|
+
const draggingRef = useRef(false)
|
|
732
|
+
const measureCenter = useCallback((): void => {
|
|
733
|
+
if (draggingRef.current) return
|
|
734
|
+
const col = centerColRef.current
|
|
735
|
+
if (col === null) return
|
|
736
|
+
if (!col.isConnected) {
|
|
737
|
+
// The observed column was detached (HMR re-render swapped the node
|
|
738
|
+
// in place): its rect is stale garbage. Drop the ref — the locate
|
|
739
|
+
// chain re-runs on the next mutation/interval tick and picks up the
|
|
740
|
+
// new column node (issue #248).
|
|
741
|
+
centerColRef.current = null
|
|
742
|
+
return
|
|
743
|
+
}
|
|
744
|
+
const rect = col.getBoundingClientRect()
|
|
745
|
+
// Ref + direct DOM write (see the centerRectRef comment): the bottom
|
|
746
|
+
// panel keeps tracking the center column per frame during the right
|
|
747
|
+
// panel's open/close animation without re-rendering the shell. The
|
|
748
|
+
// one-shot measured flip renders the panel visible once (a stale
|
|
749
|
+
// {0,0} fallback would flash full-width).
|
|
750
|
+
centerRectRef.current = { left: rect.left, right: rect.right }
|
|
751
|
+
const bottom = bottomRef.current
|
|
752
|
+
if (bottom !== null) {
|
|
753
|
+
bottom.style.setProperty('left', `${rect.left}px`)
|
|
754
|
+
bottom.style.setProperty('right', `${window.innerWidth - rect.right}px`)
|
|
755
|
+
}
|
|
756
|
+
setCenterMeasured(prev => (prev ? prev : true))
|
|
757
|
+
}, [])
|
|
758
|
+
useEffect(() => {
|
|
759
|
+
let disposed = false
|
|
760
|
+
let observer: ResizeObserver | undefined
|
|
761
|
+
// Locate the AppFrame's center column. Current DSH puts [data-slot="main"]
|
|
762
|
+
// directly inside it; earlier hosts marked the grid item with
|
|
763
|
+
// [data-pane="conversation"] or put [data-slot="conversation"] inside it.
|
|
764
|
+
// Keep the JS locator aligned with layout.css's composite anchor, without
|
|
765
|
+
// hashed-class or positional dependencies. The shell swaps the
|
|
766
|
+
// boot page for the AppFrame only AFTER boot settles, so the first
|
|
767
|
+
// query may miss it. Never give up: watch #root's subtree (the swap and
|
|
768
|
+
// HMR re-renders mutate it) and re-run this locator — querying once and
|
|
769
|
+
// bailing would strand the panel at the zero-size fallback forever
|
|
770
|
+
// (observed: a 1px sliver at the viewport's left edge).
|
|
771
|
+
const locate = (): void => {
|
|
772
|
+
if (disposed) return
|
|
773
|
+
const col = document.querySelector<HTMLElement>('#root [data-slot="main"]')?.parentElement
|
|
774
|
+
?? document.querySelector<HTMLElement>('#root [data-dsh-frame] > [data-pane="conversation"]')
|
|
775
|
+
?? document.querySelector<HTMLElement>('#root [data-slot="conversation"]')?.parentElement
|
|
776
|
+
?? null
|
|
777
|
+
if (col === null || !col.isConnected) {
|
|
778
|
+
if (centerColRef.current !== null) {
|
|
779
|
+
centerColRef.current = null
|
|
780
|
+
observer?.disconnect()
|
|
781
|
+
observer = undefined
|
|
782
|
+
}
|
|
783
|
+
return
|
|
784
|
+
}
|
|
785
|
+
if (centerColRef.current !== col) {
|
|
786
|
+
// A NEW column node (boot swap, HMR re-render, or a previous locate
|
|
787
|
+
// that found nothing): attach the ResizeObserver to THIS node and
|
|
788
|
+
// measure it once. Same-node size changes are the ResizeObserver's
|
|
789
|
+
// job — no forced measurement here, because a forced
|
|
790
|
+
// getBoundingClientRect per mutation would reflow the shell at
|
|
791
|
+
// mutation cadence.
|
|
792
|
+
centerColRef.current = col
|
|
793
|
+
observer?.disconnect()
|
|
794
|
+
observer = new ResizeObserver(measureCenter)
|
|
795
|
+
observer.observe(col)
|
|
796
|
+
measureCenter()
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
locate()
|
|
800
|
+
// rAF-debounce the mutation watchers: #root's subtree changes at chat
|
|
801
|
+
// cadence (streaming turns), and locate() itself must stay cheap.
|
|
802
|
+
let locateFrame: number | null = null
|
|
803
|
+
const scheduleLocate = (): void => {
|
|
804
|
+
if (locateFrame !== null) return
|
|
805
|
+
// Mid-drag every frame writes --dsh-sidebar-* on <html>'s style
|
|
806
|
+
// attribute, which is the mutation this watcher observes — relocating
|
|
807
|
+
// per drag frame is pointless (the center column node cannot change
|
|
808
|
+
// while the pointer is captured) and adds a querySelector to every
|
|
809
|
+
// frame's budget (#315). The 1.5s retry below still covers any node
|
|
810
|
+
// swap that somehow lands mid-drag.
|
|
811
|
+
if (draggingRef.current) return
|
|
812
|
+
locateFrame = requestAnimationFrame(() => {
|
|
813
|
+
locateFrame = null
|
|
814
|
+
locate()
|
|
815
|
+
})
|
|
816
|
+
}
|
|
817
|
+
const watcher = new MutationObserver(scheduleLocate)
|
|
818
|
+
const root = document.getElementById('root')
|
|
819
|
+
if (root !== null) watcher.observe(root, { childList: true, subtree: true })
|
|
820
|
+
// The layout push writes --dsh-sidebar-* on <html>. A HMR re-activation
|
|
821
|
+
// clears those variables on teardown and re-writes them on setup — and
|
|
822
|
+
// that is also the moment the shell may have re-created the center
|
|
823
|
+
// column under a REUSED #root child (React swaps nodes in place, so
|
|
824
|
+
// #root's childList never changes and the watcher above never fires).
|
|
825
|
+
// Watching <html>'s style attribute catches that re-sync: the push
|
|
826
|
+
// rewrite re-locates and re-measures, so the bottom panel recovers
|
|
827
|
+
// instead of staying hidden on a stale {0,0} center rect.
|
|
828
|
+
const htmlStyleWatcher = new MutationObserver(scheduleLocate)
|
|
829
|
+
htmlStyleWatcher.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })
|
|
830
|
+
// Last-resort safety net (issue #248): no watcher is guaranteed to fire
|
|
831
|
+
// for every HMR teardown/setup interleaving (e.g. the style attribute
|
|
832
|
+
// may end up byte-identical, and the col may be swapped before the
|
|
833
|
+
// subtree watcher attaches). A slow unconditional re-locate makes the
|
|
834
|
+
// panel converge on the real column within a couple of seconds no
|
|
835
|
+
// matter what sequence the shell used. locate() is cheap when nothing
|
|
836
|
+
// changed (one querySelector + an identity compare; no forced layout).
|
|
837
|
+
const retry = window.setInterval(locate, 1500)
|
|
838
|
+
return () => {
|
|
839
|
+
disposed = true
|
|
840
|
+
if (locateFrame !== null) cancelAnimationFrame(locateFrame)
|
|
841
|
+
window.clearInterval(retry)
|
|
842
|
+
observer?.disconnect()
|
|
843
|
+
watcher.disconnect()
|
|
844
|
+
htmlStyleWatcher.disconnect()
|
|
845
|
+
centerColRef.current = null
|
|
846
|
+
}
|
|
847
|
+
// Opening the bottom panel re-runs the whole locate/measure chain: a
|
|
848
|
+
// panel opened before the center column was ever found must not stay
|
|
849
|
+
// invisible forever (the HMR recovery path depends on the observers
|
|
850
|
+
// above, this is the belt-and-braces retry for the open moment itself).
|
|
851
|
+
}, [measureCenter, state?.bottomOpen])
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Free windows — drag-out detection. The tab strips already drive HTML5
|
|
855
|
+
* DnD (payload application/x-dsh-tab) with drops owned by the panes
|
|
856
|
+
* (split/merge); this shell watches the DOCUMENT (capture) for the same
|
|
857
|
+
* drag hovering OUTSIDE the panel host: while the pointer is over the
|
|
858
|
+
* conversation column it arms the drop (preventDefault) and shows a hint
|
|
859
|
+
* overlay there, and the drop floats the tab at the release point. Targets
|
|
860
|
+
* inside the host are ignored here, so pane drops keep their behavior
|
|
861
|
+
* untouched. Only OUR tab drags count (the body flag is the tab strip's;
|
|
862
|
+
* OS file drags and any DSH drags pass through). Narrow viewports skip
|
|
863
|
+
* the gesture — the merged drawer covers the conversation, leaving
|
|
864
|
+
* nothing to drop onto (the tab context menu entry still floats tabs).
|
|
865
|
+
*/
|
|
866
|
+
const [floatHint, setFloatHint] = useState<{ left: number; top: number; width: number; height: number } | null>(null)
|
|
867
|
+
const floatHintRef = useRef(false)
|
|
868
|
+
useEffect(() => {
|
|
869
|
+
if (narrow || sessionId === undefined) return
|
|
870
|
+
const inPanelHost = (target: EventTarget | null): boolean =>
|
|
871
|
+
target instanceof Element && target.closest('[data-dsh-panel-host]') !== null
|
|
872
|
+
/** The conversation column's rect when the pointer is over it (and not
|
|
873
|
+
* over our own surfaces); null otherwise. */
|
|
874
|
+
const overConversation = (event: DragEvent): DOMRect | null => {
|
|
875
|
+
if (inPanelHost(event.target)) return null
|
|
876
|
+
const col = centerColRef.current
|
|
877
|
+
if (col === null || !col.isConnected) return null
|
|
878
|
+
const rect = col.getBoundingClientRect()
|
|
879
|
+
if (rect.width === 0 || rect.height === 0) return null
|
|
880
|
+
const { clientX: x, clientY: y } = event
|
|
881
|
+
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return null
|
|
882
|
+
return rect
|
|
883
|
+
}
|
|
884
|
+
const onDragOver = (event: DragEvent): void => {
|
|
885
|
+
if (!document.body.hasAttribute('data-dsh-tab-dragging')) return
|
|
886
|
+
const rect = overConversation(event)
|
|
887
|
+
if (rect !== null) {
|
|
888
|
+
// preventDefault on dragover is what makes the browser deliver the
|
|
889
|
+
// drop (and drop the "no" cursor) over the conversation area.
|
|
890
|
+
event.preventDefault()
|
|
891
|
+
setFloatHint((prev) => {
|
|
892
|
+
const next = { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
|
|
893
|
+
if (prev !== null && prev.left === next.left && prev.top === next.top
|
|
894
|
+
&& prev.width === next.width && prev.height === next.height) return prev
|
|
895
|
+
return next
|
|
896
|
+
})
|
|
897
|
+
floatHintRef.current = true
|
|
898
|
+
} else if (floatHintRef.current) {
|
|
899
|
+
floatHintRef.current = false
|
|
900
|
+
setFloatHint(null)
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
const onDrop = (event: DragEvent): void => {
|
|
904
|
+
if (!floatHintRef.current) return
|
|
905
|
+
floatHintRef.current = false
|
|
906
|
+
setFloatHint(null)
|
|
907
|
+
const rect = overConversation(event)
|
|
908
|
+
if (rect === null) return
|
|
909
|
+
event.preventDefault()
|
|
910
|
+
event.stopPropagation()
|
|
911
|
+
const payload = parseDrag(event.dataTransfer?.getData(TAB_DRAG_TYPE) ?? '')
|
|
912
|
+
if (payload === null) return
|
|
913
|
+
store.reduce(s => floatTab(s, payload.tabId, event.clientX, event.clientY))
|
|
914
|
+
}
|
|
915
|
+
const clear = (): void => {
|
|
916
|
+
if (!floatHintRef.current) return
|
|
917
|
+
floatHintRef.current = false
|
|
918
|
+
setFloatHint(null)
|
|
919
|
+
}
|
|
920
|
+
document.addEventListener('dragover', onDragOver, true)
|
|
921
|
+
document.addEventListener('drop', onDrop, true)
|
|
922
|
+
window.addEventListener('dragend', clear, true)
|
|
923
|
+
window.addEventListener('blur', clear)
|
|
924
|
+
return () => {
|
|
925
|
+
document.removeEventListener('dragover', onDragOver, true)
|
|
926
|
+
document.removeEventListener('drop', onDrop, true)
|
|
927
|
+
window.removeEventListener('dragend', clear, true)
|
|
928
|
+
window.removeEventListener('blur', clear)
|
|
929
|
+
}
|
|
930
|
+
}, [narrow, sessionId, store])
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Bottom-panel first-expansion auto terminal: the FIRST time the user
|
|
934
|
+
* expands the bottom panel in a session, try to open a fresh terminal tab
|
|
935
|
+
* there. "Try" is literal — the terminal's own quota and enable switch
|
|
936
|
+
* gate the attempt (a full quota or a disabled terminal type makes it a
|
|
937
|
+
* no-op). Gated on the bottomPanelAutoTerminal pref (the terminal tab's
|
|
938
|
+
* nested settings toggle, default on). Only a false→true TRANSITION fires
|
|
939
|
+
* (a panel persisted open never counts as an expansion), and the session's
|
|
940
|
+
* bottomOpenedOnce flag is set atomically with the first fire so later
|
|
941
|
+
* expansions never repeat it.
|
|
942
|
+
*/
|
|
943
|
+
const bottomWasOpenRef = useRef<boolean | undefined>(undefined)
|
|
944
|
+
useEffect(() => {
|
|
945
|
+
// The bottom panel does not exist on narrow viewports (the two
|
|
946
|
+
// workbenches merge into one panel), so the first-expansion auto
|
|
947
|
+
// terminal is a desktop-only behavior.
|
|
948
|
+
if (narrow) return
|
|
949
|
+
if (state === undefined) return
|
|
950
|
+
const wasOpen = bottomWasOpenRef.current
|
|
951
|
+
bottomWasOpenRef.current = state.bottomOpen
|
|
952
|
+
if (wasOpen === undefined || wasOpen || !state.bottomOpen) return
|
|
953
|
+
if (state.bottomOpenedOnce) return
|
|
954
|
+
if (store.getPrefs().bottomPanelAutoTerminal === false) return
|
|
955
|
+
if (ctx.get('betterSidebar')?.isTabEnabled('terminal') === false) return
|
|
956
|
+
// Land the tab in the bottom panel's first pane; the once-flag is set
|
|
957
|
+
// atomically so later expansions never repeat the auto-open.
|
|
958
|
+
store.reduce(s => ({ ...s, activePane: firstLeaf(s.bottomSplits).id, bottomOpenedOnce: true }))
|
|
959
|
+
ctx.get('betterSidebar')?.openTab({ type: 'terminal' })
|
|
960
|
+
}, [state, store, ctx, narrow])
|
|
961
|
+
|
|
962
|
+
// Panel drags: the right panel's width (left edge strip), the bottom
|
|
963
|
+
// panel's height (top edge strip), and the shared corner (both at once).
|
|
964
|
+
// Drags write the sizes DIRECTLY to the DOM (panel styles + the layout CSS
|
|
965
|
+
// variables) instead of round-tripping the store on every pointer move —
|
|
966
|
+
// a store reduce re-renders both workbenches (terminals, editors…) per
|
|
967
|
+
// move, which is the visible drag lag. The store is committed once on
|
|
968
|
+
// pointer up (clamping + persistence).
|
|
969
|
+
const panelRef = useRef<HTMLDivElement | null>(null)
|
|
970
|
+
const bottomRef = useRef<HTMLDivElement | null>(null)
|
|
971
|
+
const widthDrag = useRef({ startX: 0, startWidth: 0 })
|
|
972
|
+
const [draggingWidth, setDraggingWidth] = useState(false)
|
|
973
|
+
const bottomDrag = useRef({ startY: 0, startHeight: 0 })
|
|
974
|
+
const [draggingBottom, setDraggingBottom] = useState(false)
|
|
975
|
+
const cornerDrag = useRef({ startX: 0, startY: 0, startWidth: 0, startHeight: 0 })
|
|
976
|
+
const [draggingCorner, setDraggingCorner] = useState(false)
|
|
977
|
+
const anyDragging = draggingWidth || draggingBottom || draggingCorner
|
|
978
|
+
|
|
979
|
+
// Pause center-column measurement while dragging, and re-measure once the
|
|
980
|
+
// drag settles at its committed size. The store commit lands on release and
|
|
981
|
+
// the final width equals the last drag width, so no ResizeObserver event
|
|
982
|
+
// fires to refresh centerRect — this explicit re-measure covers that gap.
|
|
983
|
+
useEffect(() => {
|
|
984
|
+
draggingRef.current = anyDragging
|
|
985
|
+
if (!anyDragging) measureCenter()
|
|
986
|
+
}, [anyDragging, measureCenter])
|
|
987
|
+
|
|
988
|
+
// Clamp mirrors of setWidth/setBottomHeight for mid-drag values (the store
|
|
989
|
+
// re-clamps on commit; these keep the panels from overshooting mid-drag).
|
|
990
|
+
const clampWidth = (width: number): number =>
|
|
991
|
+
Math.min(Math.max(PANEL_MIN, Math.round(width)), Math.max(PANEL_MIN, window.innerWidth))
|
|
992
|
+
const clampHeight = (height: number): number =>
|
|
993
|
+
Math.min(Math.max(BOTTOM_MIN, Math.round(height)), Math.max(BOTTOM_MIN, window.innerHeight - PANEL_MIN))
|
|
994
|
+
|
|
995
|
+
/** Single writer for the layout-push variables: the app shell gives up
|
|
996
|
+
* the panel's width/height while open (0 while collapsed) through
|
|
997
|
+
* layout.css's margins. Every size change — drag frames and committed
|
|
998
|
+
* state — flows through here so the push never forks between paths. */
|
|
999
|
+
const writeGeometry = (width: number, height: number): void => {
|
|
1000
|
+
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
|
|
1001
|
+
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
|
|
1002
|
+
// The corner handle positions itself relative to the panel (CSS
|
|
1003
|
+
// `bottom: calc(var(--dsh-sidebar-height) + 6px)`), so these two layout
|
|
1004
|
+
// variables are all it needs — no viewport coordinates written here
|
|
1005
|
+
// (issue #106: skins that inset the panels must not fight JS coords).
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/** Last size a drag actually applied to the DOM (updated by applyDrag).
|
|
1009
|
+
* When a pointer stream dies without any position info (issue #247: an
|
|
1010
|
+
* ultra-fast flick whose release events carried no usable coordinates),
|
|
1011
|
+
* the abort path adopts this instead of rolling back to the pre-drag
|
|
1012
|
+
* value — the DOM's current size is the only truthful record left. */
|
|
1013
|
+
const lastDragSize = useRef<{ width: number; height: number } | null>(null)
|
|
1014
|
+
|
|
1015
|
+
/** Apply a drag size to the DOM without touching React state or the store.
|
|
1016
|
+
* The bottom panel's right edge tracks the right panel's left edge HERE
|
|
1017
|
+
* too — React state only updates on release, so the inline right must be
|
|
1018
|
+
* written directly or the bottom panel would lag the sidebar mid-drag.
|
|
1019
|
+
* The layout push rides the shared writer (writeGeometry). */
|
|
1020
|
+
const applyDrag = (width: number, height: number): void => {
|
|
1021
|
+
lastDragSize.current = { width, height }
|
|
1022
|
+
panelRef.current?.style.setProperty('width', `${width}px`)
|
|
1023
|
+
bottomRef.current?.style.setProperty('height', `${height}px`)
|
|
1024
|
+
// centerRect.right is the center column's right edge at the committed
|
|
1025
|
+
// width (innerWidth - state.width - detailsWidth), so this equals
|
|
1026
|
+
// `width + detailsWidth` — derived from the measured column, keeping the
|
|
1027
|
+
// drag write-only (no React re-render mid-drag).
|
|
1028
|
+
bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRectRef.current.right) + (width - (state?.width ?? 0))}px`)
|
|
1029
|
+
const bottomPush = !narrow && state?.bottomOpen === true ? height + keyboardInset : 0
|
|
1030
|
+
writeGeometry(width, bottomPush)
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Drags write at most once per frame: pointer events fire several times
|
|
1034
|
+
// faster than the display refresh, and each write reflows the app shell
|
|
1035
|
+
// (the layout push) plus the panels — batching to one write per frame is
|
|
1036
|
+
// what keeps the drag smooth. The store is still committed once on release.
|
|
1037
|
+
const dragFrame = useRef<number | null>(null)
|
|
1038
|
+
const pendingDrag = useRef<{ width: number; height: number } | null>(null)
|
|
1039
|
+
const scheduleDrag = (width: number, height: number): void => {
|
|
1040
|
+
pendingDrag.current = { width, height }
|
|
1041
|
+
if (dragFrame.current !== null) return
|
|
1042
|
+
dragFrame.current = requestAnimationFrame(() => {
|
|
1043
|
+
dragFrame.current = null
|
|
1044
|
+
const pending = pendingDrag.current
|
|
1045
|
+
if (pending !== null) {
|
|
1046
|
+
pendingDrag.current = null
|
|
1047
|
+
applyDrag(pending.width, pending.height)
|
|
1048
|
+
}
|
|
1049
|
+
})
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/** Flush any pending drag write and stop scheduling (the store commit on
|
|
1053
|
+
* pointer up applies the final clamped values). */
|
|
1054
|
+
const stopDragScheduling = (): void => {
|
|
1055
|
+
if (dragFrame.current !== null) {
|
|
1056
|
+
cancelAnimationFrame(dragFrame.current)
|
|
1057
|
+
dragFrame.current = null
|
|
1058
|
+
}
|
|
1059
|
+
pendingDrag.current = null
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Finalize a drag on pointer up: flush the LAST drag frame to the DOM
|
|
1064
|
+
* synchronously, then commit the SAME clamped values to the store. A fast
|
|
1065
|
+
* release cancels the rAF before it ran — without the flush the DOM would
|
|
1066
|
+
* sit at the pre-drag size until React re-renders with the committed
|
|
1067
|
+
* value, and a value that never made it into a move handler would never
|
|
1068
|
+
* be applied at all. The measurement pause ends here too: the center
|
|
1069
|
+
* column is re-measured BEFORE the committed re-render lands, so the
|
|
1070
|
+
* bottom panel's React-rendered right edge already reflects the new
|
|
1071
|
+
* width (otherwise the re-render would re-apply the stale rect — the
|
|
1072
|
+
* bottom panel visibly jumps for one frame).
|
|
1073
|
+
*/
|
|
1074
|
+
const commitDrag = (
|
|
1075
|
+
width: number,
|
|
1076
|
+
height: number,
|
|
1077
|
+
reduce: (state: SidebarState) => SidebarState,
|
|
1078
|
+
): void => {
|
|
1079
|
+
stopDragScheduling()
|
|
1080
|
+
applyDrag(width, height)
|
|
1081
|
+
draggingRef.current = false
|
|
1082
|
+
measureCenter()
|
|
1083
|
+
store.reduce(reduce)
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/** Set once a drag's pointerup handler commits — premature capture loss
|
|
1087
|
+
* (pointercancel / lostpointercapture without pointerup) must then be told
|
|
1088
|
+
* apart from a normal release. */
|
|
1089
|
+
const dragCommitted = useRef(false)
|
|
1090
|
+
/**
|
|
1091
|
+
* Abort a drag whose pointer stream was interrupted (pointercancel, or
|
|
1092
|
+
* capture lost before pointerup): no pointerup will arrive, so without
|
|
1093
|
+
* this the dragging state would stick true and center-column measurement
|
|
1094
|
+
* would stay paused forever — the bottom panel freezes at stale edges and
|
|
1095
|
+
* stops tracking sidebar/app-rail layout changes.
|
|
1096
|
+
*
|
|
1097
|
+
* A FAST release is the common trigger: browsers merge pointermove bursts,
|
|
1098
|
+
* and an ultra-fast flick can cancel the stream before ANY move lands.
|
|
1099
|
+
* The commit order is therefore: the LAST KNOWN dragged size (the rAF
|
|
1100
|
+
* pending value) first, then the interrupting event's own pointer
|
|
1101
|
+
* position (only pointercancel is trusted to carry coordinates —
|
|
1102
|
+
* lostpointercapture's coordinates are not guaranteed, so the handlers
|
|
1103
|
+
* pass the event only from pointercancel), and finally the size the drag
|
|
1104
|
+
* last APPLIED to the DOM (lastDragSize). A drag that produced none of
|
|
1105
|
+
* those (pure down+up at the same spot) commits the store's own sizes —
|
|
1106
|
+
* a no-op, never an explicit rollback (issue #247: v0.13.1 never reverted
|
|
1107
|
+
* an interrupted fast flick; the abort path added in the unified-host
|
|
1108
|
+
* refactor did, and that regression is what this ordering removes).
|
|
1109
|
+
*
|
|
1110
|
+
* Every commit path marks the drag committed, so the interrupt
|
|
1111
|
+
* double-fire (pointercancel → lostpointercapture) cannot commit once
|
|
1112
|
+
* and then roll the same drag back.
|
|
1113
|
+
*/
|
|
1114
|
+
const abortDrag = (reset: () => void, event?: { clientX: number; clientY: number }): void => {
|
|
1115
|
+
if (dragCommitted.current) return
|
|
1116
|
+
const pending = pendingDrag.current
|
|
1117
|
+
let width: number | undefined
|
|
1118
|
+
let height: number | undefined
|
|
1119
|
+
if (pending !== null) {
|
|
1120
|
+
width = pending.width
|
|
1121
|
+
height = pending.height
|
|
1122
|
+
} else if (event !== undefined) {
|
|
1123
|
+
// No move ever landed: the cancel position is all we have — commit it
|
|
1124
|
+
// (clamped) instead of rolling back the flick.
|
|
1125
|
+
if (draggingWidth) {
|
|
1126
|
+
width = clampWidth(widthDrag.current.startWidth + (widthDrag.current.startX - event.clientX))
|
|
1127
|
+
height = pushedBottomHeight(state?.bottomOpen === true, state?.bottomHeight ?? 0)
|
|
1128
|
+
} else if (draggingBottom) {
|
|
1129
|
+
width = Math.min(state?.width ?? 0, window.innerWidth)
|
|
1130
|
+
height = pushedBottomHeight(true, clampHeight(bottomDrag.current.startHeight + (bottomDrag.current.startY - event.clientY)))
|
|
1131
|
+
} else if (draggingCorner) {
|
|
1132
|
+
width = clampWidth(cornerDrag.current.startWidth + (cornerDrag.current.startX - event.clientX))
|
|
1133
|
+
height = pushedBottomHeight(true, clampHeight(cornerDrag.current.startHeight + (cornerDrag.current.startY - event.clientY)))
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
if (width !== undefined && height !== undefined) {
|
|
1137
|
+
dragCommitted.current = true
|
|
1138
|
+
pendingDrag.current = null
|
|
1139
|
+
if (dragFrame.current !== null) {
|
|
1140
|
+
cancelAnimationFrame(dragFrame.current)
|
|
1141
|
+
dragFrame.current = null
|
|
1142
|
+
}
|
|
1143
|
+
applyDrag(width, height)
|
|
1144
|
+
draggingRef.current = false
|
|
1145
|
+
measureCenter()
|
|
1146
|
+
store.reduce(s => setBottomHeight(setWidth(s, width), height))
|
|
1147
|
+
} else {
|
|
1148
|
+
// No pending write and no usable event coordinates: keep the size the
|
|
1149
|
+
// drag last applied instead of rolling back to the pre-drag value
|
|
1150
|
+
// (the flick's moves may have been consumed by the rAF just before
|
|
1151
|
+
// the stream died — the DOM already shows the dragged size). Clamp to
|
|
1152
|
+
// the current geometry like the layout-push effect (closed/narrow
|
|
1153
|
+
// panels are written 0 by the push).
|
|
1154
|
+
dragCommitted.current = true
|
|
1155
|
+
stopDragScheduling()
|
|
1156
|
+
const last = lastDragSize.current
|
|
1157
|
+
const { width: adoptedWidth, height: adoptedHeight } = layoutPushSize({
|
|
1158
|
+
narrow,
|
|
1159
|
+
panelOpen: state?.panelOpen === true,
|
|
1160
|
+
bottomOpen: state?.bottomOpen === true,
|
|
1161
|
+
width: last?.width ?? state?.width ?? 0,
|
|
1162
|
+
bottomHeight: last?.height ?? state?.bottomHeight ?? 0,
|
|
1163
|
+
viewportWidth: viewport.width,
|
|
1164
|
+
viewportHeight: layoutViewportHeight,
|
|
1165
|
+
})
|
|
1166
|
+
applyDrag(adoptedWidth, adoptedHeight)
|
|
1167
|
+
draggingRef.current = false
|
|
1168
|
+
measureCenter()
|
|
1169
|
+
store.reduce(s => setBottomHeight(setWidth(s, adoptedWidth), adoptedHeight))
|
|
1170
|
+
}
|
|
1171
|
+
reset()
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// Layout push: the app shell gives up the panel's width/height while the
|
|
1175
|
+
// panels are open (0 while collapsed), so the conversation and input bar
|
|
1176
|
+
// are squeezed instead of covered. The margins are capped at the viewport
|
|
1177
|
+
// so a stale persisted size (e.g. fullscreen on a bigger window) can never
|
|
1178
|
+
// crush the app shell to zero. Dragging disables the layout transition.
|
|
1179
|
+
// On NARROW viewports the drawer FLOATS over the app shell — no push, the
|
|
1180
|
+
// conversation keeps the full width behind the drawer.
|
|
1181
|
+
useEffect(() => {
|
|
1182
|
+
const { width, height } = layoutPushSize({
|
|
1183
|
+
narrow,
|
|
1184
|
+
panelOpen: snapshot.state?.panelOpen === true,
|
|
1185
|
+
bottomOpen: snapshot.state?.bottomOpen === true,
|
|
1186
|
+
width: snapshot.state?.width ?? 0,
|
|
1187
|
+
bottomHeight: snapshot.state?.bottomHeight ?? 0,
|
|
1188
|
+
viewportWidth: viewport.width,
|
|
1189
|
+
viewportHeight: layoutViewportHeight,
|
|
1190
|
+
})
|
|
1191
|
+
const bottomPush = !narrow && snapshot.state?.bottomOpen === true
|
|
1192
|
+
? height + keyboardInset
|
|
1193
|
+
: 0
|
|
1194
|
+
writeGeometry(width, bottomPush)
|
|
1195
|
+
}, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight, viewport.width, layoutViewportHeight, keyboardInset])
|
|
1196
|
+
// Unmount must release the push (issue #31): when the boundary swaps the
|
|
1197
|
+
// whole sidebar after a render crash (or the plugin fiber is disposed /
|
|
1198
|
+
// HMR), the CSS variables would otherwise stay on <html> and layout.css
|
|
1199
|
+
// keeps squeezing #root with a stale margin — "the sidebar cannot be
|
|
1200
|
+
// hidden" until a full reload. This lives in an UNMOUNT-ONLY effect, NOT
|
|
1201
|
+
// in the push effect's cleanup: React can yield between a passive
|
|
1202
|
+
// effect's cleanup and setup phases, and removing the variables on a
|
|
1203
|
+
// dependency change used to paint the push-less layout for a frame (the
|
|
1204
|
+
// center column went full width) while the re-add restarted the margin
|
|
1205
|
+
// transition — the bottom panel flashed full width after every width
|
|
1206
|
+
// drag (issue #258). Keeping the variables continuously valid while
|
|
1207
|
+
// mounted makes the push invisible to mid-flush style recals.
|
|
1208
|
+
useEffect(() => {
|
|
1209
|
+
return () => {
|
|
1210
|
+
document.documentElement.style.removeProperty('--dsh-sidebar-width')
|
|
1211
|
+
document.documentElement.style.removeProperty('--dsh-sidebar-height')
|
|
1212
|
+
}
|
|
1213
|
+
}, [])
|
|
1214
|
+
useEffect(() => {
|
|
1215
|
+
if (anyDragging) document.body.setAttribute('data-dsh-sidebar-dragging', '')
|
|
1216
|
+
else document.body.removeAttribute('data-dsh-sidebar-dragging')
|
|
1217
|
+
}, [anyDragging])
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
const actions: WorkbenchActions = useMemo(() => ({
|
|
1221
|
+
closeTab: (paneId, tabId) => {
|
|
1222
|
+
// A closed terminal releases its pty immediately — including when its
|
|
1223
|
+
// socket is mid-reconnect, where the unmount close frame never reaches
|
|
1224
|
+
// the host and the process would hold the quota until the grace ends.
|
|
1225
|
+
// Agent terminals (tabId `agent:<uuid>`) close through a different
|
|
1226
|
+
// host route: the WS close frame is the primary path (sent by
|
|
1227
|
+
// TerminalView on unmount), and the agent-pty.close HTTP route is the
|
|
1228
|
+
// fallback when the WS is down.
|
|
1229
|
+
const current = store.getSnapshot().state
|
|
1230
|
+
// Terminal tabs may live in EITHER tree (the bottom panel hosts them
|
|
1231
|
+
// too) — the pty-release lookup covers both, or the HTTP fallback is
|
|
1232
|
+
// skipped for a bottom-panel terminal whose WS frame never arrived.
|
|
1233
|
+
const leaf = current === undefined
|
|
1234
|
+
? undefined
|
|
1235
|
+
: leafWithTab(current.splits, tabId) ?? leafWithTab(current.bottomSplits, tabId)
|
|
1236
|
+
const tab = leaf?.tabs.find(candidate => candidate.id === tabId)
|
|
1237
|
+
// Route through the service: the tab-bar close is the canonical close
|
|
1238
|
+
// path (finds the pane itself, fires descriptor.onClose); the session
|
|
1239
|
+
// scope (with its cwd) rides to the callback.
|
|
1240
|
+
ctx.get('betterSidebar')?.closeTab(tabId, sessionId === undefined ? undefined : { sessionId, cwd })
|
|
1241
|
+
if (tab?.type === 'terminal') {
|
|
1242
|
+
if (isAgentTabId(tabId)) {
|
|
1243
|
+
const uuid = agentUuidOf(tabId)
|
|
1244
|
+
void api.agentPtyClose(uuid).catch(() => { /* the host may already have released it */ })
|
|
1245
|
+
} else if (sessionId !== undefined) {
|
|
1246
|
+
void api.ptyClose({ sessionId, cwd }, tabId).catch(() => { /* the host may already have released it */ })
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
},
|
|
1250
|
+
activateTab: (paneId, tabId) => {
|
|
1251
|
+
// Route through the service: same reducer (finds the pane in EITHER
|
|
1252
|
+
// tree, sets the active pane) and fires descriptor.onActivate; the
|
|
1253
|
+
// session scope (with its cwd) rides to the callback.
|
|
1254
|
+
ctx.get('betterSidebar')?.activateTab(tabId, sessionId === undefined ? undefined : { sessionId, cwd })
|
|
1255
|
+
},
|
|
1256
|
+
focusPane: (paneId) => { store.reduce(s => ({ ...s, activePane: paneId })) },
|
|
1257
|
+
moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => {
|
|
1258
|
+
store.reduce(s => moveTabToEdge(s, payload.paneId, payload.tabId, toPane, zone))
|
|
1259
|
+
},
|
|
1260
|
+
moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => {
|
|
1261
|
+
store.reduce((s) => {
|
|
1262
|
+
let index = -1
|
|
1263
|
+
const source = leafWithTab(s.splits, beforeTabId)
|
|
1264
|
+
if (source !== undefined && source.id === toPane) {
|
|
1265
|
+
index = source.tabs.findIndex(tab => tab.id === beforeTabId)
|
|
1266
|
+
}
|
|
1267
|
+
return moveTab(s, payload.paneId, payload.tabId, toPane, index)
|
|
1268
|
+
})
|
|
1269
|
+
},
|
|
1270
|
+
resizeSplit: (splitId, index, deltaFrac) => {
|
|
1271
|
+
store.reduce(s => resizeSplitIn(s, splitId, index, deltaFrac))
|
|
1272
|
+
},
|
|
1273
|
+
// The tab context menu's "move to free window": no drop point exists, so
|
|
1274
|
+
// the window is born over the conversation column's center (the user's
|
|
1275
|
+
// focus area; clamped into the viewport by the reducer) — the same
|
|
1276
|
+
// landing the drag-out gesture produces.
|
|
1277
|
+
floatTab: (tabId) => {
|
|
1278
|
+
const col = centerColRef.current
|
|
1279
|
+
const rect = col !== null && col.isConnected ? col.getBoundingClientRect() : null
|
|
1280
|
+
const x = rect !== null ? (rect.left + rect.right) / 2 : window.innerWidth / 2
|
|
1281
|
+
const y = rect !== null ? (rect.top + rect.bottom) / 2 : window.innerHeight / 2
|
|
1282
|
+
store.reduce(s => floatTab(s, tabId, x, y))
|
|
1283
|
+
},
|
|
1284
|
+
// Pin/unpin a terminal tab (v0.17.0+): the home cwd is snapshotted at
|
|
1285
|
+
// pin time so a workspace-scoped pin only resurfaces in sessions whose
|
|
1286
|
+
// cwd matches. Unpin passes null — the tab stays open in its home
|
|
1287
|
+
// session, just unmarked.
|
|
1288
|
+
pinTab: (tabId, scope) => {
|
|
1289
|
+
store.reduce(s => setTabPin(s, tabId, scope === null ? null : { scope, homeCwd: cwd }))
|
|
1290
|
+
},
|
|
1291
|
+
}), [store, sessionId, cwd])
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* Wrap the base actions to intercept pinned VIRTUAL tab ids (injected from
|
|
1295
|
+
* other sessions). Regular tab ids pass through unchanged. Virtual ids are
|
|
1296
|
+
* detected by the `pinned:` prefix and routed to the HOME session via
|
|
1297
|
+
* reduceFor (which doesn't notify — the revision bump is the local signal).
|
|
1298
|
+
*/
|
|
1299
|
+
const wrappedActions = useMemo<WorkbenchActions>(() => {
|
|
1300
|
+
if (pinnedVirtualTabs.length === 0) return actions
|
|
1301
|
+
const closePinnedInHome = (virtualId: string): void => {
|
|
1302
|
+
const { homeSessionId, tabId: originalId } = parsePinnedVirtualId(virtualId)
|
|
1303
|
+
// The home cwd lives in the virtual tab's meta (snapshotted at pin
|
|
1304
|
+
// time) — pass it to ptyClose so the host resolves the PTY in the
|
|
1305
|
+
// correct workspace container (same scope the WS open used).
|
|
1306
|
+
const vtab = pinnedVirtualTabs.find(t => t.id === virtualId)
|
|
1307
|
+
const homeCwd = vtab !== undefined ? getPinnedHomeScope(vtab)?.cwd : undefined
|
|
1308
|
+
store.reduceFor(homeSessionId, s => {
|
|
1309
|
+
const leaf = leafWithTab(s.splits, originalId) ?? leafWithTab(s.bottomSplits, originalId)
|
|
1310
|
+
if (leaf !== undefined) return closeTab(s, leaf.id, originalId)
|
|
1311
|
+
if (s.floats.some(f => f.tab.id === originalId)) return closeFloatByTab(s, originalId)
|
|
1312
|
+
return s
|
|
1313
|
+
})
|
|
1314
|
+
if (isAgentTabId(originalId)) {
|
|
1315
|
+
void api.agentPtyClose(agentUuidOf(originalId)).catch(() => { /* already released */ })
|
|
1316
|
+
} else {
|
|
1317
|
+
void api.ptyClose({ sessionId: homeSessionId, ...(homeCwd !== undefined ? { cwd: homeCwd } : {}) }, originalId).catch(() => { /* already released */ })
|
|
1318
|
+
}
|
|
1319
|
+
if (activePinnedTabId === virtualId) setActivePinnedTabId(null)
|
|
1320
|
+
setPinnedRevision(v => v + 1)
|
|
1321
|
+
}
|
|
1322
|
+
return {
|
|
1323
|
+
...actions,
|
|
1324
|
+
activateTab: (paneId, tabId) => {
|
|
1325
|
+
if (isPinnedVirtualId(tabId)) {
|
|
1326
|
+
setActivePinnedTabId(tabId)
|
|
1327
|
+
} else {
|
|
1328
|
+
setActivePinnedTabId(null)
|
|
1329
|
+
actions.activateTab(paneId, tabId)
|
|
1330
|
+
}
|
|
1331
|
+
},
|
|
1332
|
+
closeTab: (paneId, tabId) => {
|
|
1333
|
+
if (isPinnedVirtualId(tabId)) {
|
|
1334
|
+
closePinnedInHome(tabId)
|
|
1335
|
+
} else {
|
|
1336
|
+
actions.closeTab(paneId, tabId)
|
|
1337
|
+
}
|
|
1338
|
+
},
|
|
1339
|
+
moveTabBefore: (payload, toPane, beforeTabId) => {
|
|
1340
|
+
if (isPinnedVirtualId(payload.tabId)) return
|
|
1341
|
+
if (isPinnedVirtualId(beforeTabId)) {
|
|
1342
|
+
actions.moveTabToEdge(payload, toPane, 'center')
|
|
1343
|
+
} else {
|
|
1344
|
+
actions.moveTabBefore(payload, toPane, beforeTabId)
|
|
1345
|
+
}
|
|
1346
|
+
},
|
|
1347
|
+
moveTabToEdge: (payload, toPane, zone) => {
|
|
1348
|
+
if (isPinnedVirtualId(payload.tabId)) return
|
|
1349
|
+
actions.moveTabToEdge(payload, toPane, zone)
|
|
1350
|
+
},
|
|
1351
|
+
floatTab: (tabId) => {
|
|
1352
|
+
if (isPinnedVirtualId(tabId)) return
|
|
1353
|
+
actions.floatTab(tabId)
|
|
1354
|
+
},
|
|
1355
|
+
pinTab: (tabId, scope) => {
|
|
1356
|
+
if (isPinnedVirtualId(tabId)) {
|
|
1357
|
+
if (scope !== null) return
|
|
1358
|
+
const { homeSessionId, tabId: originalId } = parsePinnedVirtualId(tabId)
|
|
1359
|
+
store.reduceFor(homeSessionId, s => setTabPin(s, originalId, null))
|
|
1360
|
+
if (activePinnedTabId === tabId) setActivePinnedTabId(null)
|
|
1361
|
+
setPinnedRevision(v => v + 1)
|
|
1362
|
+
} else {
|
|
1363
|
+
actions.pinTab?.(tabId, scope)
|
|
1364
|
+
}
|
|
1365
|
+
},
|
|
1366
|
+
}
|
|
1367
|
+
}, [actions, pinnedVirtualTabs, activePinnedTabId, store])
|
|
1368
|
+
|
|
1369
|
+
/**
|
|
1370
|
+
* The explorer's @-reference button: append `@<relative path>` to the
|
|
1371
|
+
* session's composer draft (space-separated). The conversation service is
|
|
1372
|
+
* resolved lazily through `ctx.get` (the inject-free read — the app's own
|
|
1373
|
+
* plugins read 'conversation' the same way); a missing service or scope
|
|
1374
|
+
* degrades to a logged no-op, never a crash. Defined above the no-session
|
|
1375
|
+
* early return — a hook must never sit behind a conditional return
|
|
1376
|
+
* (React counts hooks per render).
|
|
1377
|
+
*/
|
|
1378
|
+
const referenceInChat = useCallback((path: string): void => {
|
|
1379
|
+
if (sessionId === undefined) return
|
|
1380
|
+
appendToDraft(ctx, sessionId, `@${relativeTo(cwd ?? '', path)}`)
|
|
1381
|
+
}, [ctx, sessionId, cwd])
|
|
1382
|
+
|
|
1383
|
+
if (state === undefined || sessionId === undefined) {
|
|
1384
|
+
// Keep the unavailable controls focusable: touch users have no hover, so
|
|
1385
|
+
// focus is the only way the existing Tooltip can explain what is missing.
|
|
1386
|
+
return (
|
|
1387
|
+
<div data-dsh-panel-host {...osFileDragShield}>
|
|
1388
|
+
{nativeToolbarHost !== null ? createPortal(
|
|
1389
|
+
<div className={clsx(css.toggleCluster, css.toggleClusterNative)} data-dsh-toggle-cluster data-dsh-native-toolbar-cluster>
|
|
1390
|
+
{!narrow && (
|
|
1391
|
+
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
|
|
1392
|
+
<button type="button" className={css.toggleButton} aria-disabled="true" aria-label={t('noSession')}>
|
|
1393
|
+
<IconPanelBottomOutline16 />
|
|
1394
|
+
</button>
|
|
1395
|
+
</Tooltip>
|
|
1396
|
+
)}
|
|
1397
|
+
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
|
|
1398
|
+
<button type="button" className={css.toggleButton} aria-disabled="true" aria-label={t('noSession')}>
|
|
1399
|
+
<IconPanelRightOutline16 />
|
|
1400
|
+
</button>
|
|
1401
|
+
</Tooltip>
|
|
1402
|
+
<UserManualButton />
|
|
1403
|
+
</div>,
|
|
1404
|
+
nativeToolbarHost,
|
|
1405
|
+
) : <div className={css.toggleCluster} data-dsh-toggle-cluster>
|
|
1406
|
+
{!narrow && (
|
|
1407
|
+
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
|
|
1408
|
+
<button type="button" className={css.toggleButton} aria-disabled="true" aria-label={t('noSession')}>
|
|
1409
|
+
<IconPanelBottomOutline16 />
|
|
1410
|
+
</button>
|
|
1411
|
+
</Tooltip>
|
|
1412
|
+
)}
|
|
1413
|
+
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
|
|
1414
|
+
<button type="button" className={css.toggleButton} aria-disabled="true" aria-label={t('noSession')}>
|
|
1415
|
+
<IconPanelRightOutline16 />
|
|
1416
|
+
</button>
|
|
1417
|
+
</Tooltip>
|
|
1418
|
+
<UserManualButton />
|
|
1419
|
+
</div>}
|
|
1420
|
+
</div>
|
|
1421
|
+
)
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
const bottomPanelHeight = layoutPushSize({
|
|
1425
|
+
narrow,
|
|
1426
|
+
panelOpen: state.panelOpen,
|
|
1427
|
+
// Keep the hidden panel's geometry ready for its slide-in transition;
|
|
1428
|
+
// only the layout push itself becomes zero while it is closed.
|
|
1429
|
+
bottomOpen: true,
|
|
1430
|
+
width: state.width,
|
|
1431
|
+
bottomHeight: state.bottomHeight,
|
|
1432
|
+
viewportWidth: viewport.width,
|
|
1433
|
+
viewportHeight: layoutViewportHeight,
|
|
1434
|
+
}).height
|
|
1435
|
+
|
|
1436
|
+
const onNewTab = (optionId: string): void => {
|
|
1437
|
+
const service = ctx.get('betterSidebar')
|
|
1438
|
+
const descriptor = service?.getTab(optionId)
|
|
1439
|
+
if (service === undefined || descriptor === undefined) return
|
|
1440
|
+
const title = typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title
|
|
1441
|
+
// The session scope rides along: lifecycle callbacks receive it (and
|
|
1442
|
+
// the open stays in the current session, as before).
|
|
1443
|
+
service.openTab({ type: optionId, title }, { sessionId, cwd })
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/**
|
|
1447
|
+
* The explorer's @-reference button: append `@<relative path>` to the
|
|
1448
|
+
* session's composer draft (space-separated). Resolves the session-scope
|
|
1449
|
+
* ctx and the conversation input service at click time; a missing service
|
|
1450
|
+
* or scope degrades to a logged no-op, never a crash.
|
|
1451
|
+
*/
|
|
1452
|
+
/** The tab icon from the tab-type registry (shared by every workbench). */
|
|
1453
|
+
const tabIconOf = (tab: SidebarTab): ReactNode => {
|
|
1454
|
+
const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
|
|
1455
|
+
if (descriptor === undefined) return null
|
|
1456
|
+
return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/**
|
|
1460
|
+
* The tab badge from the tab-type registry: a count (99+ capped) or a
|
|
1461
|
+
* short text pill. A throwing badge is swallowed (no pill) — the tab
|
|
1462
|
+
* strip must never break because a plugin's badge computation failed.
|
|
1463
|
+
*/
|
|
1464
|
+
const tabBadgeOf = (tab: SidebarTab): ReactNode => {
|
|
1465
|
+
const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
|
|
1466
|
+
if (descriptor?.badge === undefined) return null
|
|
1467
|
+
let value: string | number | null | undefined
|
|
1468
|
+
try {
|
|
1469
|
+
value = descriptor.badge(ctx, { sessionId, cwd }, state)
|
|
1470
|
+
} catch (error) {
|
|
1471
|
+
console.error('[dsh-better-sidebar] tab badge error:', error)
|
|
1472
|
+
return null
|
|
1473
|
+
}
|
|
1474
|
+
if (value === null || value === undefined || value === '') return null
|
|
1475
|
+
const text = typeof value === 'number' ? (value > 99 ? '99+' : String(value)) : String(value)
|
|
1476
|
+
return <span className={css.tabBadge}>{text}</span>
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/**
|
|
1480
|
+
* Render one tab's content. `active` (from the workbench) tells whether
|
|
1481
|
+
* this tab is the active one in its pane; combined with the panel's
|
|
1482
|
+
* open/closed state it gates live views (the Subagent topology pauses its
|
|
1483
|
+
* polling while the page is not actually visible). The pane id travels
|
|
1484
|
+
* with the tab so diff tabs can split below their source pane.
|
|
1485
|
+
*/
|
|
1486
|
+
// `placement` decides the visibility contract handed to the tab component:
|
|
1487
|
+
// pane tabs are visible while their panel is open and they are active, but
|
|
1488
|
+
// a free window is its own surface — its tab stays visible no matter what
|
|
1489
|
+
// the panels do (the AGENTS §7.5 contract; plugin components honor
|
|
1490
|
+
// `visible` to pause work, so tying floats to panelOpen would blank them
|
|
1491
|
+
// the moment the sidebar collapses).
|
|
1492
|
+
const renderTab = (tab: SidebarTab, active: boolean, paneId: string, placement: 'top' | 'bottom' | 'float' = 'top') => {
|
|
1493
|
+
// Pinned virtual tabs: pass the home session's scope (sessionId + cwd) so
|
|
1494
|
+
// TerminalView's WS URL resolves to the home PTY, and effectiveTabId so
|
|
1495
|
+
// the descriptor component receives the ORIGINAL tab id (the virtual id
|
|
1496
|
+
// is only a display key). Regular tabs: effectiveTabId is undefined (no
|
|
1497
|
+
// override), scope is the current session's.
|
|
1498
|
+
const home = getPinnedHomeScope(tab)
|
|
1499
|
+
return (
|
|
1500
|
+
<TabContent
|
|
1501
|
+
tab={tab}
|
|
1502
|
+
effectiveTabId={home?.tabId}
|
|
1503
|
+
paneId={paneId}
|
|
1504
|
+
sessionId={home?.sessionId ?? sessionId}
|
|
1505
|
+
cwd={home?.cwd ?? cwd}
|
|
1506
|
+
expanded={state.expanded}
|
|
1507
|
+
revealed={state.revealed ?? []}
|
|
1508
|
+
onToggleDir={(path) => { store.reduce(s => toggleExpanded(s, path)) }}
|
|
1509
|
+
onReferenceFile={referenceInChat}
|
|
1510
|
+
ctx={ctx}
|
|
1511
|
+
store={store}
|
|
1512
|
+
visible={
|
|
1513
|
+
placement === 'float'
|
|
1514
|
+
? true
|
|
1515
|
+
: placement === 'bottom'
|
|
1516
|
+
? state.bottomOpen && active
|
|
1517
|
+
: state.panelOpen && active
|
|
1518
|
+
}
|
|
1519
|
+
onSubagentJump={() => { /* retained for external tab descriptor compatibility */ }}
|
|
1520
|
+
onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }}
|
|
1521
|
+
localeRevision={localeRevision}
|
|
1522
|
+
tabsVersion={tabsVersion}
|
|
1523
|
+
/>
|
|
1524
|
+
)
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
return (
|
|
1528
|
+
<div data-dsh-panel-host {...osFileDragShield}>
|
|
1529
|
+
<AccountMenu />
|
|
1530
|
+
{/*
|
|
1531
|
+
Current DSH: all three controls live in the Session utilities row; the
|
|
1532
|
+
intercepted native rightbar glyph is hidden to leave one sidebar
|
|
1533
|
+
affordance. Older hosts without the header anchor keep the viewport
|
|
1534
|
+
fallback, which alone overlaps and reserves the open panel's tab strip.
|
|
1535
|
+
*/}
|
|
1536
|
+
{nativeToolbarHost !== null ? createPortal(
|
|
1537
|
+
<div className={clsx(css.toggleCluster, css.toggleClusterNative)} data-dsh-toggle-cluster data-dsh-native-toolbar-cluster>
|
|
1538
|
+
{!narrow && (
|
|
1539
|
+
<Tooltip label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')} side="bottom" delayMs={500}>
|
|
1540
|
+
<button
|
|
1541
|
+
type="button"
|
|
1542
|
+
className={css.toggleButton}
|
|
1543
|
+
aria-label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')}
|
|
1544
|
+
onClick={() => { store.reduce(toggleBottomPanel) }}
|
|
1545
|
+
>
|
|
1546
|
+
<IconPanelBottomOutline16 />
|
|
1547
|
+
</button>
|
|
1548
|
+
</Tooltip>
|
|
1549
|
+
)}
|
|
1550
|
+
<Tooltip label={state.panelOpen ? t('collapse') : t('expand')} side="bottom" delayMs={500}>
|
|
1551
|
+
<button
|
|
1552
|
+
type="button"
|
|
1553
|
+
className={css.toggleButton}
|
|
1554
|
+
aria-label={state.panelOpen ? t('collapse') : t('expand')}
|
|
1555
|
+
onClick={() => { store.reduce(togglePanel) }}
|
|
1556
|
+
>
|
|
1557
|
+
<IconPanelRightOutline16 />
|
|
1558
|
+
</button>
|
|
1559
|
+
</Tooltip>
|
|
1560
|
+
<UserManualButton />
|
|
1561
|
+
</div>,
|
|
1562
|
+
nativeToolbarHost,
|
|
1563
|
+
) : <div className={css.toggleCluster} data-dsh-toggle-cluster>
|
|
1564
|
+
{/*
|
|
1565
|
+
Narrow viewports merge the two workbenches into the one drawer —
|
|
1566
|
+
there is no bottom panel, so its toggle button is not offered.
|
|
1567
|
+
*/}
|
|
1568
|
+
{!narrow && (
|
|
1569
|
+
<Tooltip label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')} side="bottom" delayMs={500}>
|
|
1570
|
+
<button
|
|
1571
|
+
type="button"
|
|
1572
|
+
className={css.toggleButton}
|
|
1573
|
+
aria-label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')}
|
|
1574
|
+
onClick={() => { store.reduce(toggleBottomPanel) }}
|
|
1575
|
+
>
|
|
1576
|
+
<IconPanelBottomOutline16 />
|
|
1577
|
+
</button>
|
|
1578
|
+
</Tooltip>
|
|
1579
|
+
)}
|
|
1580
|
+
<Tooltip label={state.panelOpen ? t('collapse') : t('expand')} side="bottom" delayMs={500}>
|
|
1581
|
+
<button
|
|
1582
|
+
type="button"
|
|
1583
|
+
className={css.toggleButton}
|
|
1584
|
+
aria-label={state.panelOpen ? t('collapse') : t('expand')}
|
|
1585
|
+
onClick={() => { store.reduce(togglePanel) }}
|
|
1586
|
+
>
|
|
1587
|
+
<IconPanelRightOutline16 />
|
|
1588
|
+
</button>
|
|
1589
|
+
</Tooltip>
|
|
1590
|
+
<UserManualButton />
|
|
1591
|
+
</div>}
|
|
1592
|
+
{/*
|
|
1593
|
+
The right panel stays mounted while collapsed (hidden off-screen) so
|
|
1594
|
+
the slide in/out can animate; visibility hides it after the slide
|
|
1595
|
+
settles. Its bottom edge follows the bottom panel's height (0 while
|
|
1596
|
+
the bottom panel is closed) — the VSCode-style "sidebar above panel".
|
|
1597
|
+
On NARROW viewports it is a full-width drawer holding both
|
|
1598
|
+
workbenches (see MobileWorkbench); the width drag strip is not
|
|
1599
|
+
offered there — a full-screen sheet has nothing to drag.
|
|
1600
|
+
*/}
|
|
1601
|
+
<div
|
|
1602
|
+
ref={panelRef}
|
|
1603
|
+
className={clsx(css.panel, !state.panelOpen && css.panelHidden)}
|
|
1604
|
+
data-dsh-panel
|
|
1605
|
+
style={{
|
|
1606
|
+
width: narrow ? '100vw' : Math.min(state.width, window.innerWidth),
|
|
1607
|
+
// Narrow drawer: keep the bottom-anchored sheet above the on-screen
|
|
1608
|
+
// keyboard (visualViewport inset); desktop panels are full-height
|
|
1609
|
+
// and unaffected.
|
|
1610
|
+
bottom: narrow && keyboardInset > 0 ? `${keyboardInset}px` : undefined,
|
|
1611
|
+
}}
|
|
1612
|
+
|
|
1613
|
+
data-dragging={anyDragging || undefined}
|
|
1614
|
+
>
|
|
1615
|
+
{!narrow && (
|
|
1616
|
+
<div
|
|
1617
|
+
className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)}
|
|
1618
|
+
|
|
1619
|
+
onPointerDown={(event) => {
|
|
1620
|
+
event.preventDefault()
|
|
1621
|
+
event.currentTarget.setPointerCapture(event.pointerId)
|
|
1622
|
+
dragCommitted.current = false
|
|
1623
|
+
widthDrag.current = { startX: event.clientX, startWidth: state.width }
|
|
1624
|
+
setDraggingWidth(true)
|
|
1625
|
+
}}
|
|
1626
|
+
onPointerMove={(event) => {
|
|
1627
|
+
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
|
1628
|
+
const { startX, startWidth } = widthDrag.current
|
|
1629
|
+
const width = clampWidth(startWidth + (startX - event.clientX))
|
|
1630
|
+
const height = pushedBottomHeight(state.bottomOpen, state.bottomHeight)
|
|
1631
|
+
scheduleDrag(width, height)
|
|
1632
|
+
}}
|
|
1633
|
+
onPointerUp={(event) => {
|
|
1634
|
+
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
|
1635
|
+
if (dragCommitted.current) return
|
|
1636
|
+
dragCommitted.current = true
|
|
1637
|
+
event.currentTarget.releasePointerCapture(event.pointerId)
|
|
1638
|
+
const { startX, startWidth } = widthDrag.current
|
|
1639
|
+
// The up position is the pointer's FINAL position — a fast
|
|
1640
|
+
// flick's tail is coalesced into the up event, so the last
|
|
1641
|
+
// pointermove (the rAF pending value) can be stale. Commit
|
|
1642
|
+
// from the up position (v0.13.1 semantics; issue #247).
|
|
1643
|
+
const width = clampWidth(startWidth + (startX - event.clientX))
|
|
1644
|
+
const height = pushedBottomHeight(state.bottomOpen, state.bottomHeight)
|
|
1645
|
+
commitDrag(width, height, s => setWidth(s, width))
|
|
1646
|
+
setDraggingWidth(false)
|
|
1647
|
+
}}
|
|
1648
|
+
onPointerCancel={(event) => { abortDrag(() => setDraggingWidth(false), event) }}
|
|
1649
|
+
onLostPointerCapture={() => { abortDrag(() => setDraggingWidth(false)) }}
|
|
1650
|
+
/>
|
|
1651
|
+
)}
|
|
1652
|
+
<div className={css.panelBody}>
|
|
1653
|
+
<Workbench
|
|
1654
|
+
state={state}
|
|
1655
|
+
tree={augmentedTree}
|
|
1656
|
+
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
|
|
1657
|
+
actions={wrappedActions}
|
|
1658
|
+
onNewTab={onNewTab}
|
|
1659
|
+
renderTab={renderTab}
|
|
1660
|
+
getTabIcon={tabIconOf}
|
|
1661
|
+
getTabBadge={tabBadgeOf}
|
|
1662
|
+
/>
|
|
1663
|
+
</div>
|
|
1664
|
+
{/*
|
|
1665
|
+
The shared corner (only while BOTH panels are open): the
|
|
1666
|
+
intersection of the right panel's left edge and the bottom panel's
|
|
1667
|
+
top edge. Horizontal drags resize the right panel's width, vertical
|
|
1668
|
+
drags the bottom panel's height — the two panels drag against each
|
|
1669
|
+
other. Rendered INSIDE the right panel and positioned by CSS
|
|
1670
|
+
relative to it (left edge + the bottom panel's height via the
|
|
1671
|
+
--dsh-sidebar-height layout variable) — no JS-written viewport
|
|
1672
|
+
coordinates to keep in sync. (Never on narrow viewports: the
|
|
1673
|
+
bottom panel does not exist there.)
|
|
1674
|
+
*/}
|
|
1675
|
+
{!narrow && state.panelOpen && state.bottomOpen && (
|
|
1676
|
+
<div
|
|
1677
|
+
className={css.cornerHandle}
|
|
1678
|
+
data-dragging={draggingCorner || undefined}
|
|
1679
|
+
onPointerDown={(event) => {
|
|
1680
|
+
event.preventDefault()
|
|
1681
|
+
event.currentTarget.setPointerCapture(event.pointerId)
|
|
1682
|
+
dragCommitted.current = false
|
|
1683
|
+
cornerDrag.current = {
|
|
1684
|
+
startX: event.clientX,
|
|
1685
|
+
startY: event.clientY,
|
|
1686
|
+
startWidth: state.width,
|
|
1687
|
+
startHeight: state.bottomHeight,
|
|
1688
|
+
}
|
|
1689
|
+
setDraggingCorner(true)
|
|
1690
|
+
}}
|
|
1691
|
+
onPointerMove={(event) => {
|
|
1692
|
+
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
|
1693
|
+
const { startX, startY, startWidth, startHeight } = cornerDrag.current
|
|
1694
|
+
const width = clampWidth(startWidth + (startX - event.clientX))
|
|
1695
|
+
const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY)))
|
|
1696
|
+
scheduleDrag(width, height)
|
|
1697
|
+
}}
|
|
1698
|
+
onPointerUp={(event) => {
|
|
1699
|
+
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
|
1700
|
+
if (dragCommitted.current) return
|
|
1701
|
+
dragCommitted.current = true
|
|
1702
|
+
event.currentTarget.releasePointerCapture(event.pointerId)
|
|
1703
|
+
const { startX, startY, startWidth, startHeight } = cornerDrag.current
|
|
1704
|
+
// Up position wins over the rAF pending value (see the width
|
|
1705
|
+
// strip handler — issue #247).
|
|
1706
|
+
const width = clampWidth(startWidth + (startX - event.clientX))
|
|
1707
|
+
const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY)))
|
|
1708
|
+
commitDrag(width, height, s => setBottomHeight(setWidth(s, width), height))
|
|
1709
|
+
setDraggingCorner(false)
|
|
1710
|
+
}}
|
|
1711
|
+
onPointerCancel={(event) => { abortDrag(() => setDraggingCorner(false), event) }}
|
|
1712
|
+
onLostPointerCapture={() => { abortDrag(() => setDraggingCorner(false)) }}
|
|
1713
|
+
/>
|
|
1714
|
+
)}
|
|
1715
|
+
</div>
|
|
1716
|
+
{/*
|
|
1717
|
+
The bottom panel: a second, independent workbench. It squeezes ONLY
|
|
1718
|
+
the center column (the agent output area): it starts at the app
|
|
1719
|
+
shell's own left sidebar and ends at the right panel's left edge —
|
|
1720
|
+
neither sidebar gives up any position (the right panel keeps its
|
|
1721
|
+
full height). Its resize strip is the top edge; hidden by sliding
|
|
1722
|
+
down like the right panel. On NARROW viewports it does not exist —
|
|
1723
|
+
the bottom workbench lives inside the drawer (MobileWorkbench).
|
|
1724
|
+
*/}
|
|
1725
|
+
{/* The bottom panel only becomes VISIBLE once the center column is
|
|
1726
|
+
measured: before that, `centerRect` is the {0,0} fallback and
|
|
1727
|
+
`right` computes to the full viewport width — the panel (and its
|
|
1728
|
+
overflow content) would flash full-width for a frame until the
|
|
1729
|
+
first measurement lands. Rendering stays unconditional so the
|
|
1730
|
+
mount/render chain (auto-terminal etc.) is never gated on
|
|
1731
|
+
geometry. */}
|
|
1732
|
+
{!narrow && (
|
|
1733
|
+
<div
|
|
1734
|
+
ref={bottomRef}
|
|
1735
|
+
className={clsx(css.bottomPanel, !state.bottomOpen && css.bottomPanelHidden)}
|
|
1736
|
+
data-dsh-panel
|
|
1737
|
+
data-dsh-bottom-panel
|
|
1738
|
+
style={{
|
|
1739
|
+
height: bottomPanelHeight,
|
|
1740
|
+
left: centerRectRef.current.left,
|
|
1741
|
+
// Keep the panel above the on-screen keyboard when the visual
|
|
1742
|
+
// viewport shrinks (see the keyboardInset effect).
|
|
1743
|
+
bottom: keyboardInset > 0 ? `${keyboardInset}px` : undefined,
|
|
1744
|
+
// Direct from the center column's measured right edge: the bottom
|
|
1745
|
+
// panel spans ONLY the center column, ending exactly at the
|
|
1746
|
+
// details column's left edge (the details column sits between the
|
|
1747
|
+
// center and the right panel, and the right panel's margin-right
|
|
1748
|
+
// push is already baked into centerRect.right).
|
|
1749
|
+
right: window.innerWidth - centerRectRef.current.right,
|
|
1750
|
+
// The seam against the open right panel needs its own hairline
|
|
1751
|
+
// (the right panel's border-left alone is covered by this panel's
|
|
1752
|
+
// fill — without it the corner looks cut off).
|
|
1753
|
+
borderRight: state.panelOpen ? '1px solid var(--dsw-alias-border-l2)' : undefined,
|
|
1754
|
+
// Unmeasured center column → keep the panel invisible (zero-size
|
|
1755
|
+
// geometry would flash full-width overflow instead).
|
|
1756
|
+
visibility: centerMeasured ? undefined : 'hidden',
|
|
1757
|
+
}}
|
|
1758
|
+
|
|
1759
|
+
data-dragging={(draggingBottom || draggingCorner) || undefined}
|
|
1760
|
+
>
|
|
1761
|
+
<div
|
|
1762
|
+
className={clsx(css.bottomResize, draggingBottom && css.bottomResizeActive)}
|
|
1763
|
+
|
|
1764
|
+
onPointerDown={(event) => {
|
|
1765
|
+
event.preventDefault()
|
|
1766
|
+
event.currentTarget.setPointerCapture(event.pointerId)
|
|
1767
|
+
dragCommitted.current = false
|
|
1768
|
+
bottomDrag.current = { startY: event.clientY, startHeight: state.bottomHeight }
|
|
1769
|
+
setDraggingBottom(true)
|
|
1770
|
+
}}
|
|
1771
|
+
onPointerMove={(event) => {
|
|
1772
|
+
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
|
1773
|
+
const { startY, startHeight } = bottomDrag.current
|
|
1774
|
+
const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY)))
|
|
1775
|
+
scheduleDrag(Math.min(state.width, window.innerWidth), height)
|
|
1776
|
+
}}
|
|
1777
|
+
onPointerUp={(event) => {
|
|
1778
|
+
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
|
1779
|
+
if (dragCommitted.current) return
|
|
1780
|
+
dragCommitted.current = true
|
|
1781
|
+
event.currentTarget.releasePointerCapture(event.pointerId)
|
|
1782
|
+
const { startY, startHeight } = bottomDrag.current
|
|
1783
|
+
// Up position wins over the rAF pending value (see the width
|
|
1784
|
+
// strip handler — issue #247).
|
|
1785
|
+
const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY)))
|
|
1786
|
+
commitDrag(Math.min(state.width, window.innerWidth), height, s => setBottomHeight(s, height))
|
|
1787
|
+
setDraggingBottom(false)
|
|
1788
|
+
}}
|
|
1789
|
+
onPointerCancel={(event) => { abortDrag(() => setDraggingBottom(false), event) }}
|
|
1790
|
+
onLostPointerCapture={() => { abortDrag(() => setDraggingBottom(false)) }}
|
|
1791
|
+
/>
|
|
1792
|
+
{/*
|
|
1793
|
+
The bottom panel's own close control at its tab strip's right end
|
|
1794
|
+
(the strip reserves the width via CSS so the + menu never hides
|
|
1795
|
+
under it): one tap collapses the panel.
|
|
1796
|
+
*/}
|
|
1797
|
+
<Tooltip label={t('collapseBottomPanel')} side="bottom" delayMs={500}>
|
|
1798
|
+
<button
|
|
1799
|
+
type="button"
|
|
1800
|
+
className={css.bottomClose}
|
|
1801
|
+
aria-label={t('collapseBottomPanel')}
|
|
1802
|
+
onClick={() => { store.reduce(toggleBottomPanel) }}
|
|
1803
|
+
>
|
|
1804
|
+
<IconCloseFill14 />
|
|
1805
|
+
</button>
|
|
1806
|
+
</Tooltip>
|
|
1807
|
+
<div className={css.panelBody}>
|
|
1808
|
+
<Workbench
|
|
1809
|
+
state={state}
|
|
1810
|
+
tree={state.bottomSplits}
|
|
1811
|
+
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
|
|
1812
|
+
actions={actions}
|
|
1813
|
+
onNewTab={onNewTab}
|
|
1814
|
+
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, 'bottom')}
|
|
1815
|
+
getTabIcon={tabIconOf}
|
|
1816
|
+
getTabBadge={tabBadgeOf}
|
|
1817
|
+
/>
|
|
1818
|
+
</div>
|
|
1819
|
+
</div>
|
|
1820
|
+
)}
|
|
1821
|
+
{/*
|
|
1822
|
+
Free windows: tabs dragged out onto the conversation area (or floated
|
|
1823
|
+
from the tab context menu). They live in the panel host like the
|
|
1824
|
+
panels (viewport coordinates, immune to desktop-shell transforms) but
|
|
1825
|
+
are independent of panel state — a window stays up while panels
|
|
1826
|
+
collapse. The floats array's order is the stacking order; the content
|
|
1827
|
+
reuses the regular tab renderer, so every tab type floats unchanged.
|
|
1828
|
+
*/}
|
|
1829
|
+
{state.floats.map(float => (
|
|
1830
|
+
<FreeWindow
|
|
1831
|
+
key={float.id}
|
|
1832
|
+
float={float}
|
|
1833
|
+
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, 'float')}
|
|
1834
|
+
getTabIcon={tabIconOf}
|
|
1835
|
+
onRaise={() => { store.reduce(s => raiseFloat(s, float.id)) }}
|
|
1836
|
+
onMove={(x, y) => { store.reduce(s => moveFloat(s, float.id, x, y)) }}
|
|
1837
|
+
onResize={(w, h) => { store.reduce(s => resizeFloat(s, float.id, w, h)) }}
|
|
1838
|
+
onDock={(paneId) => { store.reduce(s => dockFloat(s, float.id, paneId ?? undefined)) }}
|
|
1839
|
+
onClose={() => { ctx.get('betterSidebar')?.closeTab(float.tab.id, sessionId === undefined ? undefined : { sessionId, cwd }) }}
|
|
1840
|
+
/>
|
|
1841
|
+
))}
|
|
1842
|
+
{/*
|
|
1843
|
+
The drag-out hint: while a tab drag hovers the conversation column,
|
|
1844
|
+
a dashed overlay marks the drop zone there (pointer-transparent — it
|
|
1845
|
+
must not disturb the drag it describes).
|
|
1846
|
+
*/}
|
|
1847
|
+
{floatHint !== null && (
|
|
1848
|
+
<div
|
|
1849
|
+
className={css.floatDropHint}
|
|
1850
|
+
style={{ left: floatHint.left, top: floatHint.top, width: floatHint.width, height: floatHint.height }}
|
|
1851
|
+
>
|
|
1852
|
+
<span className={css.floatDropHintLabel}>{t('floatDropHint')}</span>
|
|
1853
|
+
</div>
|
|
1854
|
+
)}
|
|
1855
|
+
</div>
|
|
1856
|
+
)
|
|
1857
|
+
}
|