ompchamber 1.0.0 → 1.2.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/dist/client/index.html +1 -1
- package/dist/client/static/css/index.b938f4a693.css +2 -0
- package/dist/client/static/js/77.8022a800b5.js +139 -0
- package/dist/client/static/js/async/{1k.5974df3b87.js → 1k.a411fe5b43.js} +2 -2
- package/dist/client/static/js/async/{3y.71f2ccf3b6.js → 3y.3186ecdd63.js} +5 -5
- package/dist/client/static/js/async/5b.d88304bc13.js +1 -0
- package/dist/client/static/js/async/66.86544ad741.js +93 -0
- package/dist/client/static/js/async/7g.db82aca7ba.js +1 -0
- package/dist/client/static/js/async/7l.fadbbc0583.js +1 -0
- package/dist/client/static/js/async/{dc.856fda6560.js → ar.ad5c8822a8.js} +2 -2
- package/dist/client/static/js/async/cx.9c8268d542.js +226 -0
- package/dist/client/static/js/async/e.6443e739a2.js +79 -0
- package/dist/client/static/js/async/f7.46971e9d6d.js +2 -0
- package/dist/client/static/js/async/g.ee41bfed7f.js +2 -0
- package/dist/client/static/js/async/h1.d7091cef78.js +1 -0
- package/dist/client/static/js/async/hs.e7c59d8090.js +41 -0
- package/dist/client/static/js/async/jl.4911fa3383.js +1 -0
- package/dist/client/static/js/async/{k.7466330d1a.js → k.d16cd09ae7.js} +2 -2
- package/dist/client/static/js/async/{m.c038c71b9f.js → m.2e1ea3db9e.js} +2 -2
- package/dist/client/static/js/async/m3.d2b05f9604.js +2 -0
- package/dist/client/static/js/async/mt.853be2b5aa.js +1 -0
- package/dist/client/static/js/async/{oc.9c060c9779.js → oc.eb22093f05.js} +1 -1
- package/dist/client/static/js/async/{og.634fba99ac.js → og.53f068804c.js} +1 -1
- package/dist/client/static/js/async/{5b.0d6ae50fe8.js → q.b453193538.js} +10 -10
- package/dist/client/static/js/async/q5.88b50ffa95.js +286 -0
- package/dist/client/static/js/async/q8.7f1b48e9cd.js +21 -0
- package/dist/client/static/js/async/{sy.d16be843a7.js → s.aad2ecebc6.js} +1 -1
- package/dist/client/static/js/async/{tr.e878658140.js → tr.2a0d2b7600.js} +1 -1
- package/dist/client/static/js/async/vt.20406a5604.js +1 -0
- package/dist/client/static/js/async/x1.fe8af17016.js +90 -0
- package/dist/client/static/js/async/yg.5a94e1fc8a.js +149 -0
- package/dist/client/static/js/async/yx.af2936c368.js +10 -0
- package/dist/client/static/js/index.57b39ffa7f.js +34 -0
- package/package.json +1 -1
- package/src/client/components/common/ComposerPicker.tsx +16 -4
- package/src/client/components/common/ComposerTextarea.tsx +1 -0
- package/src/client/components/common/file-icon/index.tsx +4 -1
- package/src/client/components/common/image-viewer/index.tsx +224 -0
- package/src/client/components/layout/RightActivityBar.tsx +1 -1
- package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
- package/src/client/components/layout/desktop-layout/index.tsx +25 -96
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
- package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
- package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +26 -12
- package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +57 -43
- package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +31 -24
- package/src/client/components/settings/Modal.tsx +1 -0
- package/src/client/components/settings/categories/ChatSettings.tsx +6 -2
- package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +99 -0
- package/src/client/components/workspace/btw-panel/AskCard.tsx +155 -0
- package/src/client/components/workspace/btw-panel/Composer.tsx +181 -0
- package/src/client/components/workspace/btw-panel/Messages.tsx +100 -0
- package/src/client/components/workspace/btw-panel/TopicMenu.tsx +106 -0
- package/src/client/components/workspace/btw-panel/attachments.ts +80 -0
- package/src/client/components/workspace/btw-panel/index.tsx +83 -0
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +39 -20
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
- package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +10 -6
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +17 -0
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +67 -43
- package/src/client/components/workspace/chat-timeline/index.tsx +14 -9
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +25 -7
- package/src/client/components/workspace/editor/Toolbar.tsx +32 -23
- package/src/client/components/workspace/editor/index.tsx +45 -36
- package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
- package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
- package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
- package/src/client/hooks/chat/btw/index.ts +180 -0
- package/src/client/hooks/chat/btw/intercept.ts +43 -0
- package/src/client/hooks/chat/btw/mode.ts +179 -0
- package/src/client/hooks/chat/composer/attachments.ts +198 -0
- package/src/client/hooks/chat/composer/drop-entries.ts +204 -0
- package/src/client/hooks/chat/composer/drop-payload.test.ts +97 -0
- package/src/client/hooks/chat/composer/drop-payload.ts +130 -0
- package/src/client/hooks/chat/composer/drop-references.ts +106 -0
- package/src/client/hooks/chat/composer/file-drop.test.ts +194 -0
- package/src/client/hooks/chat/composer/file-drop.ts +226 -0
- package/src/client/hooks/chat/composer/file-reads.test.ts +211 -0
- package/src/client/hooks/chat/composer/file-reads.ts +238 -0
- package/src/client/hooks/chat/composer/index.ts +35 -9
- package/src/client/hooks/chat/composer/pipeline.ts +181 -0
- package/src/client/hooks/chat/omp/index.ts +3 -3
- package/src/client/hooks/chat/omp/prompt-send.ts +5 -5
- package/src/client/hooks/chat/timeline/actions.ts +40 -4
- package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
- package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
- package/src/client/hooks/chat/timeline/index.ts +29 -9
- package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
- package/src/client/hooks/chat/timeline/send.ts +45 -70
- package/src/client/hooks/chat/timeline/session-load.ts +2 -1
- package/src/client/hooks/chat/timeline/session-pagination.ts +161 -14
- package/src/client/hooks/chat/timeline/user-turns.ts +149 -0
- package/src/client/hooks/editor/file-actions.ts +49 -0
- package/src/client/hooks/editor/use-file-editor.ts +38 -27
- package/src/client/hooks/editor/use-file-transfer.ts +78 -0
- package/src/client/hooks/workspace/available-width.ts +33 -0
- package/src/client/hooks/workspace/panel-widths.ts +34 -37
- package/src/client/tailwind.css +22 -0
- package/src/server/db.server.ts +21 -287
- package/src/server/lib/btw/frames.test.ts +68 -0
- package/src/server/lib/btw/frames.ts +56 -0
- package/src/server/lib/btw/prompt.ts +28 -0
- package/src/server/lib/btw/registry.server.ts +141 -0
- package/src/server/lib/btw/runtime.server.ts +324 -0
- package/src/server/lib/btw/schema.server.ts +44 -0
- package/src/server/lib/btw/service.server.ts +171 -0
- package/src/server/lib/btw/session-copy.server.ts +213 -0
- package/src/server/lib/btw/session-copy.test.ts +166 -0
- package/src/server/lib/btw/store.server.ts +240 -0
- package/src/server/lib/db/schema.ts +110 -0
- package/src/server/lib/db/seed.ts +160 -0
- package/src/server/lib/db/workspace-sync.ts +70 -0
- package/src/server/lib/fs/root.ts +37 -0
- package/src/server/lib/models/preferences.server.ts +110 -0
- package/src/server/lib/models/registry.server.ts +206 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +207 -0
- package/src/server/lib/omp/rpc/manager.ts +27 -123
- package/src/server/lib/omp/rpc/utility.ts +7 -1
- package/src/server/lib/omp/session/auto-title.server.test.ts +137 -0
- package/src/server/lib/omp/session/auto-title.server.ts +152 -0
- package/src/server/lib/omp/session/blobs.server.test.ts +100 -0
- package/src/server/lib/omp/session/blobs.server.ts +76 -0
- package/src/server/lib/omp/session/file-scan.ts +174 -0
- package/src/server/lib/omp/session/files.test.ts +147 -0
- package/src/server/lib/omp/session/files.ts +52 -35
- package/src/server/lib/omp/session/locator.ts +2 -2
- package/src/server/lib/omp/session/merge-stored.test.ts +118 -0
- package/src/server/lib/omp/session/merge-stored.ts +171 -0
- package/src/server/lib/omp/session/messages.ts +34 -0
- package/src/server/lib/omp/session/reader.ts +2 -2
- package/src/server/lib/omp/session/subagent-presence.ts +9 -6
- package/src/server/lib/queue/delivery.server.test.ts +260 -0
- package/src/server/lib/queue/delivery.server.ts +63 -11
- package/src/server/plugins/ssr.ts +29 -2
- package/src/server/routes/btw/events.ts +70 -0
- package/src/server/routes/btw/index.ts +8 -0
- package/src/server/routes/btw/topics.ts +87 -0
- package/src/server/routes/btw/ws.ts +136 -0
- package/src/server/routes/chat/index.ts +2 -0
- package/src/server/routes/chat/session.ts +1 -115
- package/src/server/routes/chat/turns.ts +87 -0
- package/src/server/routes/fs/index.ts +4 -1
- package/src/server/routes/fs/raw.ts +85 -0
- package/src/server/routes/fs/read.ts +58 -3
- package/src/server/routes/index.ts +7 -1
- package/src/server/routes/models/root.ts +88 -215
- package/src/server/routes/settings/commands.ts +59 -18
- package/src/shared/lib/chat/attachments.test.ts +208 -0
- package/src/shared/lib/chat/attachments.ts +200 -15
- package/src/shared/lib/chat/btw/client.ts +94 -0
- package/src/shared/lib/chat/composer/client.test.ts +48 -0
- package/src/shared/lib/chat/composer/client.ts +23 -9
- package/src/shared/lib/chat/composer/commands.ts +290 -0
- package/src/shared/lib/chat/composer/composer.test.ts +195 -0
- package/src/shared/lib/chat/composer/filter.ts +21 -7
- package/src/shared/lib/chat/composer/trigger.ts +154 -21
- package/src/shared/lib/chat/omp/agent-events.ts +33 -80
- package/src/shared/lib/chat/omp/socket.ts +11 -6
- package/src/shared/lib/chat/omp/sse.ts +11 -6
- package/src/shared/lib/chat/omp/tool-results.ts +102 -0
- package/src/shared/lib/chat/omp/transport.ts +19 -3
- package/src/shared/lib/chat/text-file.ts +148 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -32
- package/src/shared/lib/chat/timeline/stream-coalescer.ts +66 -0
- package/src/shared/lib/chat/timeline/turns.ts +7 -0
- package/src/shared/lib/fs/file-kind.ts +50 -0
- package/src/shared/lib/fs/paths.ts +19 -1
- package/src/shared/lib/markdown/math.test.ts +20 -3
- package/src/shared/lib/models/preferences.test.ts +131 -0
- package/src/shared/lib/models/preferences.ts +88 -0
- package/src/shared/lib/models/provider-models.ts +8 -1
- package/src/shared/lib/omp/session/inlined-attachments.test.ts +82 -0
- package/src/shared/lib/omp/session/mapper.ts +24 -4
- package/src/shared/lib/omp/session/messages-map.ts +16 -3
- package/src/shared/lib/omp/session/messages-parse.ts +2 -2
- package/src/shared/lib/omp/session/parse-message-blocks.ts +85 -5
- package/src/shared/lib/omp/subagent/label.test.ts +94 -0
- package/src/shared/lib/omp/subagent/label.ts +84 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
- package/src/shared/lib/workspace/panel-widths.ts +161 -30
- package/src/shared/lib/workspace/right-panels.ts +50 -11
- package/src/shared/lib/workspace/session-state/store.ts +3 -0
- package/src/shared/types/btw.ts +65 -0
- package/src/shared/types/chat.ts +77 -66
- package/src/shared/types/index.ts +2 -0
- package/src/shared/types/models.ts +101 -0
- package/src/shared/types/omp/agent.ts +14 -1
- package/src/shared/types/omp/session.ts +4 -1
- package/src/shared/types/omp/subagent.ts +4 -0
- package/src/shared/types/settings/command.ts +13 -0
- package/src/shared/types/settings/state.ts +5 -0
- package/dist/client/static/css/index.e880517fcd.css +0 -2
- package/dist/client/static/js/async/5bh.8b2363424f.js +0 -1
- package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
- package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
- package/dist/client/static/js/async/7l.7a6a377ecc.js +0 -1
- package/dist/client/static/js/async/cx.ca5feec570.js +0 -226
- package/dist/client/static/js/async/e.93214d622b.js +0 -79
- package/dist/client/static/js/async/g.a06ca3b3e9.js +0 -2
- package/dist/client/static/js/async/h1.e8d5aa8e80.js +0 -1
- package/dist/client/static/js/async/hs.6d480d0ded.js +0 -41
- package/dist/client/static/js/async/jl.874d352278.js +0 -1
- package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
- package/dist/client/static/js/async/nam.b5663e919f.js +0 -10
- package/dist/client/static/js/async/q.23af2a358f.js +0 -21
- package/dist/client/static/js/async/q5.e683f7c26e.js +0 -286
- package/dist/client/static/js/async/s.222ee974f6.js +0 -2
- package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
- package/dist/client/static/js/async/vt.42416b707d.js +0 -1
- package/dist/client/static/js/async/x1.da8687159b.js +0 -90
- package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
- package/dist/client/static/js/index.0de15c5e17.js +0 -33
- package/dist/client/static/js/jp.50817896f3.js +0 -139
- /package/dist/client/static/js/{jp.50817896f3.js.LICENSE.txt → 77.8022a800b5.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{1k.5974df3b87.js.LICENSE.txt → 1k.a411fe5b43.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{3y.71f2ccf3b6.js.LICENSE.txt → 3y.3186ecdd63.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{dc.856fda6560.js.LICENSE.txt → ar.ad5c8822a8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.c038c71b9f.js.LICENSE.txt → f7.46971e9d6d.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → g.ee41bfed7f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.23af2a358f.js.LICENSE.txt → m.2e1ea3db9e.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{s.222ee974f6.js.LICENSE.txt → m3.d2b05f9604.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.a06ca3b3e9.js.LICENSE.txt → q.b453193538.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → q8.7f1b48e9cd.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{nam.b5663e919f.js.LICENSE.txt → yx.af2936c368.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.0de15c5e17.js.LICENSE.txt → index.57b39ffa7f.js.LICENSE.txt} +0 -0
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
1
|
+
import { useCallback, useMemo, useRef, useState } from 'preact/hooks';
|
|
3
2
|
import { useSearchParams } from '@/client/lib/router/search-params';
|
|
4
3
|
import { Group, Panel, type PanelImperativeHandle } from '@/client/components/layout/desktop-layout/resizer';
|
|
5
4
|
import { SessionSidebar } from '@/client/components/layout/session-sidebar/index';
|
|
6
|
-
import {
|
|
5
|
+
import type { RightPanelType } from '@/shared/lib/workspace/right-panels';
|
|
7
6
|
import { SettingsModal } from '@/client/components/settings/LazyModal';
|
|
8
|
-
import { PanelLeft } from 'lucide-preact';
|
|
9
7
|
import type { SettingsCategoryId } from '@/shared/types';
|
|
10
8
|
import { activeProjectForSession } from '@/shared/lib/workspace/active-project';
|
|
11
9
|
import { useFileTabs } from '@/client/hooks/workspace/file-tabs';
|
|
@@ -15,9 +13,8 @@ import { WorkspacePanels } from '@/client/components/layout/desktop-layout/Works
|
|
|
15
13
|
import { useAgentStreamStatus } from '@/client/hooks/chat/omp/status';
|
|
16
14
|
import { usePanelWidths } from '@/client/hooks/workspace/panel-widths';
|
|
17
15
|
import { useSidebarData } from '@/client/hooks/chat/omp/session-list';
|
|
18
|
-
import {
|
|
16
|
+
import { DEFAULT_LEFT_PANEL_WIDTH, MAX_LEFT_PANEL_WIDTH, MIN_LEFT_PANEL_WIDTH, type EditorWidthMode } from '@/shared/lib/workspace/panel-widths';
|
|
19
17
|
import { ResizeHandle } from '@/client/components/layout/desktop-layout/ResizeHandle';
|
|
20
|
-
import { writeSetting } from '@/shared/lib/settings/client';
|
|
21
18
|
import { useChamberEvent, useWindowEvent } from '@/client/hooks/ui/window-event';
|
|
22
19
|
|
|
23
20
|
interface DesktopLayoutProps {
|
|
@@ -26,29 +23,17 @@ interface DesktopLayoutProps {
|
|
|
26
23
|
appSettings?: Record<string, any>;
|
|
27
24
|
}
|
|
28
25
|
|
|
29
|
-
/**
|
|
30
|
-
* Push a remembered width onto a mounted panel. Panels report nothing while
|
|
31
|
-
* unmounted, so a missing width (never resized) or a missing handle (panel
|
|
32
|
-
* closed) simply leaves the panel at whatever its `defaultSize` derived.
|
|
33
|
-
*/
|
|
34
|
-
function applyWidth(
|
|
35
|
-
ref: RefObject<PanelImperativeHandle | null>,
|
|
36
|
-
px: number | undefined,
|
|
37
|
-
) {
|
|
38
|
-
if (px != null) ref.current?.resize(px);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
26
|
export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }: DesktopLayoutProps) {
|
|
42
27
|
const { folders } = useSidebarData();
|
|
43
28
|
const [showRightPanel, setShowRightPanel] = useSessionState<boolean>('layout.showRightPanel', appSettings.showRightPanel ?? true);
|
|
44
29
|
const [activeRightPanel, setActiveRightPanel] = useSessionState<RightPanelType>('layout.activeRightPanel', (appSettings.activeRightPanel as RightPanelType) ?? 'files');
|
|
45
|
-
const [showLeftPanel, setShowLeftPanel] =
|
|
46
|
-
const { widths: panelWidths,
|
|
30
|
+
const [showLeftPanel, setShowLeftPanel] = useSessionState<boolean>('layout.showLeftPanel', appSettings.showLeftPanel ?? true);
|
|
31
|
+
const { widths: panelWidths, commitWidths } = usePanelWidths(appSettings, activeRightPanel);
|
|
47
32
|
|
|
48
33
|
const editorPanelRef = useRef<PanelImperativeHandle | null>(null);
|
|
49
34
|
const rightPanelRef = useRef<PanelImperativeHandle | null>(null);
|
|
50
35
|
const leftPanelRef = useRef<PanelImperativeHandle | null>(null);
|
|
51
|
-
const [userToggledEditor, setUserToggledEditor] =
|
|
36
|
+
const [userToggledEditor, setUserToggledEditor] = useSessionState<boolean | null>('layout.userToggledEditor', appSettings.userToggledEditor ?? null);
|
|
52
37
|
|
|
53
38
|
const [searchParams] = useSearchParams();
|
|
54
39
|
const folderIdParam = searchParams.get('folderId');
|
|
@@ -63,8 +48,7 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
|
|
|
63
48
|
|
|
64
49
|
const handleOpenTab = useCallback(() => {
|
|
65
50
|
setUserToggledEditor(true);
|
|
66
|
-
|
|
67
|
-
}, []);
|
|
51
|
+
}, [setUserToggledEditor]);
|
|
68
52
|
|
|
69
53
|
const {
|
|
70
54
|
openedFiles,
|
|
@@ -83,42 +67,13 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
|
|
|
83
67
|
[openedFiles, activeFileId],
|
|
84
68
|
);
|
|
85
69
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
useEffect(() => {
|
|
94
|
-
const frame = requestAnimationFrame(() => {
|
|
95
|
-
const widths = widthsRef.current;
|
|
96
|
-
if (widths) applyWidth(leftPanelRef, widths.left);
|
|
97
|
-
});
|
|
98
|
-
return () => cancelAnimationFrame(frame);
|
|
99
|
-
}, [showLeftPanel, widthsRef]);
|
|
100
|
-
|
|
101
|
-
// The right panel survives activity-bar view switches (only CSS hides the
|
|
102
|
-
// outgoing view), so a mounted panel never re-reads its defaultSize. A view
|
|
103
|
-
// that has been resized gets that width pushed here; one that never has gets
|
|
104
|
-
// the width it is supposed to open at, which the element would otherwise
|
|
105
|
-
// have taken from the view it was mounted under.
|
|
106
|
-
useEffect(() => {
|
|
107
|
-
const frame = requestAnimationFrame(() => {
|
|
108
|
-
applyWidth(rightPanelRef, widthsRef.current?.right?.[activeRightPanel] ?? DEFAULT_RIGHT_PANEL_WIDTHS[activeRightPanel]);
|
|
109
|
-
});
|
|
110
|
-
return () => cancelAnimationFrame(frame);
|
|
111
|
-
}, [showRightPanel, activeRightPanel, widthsRef]);
|
|
112
|
-
|
|
113
|
-
// Same story for the editor across a source↔diff tab switch: the panel
|
|
114
|
-
// stays mounted, so the width its tab kind remembers — or opens at, for a
|
|
115
|
-
// diff, which is wider than a source view — is re-applied here.
|
|
116
|
-
useEffect(() => {
|
|
117
|
-
const frame = requestAnimationFrame(() => {
|
|
118
|
-
applyWidth(editorPanelRef, widthsRef.current?.[editorWidthMode] ?? DEFAULT_PANEL_WIDTHS[editorWidthMode]);
|
|
119
|
-
});
|
|
120
|
-
return () => cancelAnimationFrame(frame);
|
|
121
|
-
}, [showEditor, editorWidthMode, widthsRef]);
|
|
70
|
+
// Width restoration is declarative: every panel's `defaultSize` is derived
|
|
71
|
+
// from the remembered width for its current slot, and `Panel` adopts a
|
|
72
|
+
// changed `defaultSize` itself. That covers the two cases that used to need
|
|
73
|
+
// an imperative push — a right-panel view switch and an editor source↔diff
|
|
74
|
+
// switch, both of which keep the panel mounted while only its width slot
|
|
75
|
+
// changes — and it cannot go stale, because there is no cached layout to
|
|
76
|
+
// disagree with the props.
|
|
122
77
|
|
|
123
78
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
124
79
|
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('appearance');
|
|
@@ -153,41 +108,30 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
|
|
|
153
108
|
const handleChangeRightPanel = (panel: RightPanelType) => {
|
|
154
109
|
let nextShow = showRightPanel;
|
|
155
110
|
let nextActive = activeRightPanel;
|
|
156
|
-
|
|
111
|
+
|
|
157
112
|
if (activeRightPanel === panel && showRightPanel) {
|
|
158
113
|
nextShow = false;
|
|
159
114
|
} else {
|
|
160
115
|
nextActive = panel;
|
|
161
116
|
nextShow = true;
|
|
162
117
|
}
|
|
163
|
-
|
|
118
|
+
|
|
164
119
|
setShowRightPanel(nextShow);
|
|
165
120
|
setActiveRightPanel(nextActive);
|
|
166
|
-
|
|
167
|
-
// The width is not set here: the restore effect re-applies whichever width
|
|
168
|
-
// this view remembers, and a view that opens for the first time derives it
|
|
169
|
-
// from its own defaultSize. Reopening the same view keeps its width.
|
|
170
|
-
writeSetting('showRightPanel', nextShow);
|
|
171
|
-
writeSetting('activeRightPanel', nextActive);
|
|
172
121
|
};
|
|
173
122
|
|
|
174
123
|
const handleToggleLeftPanel = (show: boolean) => {
|
|
175
124
|
setShowLeftPanel(show);
|
|
176
|
-
saveSetting('showLeftPanel', show);
|
|
177
125
|
};
|
|
178
126
|
|
|
179
127
|
const handleToggleRightPanel = () => {
|
|
180
|
-
|
|
181
|
-
setShowRightPanel(nextShow);
|
|
182
|
-
saveSetting('showRightPanel', nextShow);
|
|
128
|
+
setShowRightPanel(!showRightPanel);
|
|
183
129
|
};
|
|
184
130
|
|
|
185
131
|
const handleToggleEditor = () => {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
// Reopening the editor re-applies the width its tab kind remembers (see the
|
|
190
|
-
// restore effect), so a dragged width survives the toggle.
|
|
132
|
+
setUserToggledEditor(!showEditor);
|
|
133
|
+
// The editor panel keeps its own remembered width per tab kind, so a
|
|
134
|
+
// dragged width survives the toggle without being re-applied here.
|
|
191
135
|
};
|
|
192
136
|
|
|
193
137
|
return (
|
|
@@ -195,18 +139,6 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
|
|
|
195
139
|
|
|
196
140
|
{/* Main Workspace: full-height left sidebar (resizable) + right stack */}
|
|
197
141
|
<div className="flex flex-1 overflow-hidden">
|
|
198
|
-
{!showLeftPanel && (
|
|
199
|
-
<div
|
|
200
|
-
className="w-12 h-full bg-paper border-r border-ink/10 flex flex-col items-center py-3 flex-shrink-0 cursor-pointer hover:bg-ink/5 transition-colors titlebar-no-drag"
|
|
201
|
-
style={{ paddingTop: 'max(0.75rem, env(titlebar-area-height, 0px))' }}
|
|
202
|
-
onClick={() => handleToggleLeftPanel(true)}
|
|
203
|
-
title="Expand Sidebar"
|
|
204
|
-
>
|
|
205
|
-
<PanelLeft size={16} className="text-ink/60" />
|
|
206
|
-
<div className="w-[1px] flex-1 bg-ink/10 my-4" />
|
|
207
|
-
</div>
|
|
208
|
-
)}
|
|
209
|
-
|
|
210
142
|
<Group
|
|
211
143
|
orientation="horizontal"
|
|
212
144
|
id="ompchamber-main"
|
|
@@ -215,14 +147,10 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
|
|
|
215
147
|
if (left != null && left > 0) commitWidths({ left: Math.round(left) });
|
|
216
148
|
}}
|
|
217
149
|
>
|
|
218
|
-
{
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
</Panel>
|
|
223
|
-
<ResizeHandle />
|
|
224
|
-
</>
|
|
225
|
-
)}
|
|
150
|
+
<Panel panelRef={leftPanelRef} id="left-panel" defaultSize={panelWidths.left ?? DEFAULT_LEFT_PANEL_WIDTH} minSize={MIN_LEFT_PANEL_WIDTH} maxSize={MAX_LEFT_PANEL_WIDTH} collapsed={!showLeftPanel}>
|
|
151
|
+
<SessionSidebar className="w-full h-full" onClose={() => handleToggleLeftPanel(false)} appSettings={appSettings} />
|
|
152
|
+
</Panel>
|
|
153
|
+
{showLeftPanel && <ResizeHandle />}
|
|
226
154
|
|
|
227
155
|
{/* Right stack: top navbar + resizable workspace (the group's filler) */}
|
|
228
156
|
<Panel id="main-right-stack" filler minSize={0}>
|
|
@@ -236,6 +164,7 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
|
|
|
236
164
|
onSwitchToMobile={onSwitchToMobile}
|
|
237
165
|
onToggleEditor={handleToggleEditor}
|
|
238
166
|
onToggleRightPanel={handleToggleRightPanel}
|
|
167
|
+
onToggleLeftPanel={() => handleToggleLeftPanel(!showLeftPanel)}
|
|
239
168
|
/>
|
|
240
169
|
|
|
241
170
|
<WorkspacePanels
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'preact/compat';
|
|
2
2
|
import { useRef as useRefBase } from 'preact/hooks';
|
|
3
3
|
import type { ComponentChildren, RefObject } from 'preact';
|
|
4
|
-
import type { CSSProperties
|
|
5
|
-
import { clamp, toPx
|
|
4
|
+
import type { CSSProperties } from 'preact';
|
|
5
|
+
import { clamp, toPx } from '@/client/components/layout/desktop-layout/resizer/utils';
|
|
6
|
+
import { useSeparatorDrag } from '@/client/components/layout/desktop-layout/resizer/separator-drag';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Hand-rolled resizable panel group (the Preact-native replacement for
|
|
@@ -15,6 +16,10 @@ import { clamp, toPx, transfer } from '@/client/components/layout/desktop-layout
|
|
|
15
16
|
* clamped by both neighbors' min/max constraints, and commits once per drag
|
|
16
17
|
* through `onLayoutChanged(_, { isUserInteraction: true })`.
|
|
17
18
|
*
|
|
19
|
+
* Sizes are pixels here. A caller that stores a width as a share of the group's
|
|
20
|
+
* area (see `panel-widths.ts`) resolves it to px before passing it in — the
|
|
21
|
+
* resizer has no percentage path and should not grow one.
|
|
22
|
+
*
|
|
18
23
|
* The filler's `minSize` is a real CSS floor, which is what makes a shrinking
|
|
19
24
|
* window push the resizable panels toward their own floors instead of
|
|
20
25
|
* collapsing the filler to nothing (a zero flex basis absorbs no negative free
|
|
@@ -39,14 +44,9 @@ export interface PanelImperativeHandle {
|
|
|
39
44
|
getSize: () => { inPixels: number } | null;
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
export interface GroupImperativeHandle {
|
|
43
|
-
/** Batch resize of the named panels, in pixels. */
|
|
44
|
-
setLayout: (layout: Record<string, number>) => void;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
47
|
export type LayoutChangedHandler = (layout: Record<string, number>, meta: LayoutMeta) => void;
|
|
48
48
|
|
|
49
|
-
interface PanelState {
|
|
49
|
+
export interface PanelState {
|
|
50
50
|
id: string;
|
|
51
51
|
isFiller: boolean;
|
|
52
52
|
minSize: number;
|
|
@@ -69,7 +69,8 @@ const GroupContext = createContext<GroupContextValue | null>(null);
|
|
|
69
69
|
export function Group(props: {
|
|
70
70
|
orientation: GroupOrientation;
|
|
71
71
|
id?: string;
|
|
72
|
-
|
|
72
|
+
/** The group's own element, for measuring the area its panels share. */
|
|
73
|
+
groupRef?: RefObject<HTMLDivElement>;
|
|
73
74
|
onLayoutChanged?: LayoutChangedHandler;
|
|
74
75
|
className?: string;
|
|
75
76
|
children: ComponentChildren;
|
|
@@ -86,35 +87,10 @@ export function Group(props: {
|
|
|
86
87
|
[orientation],
|
|
87
88
|
);
|
|
88
89
|
|
|
89
|
-
const snapshot = () => {
|
|
90
|
-
const layout: Record<string, number> = {};
|
|
91
|
-
for (const [pid, state] of panelsRef.current) {
|
|
92
|
-
const px = state.getSize();
|
|
93
|
-
if (px != null) layout[pid] = px;
|
|
94
|
-
}
|
|
95
|
-
return layout;
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
useEffect(() => {
|
|
99
|
-
if (!groupRef) return;
|
|
100
|
-
groupRef.current = {
|
|
101
|
-
setLayout: (layout) => {
|
|
102
|
-
for (const [pid, px] of Object.entries(layout)) {
|
|
103
|
-
const state = panelsRef.current.get(pid);
|
|
104
|
-
if (!state || state.isFiller || typeof px !== 'number') continue;
|
|
105
|
-
state.setSize(clamp(px, state.minSize, state.maxSize));
|
|
106
|
-
}
|
|
107
|
-
changedRef.current?.(snapshot(), { isUserInteraction: false });
|
|
108
|
-
},
|
|
109
|
-
};
|
|
110
|
-
return () => {
|
|
111
|
-
groupRef.current = null;
|
|
112
|
-
};
|
|
113
|
-
}, [groupRef]);
|
|
114
|
-
|
|
115
90
|
return (
|
|
116
91
|
<GroupContext.Provider value={ctx}>
|
|
117
92
|
<div
|
|
93
|
+
ref={groupRef}
|
|
118
94
|
data-panel-group=""
|
|
119
95
|
data-panel-group-id={id}
|
|
120
96
|
className={className}
|
|
@@ -133,14 +109,19 @@ export function Panel(props: {
|
|
|
133
109
|
defaultSize?: number | string;
|
|
134
110
|
minSize?: number | string;
|
|
135
111
|
maxSize?: number | string;
|
|
136
|
-
|
|
112
|
+
/**
|
|
113
|
+
* Render at zero width without unmounting. The panel keeps its size state, so
|
|
114
|
+
* reopening restores the remembered width and the width transition animates
|
|
115
|
+
* the movement instead of the content popping in.
|
|
116
|
+
*/
|
|
117
|
+
collapsed?: boolean;
|
|
137
118
|
/** This panel absorbs the remaining space instead of holding its own px. */
|
|
138
119
|
filler?: boolean;
|
|
139
120
|
className?: string;
|
|
140
121
|
style?: CSSProperties;
|
|
141
122
|
children: ComponentChildren;
|
|
142
123
|
}) {
|
|
143
|
-
const { id, panelRef, defaultSize, minSize = 0, maxSize, filler = false, className, style, children } = props;
|
|
124
|
+
const { id, panelRef, defaultSize, minSize = 0, maxSize, collapsed = false, filler = false, className, style, children } = props;
|
|
144
125
|
const group = useContext(GroupContext);
|
|
145
126
|
if (!group) {
|
|
146
127
|
throw new Error(`<Panel id="${id}"> must be rendered inside a <Group>`);
|
|
@@ -184,6 +165,21 @@ export function Panel(props: {
|
|
|
184
165
|
}
|
|
185
166
|
});
|
|
186
167
|
|
|
168
|
+
// Adopt a changed `defaultSize`. This is what restores a panel after its
|
|
169
|
+
// owner swaps it — a right-panel view switch, an editor source↔diff switch —
|
|
170
|
+
// and after a resize recomputes it, without an imperative push from the
|
|
171
|
+
// owner. A drag does not fight it: the owner only produces a new
|
|
172
|
+
// `defaultSize` once the drag commits, and that value is the size the drag
|
|
173
|
+
// already applied.
|
|
174
|
+
const lastDefaultPxRef = useRef<number | null>(null);
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (filler || defaultSize == null) return;
|
|
177
|
+
const nextPx = toPx(defaultSize, minPx);
|
|
178
|
+
if (lastDefaultPxRef.current === nextPx) return;
|
|
179
|
+
lastDefaultPxRef.current = nextPx;
|
|
180
|
+
setSizePx(clamp(nextPx, minPx, maxPx));
|
|
181
|
+
}, [defaultSize, filler, minPx, maxPx]);
|
|
182
|
+
|
|
187
183
|
useEffect(() => {
|
|
188
184
|
if (!panelRef) return;
|
|
189
185
|
panelRef.current = {
|
|
@@ -212,17 +208,29 @@ export function Panel(props: {
|
|
|
212
208
|
}
|
|
213
209
|
: {
|
|
214
210
|
flex: '0 1 auto',
|
|
215
|
-
width: orientation === 'horizontal' ? sizePx ?? undefined : undefined,
|
|
216
|
-
height: orientation === 'horizontal' ? undefined : sizePx ?? undefined,
|
|
217
|
-
minWidth: orientation === 'horizontal' ? minPx : undefined,
|
|
218
|
-
minHeight: orientation === 'horizontal' ? undefined : minPx,
|
|
211
|
+
width: orientation === 'horizontal' ? (collapsed ? 0 : sizePx ?? undefined) : undefined,
|
|
212
|
+
height: orientation === 'horizontal' ? undefined : collapsed ? 0 : sizePx ?? undefined,
|
|
213
|
+
minWidth: orientation === 'horizontal' ? (collapsed ? 0 : minPx) : undefined,
|
|
214
|
+
minHeight: orientation === 'horizontal' ? undefined : collapsed ? 0 : minPx,
|
|
219
215
|
maxWidth: maxSize != null ? maxPx : undefined,
|
|
216
|
+
// A drag re-applies the real width at most every RESIZE_FOLLOW_INTERVAL_MS
|
|
217
|
+
// (see useSeparatorDrag), and this transition is what makes those steps
|
|
218
|
+
// read as one movement. It also carries a panel to its remembered width
|
|
219
|
+
// on a view switch, and to zero on collapse, instead of snapping.
|
|
220
|
+
transition: `${orientation === 'horizontal' ? 'width' : 'height'} 200ms cubic-bezier(0.22, 1, 0.36, 1)`,
|
|
220
221
|
overflow: 'hidden',
|
|
221
222
|
position: 'relative',
|
|
222
223
|
};
|
|
223
224
|
|
|
224
225
|
return (
|
|
225
|
-
<div
|
|
226
|
+
<div
|
|
227
|
+
ref={elementRef}
|
|
228
|
+
data-panel=""
|
|
229
|
+
data-panel-id={id}
|
|
230
|
+
data-panel-collapsed={collapsed ? '' : undefined}
|
|
231
|
+
className={className}
|
|
232
|
+
style={{ ...baseStyle, ...style }}
|
|
233
|
+
>
|
|
226
234
|
{children}
|
|
227
235
|
</div>
|
|
228
236
|
);
|
|
@@ -240,88 +248,26 @@ export function Panel(props: {
|
|
|
240
248
|
export function Separator(props: { className?: string; style?: CSSProperties; children?: ComponentChildren }) {
|
|
241
249
|
const { className, style, children } = props;
|
|
242
250
|
const group = useContext(GroupContext);
|
|
243
|
-
const [dragging, setDragging] = useState(false);
|
|
244
251
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
|
245
|
-
const dragCleanupRef = useRef<(() => void) | null>(null);
|
|
246
|
-
|
|
247
|
-
// A drag owns two window listeners; if the separator unmounts mid-drag (a
|
|
248
|
-
// layout switch) nothing else would ever remove them.
|
|
249
|
-
useEffect(() => () => dragCleanupRef.current?.(), []);
|
|
250
252
|
|
|
251
253
|
if (!group) {
|
|
252
254
|
throw new Error('<Separator> must be rendered inside a <Group>');
|
|
253
255
|
}
|
|
254
256
|
const { orientation, panels, onLayoutChanged } = group;
|
|
255
|
-
|
|
256
|
-
const handlePointerDown = (e: TargetedPointerEvent<HTMLDivElement>) => {
|
|
257
|
-
const host = hostRef.current;
|
|
258
|
-
if (!host || e.button !== 0) return;
|
|
259
|
-
const previous = host.previousElementSibling as HTMLElement | null;
|
|
260
|
-
const next = host.nextElementSibling as HTMLElement | null;
|
|
261
|
-
const aId = previous?.getAttribute('data-panel-id');
|
|
262
|
-
const bId = next?.getAttribute('data-panel-id');
|
|
263
|
-
const a = aId ? panels.current.get(aId) : undefined;
|
|
264
|
-
const b = bId ? panels.current.get(bId) : undefined;
|
|
265
|
-
if (!a || !b || !previous || !next) return;
|
|
266
|
-
|
|
267
|
-
e.preventDefault();
|
|
268
|
-
setDragging(true);
|
|
269
|
-
|
|
270
|
-
const startPos = orientation === 'horizontal' ? e.clientX : e.clientY;
|
|
271
|
-
const measure = (el: HTMLElement) =>
|
|
272
|
-
orientation === 'horizontal' ? el.getBoundingClientRect().width : el.getBoundingClientRect().height;
|
|
273
|
-
// Measure, never ask the panel: a window narrow enough for flexbox to
|
|
274
|
-
// shrink a panel below its specified width would otherwise start the drag
|
|
275
|
-
// from a size the panel does not have on screen.
|
|
276
|
-
const aStart = measure(previous);
|
|
277
|
-
const bStart = measure(next);
|
|
278
|
-
|
|
279
|
-
const onMove = (ev: PointerEvent) => {
|
|
280
|
-
const pos = orientation === 'horizontal' ? ev.clientX : ev.clientY;
|
|
281
|
-
const delta = transfer(
|
|
282
|
-
{ size: aStart, min: a.minSize, max: a.maxSize },
|
|
283
|
-
{ size: bStart, min: b.minSize, max: b.maxSize },
|
|
284
|
-
pos - startPos,
|
|
285
|
-
);
|
|
286
|
-
a.setSize(clamp(aStart + delta, a.minSize, a.maxSize));
|
|
287
|
-
if (!b.isFiller) b.setSize(clamp(bStart - delta, b.minSize, b.maxSize));
|
|
288
|
-
};
|
|
289
|
-
|
|
290
|
-
const cleanup = () => {
|
|
291
|
-
window.removeEventListener('pointermove', onMove);
|
|
292
|
-
window.removeEventListener('pointerup', onUp);
|
|
293
|
-
window.removeEventListener('pointercancel', onUp);
|
|
294
|
-
dragCleanupRef.current = null;
|
|
295
|
-
setDragging(false);
|
|
296
|
-
const layout: Record<string, number> = {};
|
|
297
|
-
for (const [pid, state] of panels.current) {
|
|
298
|
-
const px = state.getSize();
|
|
299
|
-
if (px != null) layout[pid] = px;
|
|
300
|
-
}
|
|
301
|
-
onLayoutChanged.current?.(layout, { isUserInteraction: true });
|
|
302
|
-
};
|
|
303
|
-
const onUp = () => cleanup();
|
|
304
|
-
|
|
305
|
-
window.addEventListener('pointermove', onMove);
|
|
306
|
-
window.addEventListener('pointerup', onUp);
|
|
307
|
-
window.addEventListener('pointercancel', onUp);
|
|
308
|
-
dragCleanupRef.current = cleanup;
|
|
309
|
-
};
|
|
257
|
+
const { dragging, guidePx, onPointerDown } = useSeparatorDrag(hostRef, orientation, panels, onLayoutChanged);
|
|
310
258
|
|
|
311
259
|
return (
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
{children}
|
|
324
|
-
</div>
|
|
260
|
+
<div
|
|
261
|
+
ref={hostRef}
|
|
262
|
+
data-separator=""
|
|
263
|
+
role="separator"
|
|
264
|
+
aria-orientation={orientation === 'horizontal' ? 'vertical' : 'horizontal'}
|
|
265
|
+
className={className}
|
|
266
|
+
style={style}
|
|
267
|
+
onPointerDown={onPointerDown}
|
|
268
|
+
onDragStart={(e: DragEvent) => e.preventDefault()}
|
|
269
|
+
>
|
|
270
|
+
{children}
|
|
325
271
|
{dragging && (
|
|
326
272
|
<div
|
|
327
273
|
style={{
|
|
@@ -332,6 +278,21 @@ export function Separator(props: { className?: string; style?: CSSProperties; ch
|
|
|
332
278
|
} as CSSProperties}
|
|
333
279
|
/>
|
|
334
280
|
)}
|
|
335
|
-
|
|
281
|
+
{guidePx !== null && (
|
|
282
|
+
<div
|
|
283
|
+
aria-hidden="true"
|
|
284
|
+
style={{
|
|
285
|
+
position: 'fixed',
|
|
286
|
+
...(orientation === 'horizontal'
|
|
287
|
+
? { left: `${guidePx}px`, top: 0, bottom: 0, width: '1px' }
|
|
288
|
+
: { top: `${guidePx}px`, left: 0, right: 0, height: '1px' }),
|
|
289
|
+
zIndex: 9998,
|
|
290
|
+
pointerEvents: 'none',
|
|
291
|
+
background: 'var(--theme-ink)',
|
|
292
|
+
opacity: 0.35,
|
|
293
|
+
} as CSSProperties}
|
|
294
|
+
/>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
336
297
|
);
|
|
337
|
-
}
|
|
298
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
2
|
+
import type { RefObject } from 'preact';
|
|
3
|
+
import type { TargetedPointerEvent } from 'preact';
|
|
4
|
+
import { clamp, transfer } from '@/client/components/layout/desktop-layout/resizer/utils';
|
|
5
|
+
import { RESIZE_FOLLOW_INTERVAL_MS } from '@/shared/lib/workspace/panel-widths';
|
|
6
|
+
import type { GroupOrientation, LayoutChangedHandler, PanelState } from '@/client/components/layout/desktop-layout/resizer';
|
|
7
|
+
|
|
8
|
+
export interface SeparatorDrag {
|
|
9
|
+
/** A drag is in flight; the caller renders the pointer-capture blocker. */
|
|
10
|
+
dragging: boolean;
|
|
11
|
+
/** Pointer position of the guide line, or null when not dragging. */
|
|
12
|
+
guidePx: number | null;
|
|
13
|
+
onPointerDown: (event: TargetedPointerEvent<HTMLDivElement>) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The deferred drag behind a separator.
|
|
18
|
+
*
|
|
19
|
+
* A ghost guide line follows the pointer, and the real width is re-applied at
|
|
20
|
+
* most every `RESIZE_FOLLOW_INTERVAL_MS`. Applying it on every pointermove
|
|
21
|
+
* re-renders both panels per frame and reflows whatever they contain — an xterm
|
|
22
|
+
* buffer, a CodeMirror document, a browser iframe — for a width the user is
|
|
23
|
+
* still choosing. The panel's own width transition smooths the steps that land.
|
|
24
|
+
*
|
|
25
|
+
* Kept out of `index.tsx` so the components stay inside the repo's file-size
|
|
26
|
+
* ceiling.
|
|
27
|
+
*/
|
|
28
|
+
export function useSeparatorDrag(
|
|
29
|
+
hostRef: RefObject<HTMLDivElement | null>,
|
|
30
|
+
orientation: GroupOrientation,
|
|
31
|
+
panels: { current: Map<string, PanelState> },
|
|
32
|
+
onLayoutChanged: { current: LayoutChangedHandler | null },
|
|
33
|
+
): SeparatorDrag {
|
|
34
|
+
const [dragging, setDragging] = useState(false);
|
|
35
|
+
// State, not a ref: the guide line has to move with the pointer even though
|
|
36
|
+
// the panels deliberately do not.
|
|
37
|
+
const [guidePx, setGuidePx] = useState<number | null>(null);
|
|
38
|
+
const cleanupRef = useRef<(() => void) | null>(null);
|
|
39
|
+
|
|
40
|
+
// A drag owns two window listeners; if the separator unmounts mid-drag (a
|
|
41
|
+
// layout switch) nothing else would ever remove them.
|
|
42
|
+
useEffect(() => () => cleanupRef.current?.(), []);
|
|
43
|
+
|
|
44
|
+
const onPointerDown = (e: TargetedPointerEvent<HTMLDivElement>) => {
|
|
45
|
+
const host = hostRef.current;
|
|
46
|
+
if (!host || e.button !== 0) return;
|
|
47
|
+
// Walk past separators and collapsed panels to the real neighbours. A
|
|
48
|
+
// collapsed panel keeps its DOM node (so its state survives and the width
|
|
49
|
+
// transition has something to animate), but it is zero-width and its own
|
|
50
|
+
// floor still reads as 320 in the registry — pairing a drag against it
|
|
51
|
+
// would clamp every delta to zero and kill the separator.
|
|
52
|
+
const neighbour = (start: Element | null, step: 'previous' | 'next') => {
|
|
53
|
+
let node = start;
|
|
54
|
+
while (node) {
|
|
55
|
+
if (node.hasAttribute('data-separator') || node.hasAttribute('data-panel-collapsed')) {
|
|
56
|
+
node = step === 'previous' ? node.previousElementSibling : node.nextElementSibling;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
return node instanceof HTMLElement && node.hasAttribute('data-panel') ? node : null;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const previous = neighbour(host.previousElementSibling, 'previous');
|
|
65
|
+
const next = neighbour(host.nextElementSibling, 'next');
|
|
66
|
+
const a = previous ? panels.current.get(previous.getAttribute('data-panel-id') ?? '') : undefined;
|
|
67
|
+
const b = next ? panels.current.get(next.getAttribute('data-panel-id') ?? '') : undefined;
|
|
68
|
+
if (!a || !b || !previous || !next) return;
|
|
69
|
+
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
setDragging(true);
|
|
72
|
+
|
|
73
|
+
const horizontal = orientation === 'horizontal';
|
|
74
|
+
const startPos = horizontal ? e.clientX : e.clientY;
|
|
75
|
+
const measure = (el: HTMLElement) =>
|
|
76
|
+
horizontal ? el.getBoundingClientRect().width : el.getBoundingClientRect().height;
|
|
77
|
+
// Measure, never ask the panel: a window narrow enough for flexbox to
|
|
78
|
+
// shrink a panel below its specified width would otherwise start the drag
|
|
79
|
+
// from a size the panel does not have on screen.
|
|
80
|
+
const aStart = measure(previous);
|
|
81
|
+
const bStart = measure(next);
|
|
82
|
+
|
|
83
|
+
// The size the throttle last committed, kept apart from the guide line so
|
|
84
|
+
// every step that lands is a legal clamped size.
|
|
85
|
+
let pending: { a: number; b: number } | null = null;
|
|
86
|
+
let followTimer: ReturnType<typeof setTimeout> | undefined;
|
|
87
|
+
|
|
88
|
+
const apply = (sizes: { a: number; b: number }) => {
|
|
89
|
+
a.setSize(sizes.a);
|
|
90
|
+
if (!b.isFiller) b.setSize(sizes.b);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const onMove = (ev: PointerEvent) => {
|
|
94
|
+
const pos = horizontal ? ev.clientX : ev.clientY;
|
|
95
|
+
const delta = transfer(
|
|
96
|
+
{ size: aStart, min: a.minSize, max: a.maxSize },
|
|
97
|
+
{ size: bStart, min: b.minSize, max: b.maxSize },
|
|
98
|
+
pos - startPos,
|
|
99
|
+
);
|
|
100
|
+
pending = {
|
|
101
|
+
a: clamp(aStart + delta, a.minSize, a.maxSize),
|
|
102
|
+
b: clamp(bStart - delta, b.minSize, b.maxSize),
|
|
103
|
+
};
|
|
104
|
+
setGuidePx(pos);
|
|
105
|
+
if (followTimer === undefined) {
|
|
106
|
+
followTimer = setTimeout(() => {
|
|
107
|
+
followTimer = undefined;
|
|
108
|
+
if (pending) apply(pending);
|
|
109
|
+
}, RESIZE_FOLLOW_INTERVAL_MS);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const onUp = () => {
|
|
114
|
+
window.removeEventListener('pointermove', onMove);
|
|
115
|
+
window.removeEventListener('pointerup', onUp);
|
|
116
|
+
window.removeEventListener('pointercancel', onUp);
|
|
117
|
+
clearTimeout(followTimer);
|
|
118
|
+
cleanupRef.current = null;
|
|
119
|
+
setDragging(false);
|
|
120
|
+
setGuidePx(null);
|
|
121
|
+
// Land the exact final size once, so the release position is the one the
|
|
122
|
+
// guide line showed rather than the last throttled step.
|
|
123
|
+
if (pending) apply(pending);
|
|
124
|
+
|
|
125
|
+
const layout: Record<string, number> = {};
|
|
126
|
+
for (const [pid, state] of panels.current) {
|
|
127
|
+
const px = state.getSize();
|
|
128
|
+
if (px != null) layout[pid] = px;
|
|
129
|
+
}
|
|
130
|
+
onLayoutChanged.current?.(layout, { isUserInteraction: true });
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
window.addEventListener('pointermove', onMove);
|
|
134
|
+
window.addEventListener('pointerup', onUp);
|
|
135
|
+
window.addEventListener('pointercancel', onUp);
|
|
136
|
+
cleanupRef.current = onUp;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
return { dragging, guidePx, onPointerDown };
|
|
140
|
+
}
|
|
@@ -5,7 +5,7 @@ export function SessionSidebarHeader({ onNewSession }: { onNewSession: () => voi
|
|
|
5
5
|
<>
|
|
6
6
|
{/* App Title */}
|
|
7
7
|
<div
|
|
8
|
-
className="h-
|
|
8
|
+
className="h-14 flex-shrink-0 flex items-center px-4 border-b border-ink/10 titlebar-drag-region select-none"
|
|
9
9
|
style={{ paddingLeft: 'max(1rem, env(titlebar-area-x, 0px))' }}
|
|
10
10
|
>
|
|
11
11
|
<span className="font-bold text-sm tracking-tight flex items-center">
|