@khorsheed/dsh-mobile 0.1.3 → 0.1.4

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.
@@ -1,9 +1,11 @@
1
1
  import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots';
2
2
  import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client';
3
3
  import type { MobileRooms } from './rooms.ts';
4
+ import type { MobilePanels } from './panels.ts';
4
5
  import type { MobileNavigation } from './navigation.ts';
5
6
  import type { MobilePresentation } from './presentation.ts';
6
7
  export interface MobileChromeInjected {
8
+ panels?: MobilePanels;
7
9
  rooms?: MobileRooms;
8
10
  navigation: MobileNavigation;
9
11
  presentation: MobilePresentation;
@@ -11,5 +13,5 @@ export interface MobileChromeInjected {
11
13
  connection: ConnectionHandle;
12
14
  }
13
15
  /** One visible navigation bar, with native connection sheets when supported. */
14
- export declare function MobileChrome({ presentation, toggleSidebar, connection, navigation, rooms, t }: MobileChromeInjected & PropsLocale<'mobile'>): import("react").JSX.Element | null;
16
+ export declare function MobileChrome({ presentation, toggleSidebar, connection, navigation, rooms, panels, t }: MobileChromeInjected & PropsLocale<'mobile'>): import("react").JSX.Element | null;
15
17
  //# sourceMappingURL=MobileChrome.d.ts.map
@@ -4,6 +4,7 @@ import { installNavigationGestures } from './gestures.js';
4
4
  import { MobileRoomNavigation } from './MobileRooms.js';
5
5
  import { SessionRename } from './SessionRename.js';
6
6
  import { MobileIcon } from './MobileIcon.js';
7
+ import { MobilePanelEntry, MobilePanelNavigation } from './MobilePanelNavigation.js';
7
8
  import { MobileLibrary } from './MobileLibrary.js';
8
9
  import { mainSessionId } from './navigation.js';
9
10
  import { hasNativeAction, reportChrome, requestNativeAction } from './native.js';
@@ -22,7 +23,7 @@ function ConversationTitle({ navigation, fallback, prepareNavigation, surface, t
22
23
  return _jsxs(_Fragment, { children: [_jsxs("span", { "data-mobile-title-line": true, children: [_jsx("strong", { "data-mobile-session-title": row && !row.blank ? '' : undefined, children: row && !row.blank ? row.title || row.displayTitle : fallback }), row && !row.blank && _jsx("button", { "data-mobile-rename": true, "aria-label": t('renameSession'), onClick: () => { prepareNavigation(); setRenaming(true); }, children: _jsx(MobileIcon, { name: "edit", size: 16 }) })] }), row && !row.blank && (cwd || preset) && _jsxs("span", { "data-mobile-subtitle": true, children: [_jsx("span", { title: row.cwd, children: cwd }), cwd && preset && _jsx("span", { "aria-hidden": true, children: "\u00B7" }), preset && _jsx("span", { children: preset })] }), renaming && row && !row.blank && _jsx(SessionRename, { navigation: navigation, sessionId: row.id, title: row.title || row.displayTitle, close: () => { prepareNavigation(); setRenaming(false); }, t: t }, row.id)] });
23
24
  }
24
25
  /** One visible navigation bar, with native connection sheets when supported. */
25
- export function MobileChrome({ presentation, toggleSidebar, connection, navigation, rooms, t }) {
26
+ export function MobileChrome({ presentation, toggleSidebar, connection, navigation, rooms, panels, t }) {
26
27
  const state = useSyncExternalStore(presentation.subscribe, presentation.getSnapshot);
27
28
  const wire = useSyncExternalStore(connection.state.subscribe, connection.state.getSnapshot);
28
29
  const available = useSyncExternalStore(navigation.subscribe, navigation.getSnapshot);
@@ -51,9 +52,21 @@ export function MobileChrome({ presentation, toggleSidebar, connection, navigati
51
52
  if (available && !activated.current) {
52
53
  activated.current = true;
53
54
  setLibrary(true);
54
- if (state.drawer)
55
- toggleSidebar();
56
55
  }
56
+ }, [state.active, available]);
57
+ // With the mobile library available, the official sidebar drawer opens only
58
+ // on request (settings → workspaces). Anything else that expands it — the
59
+ // host restoring its desktop layout after a reconnect, a fallback drawer
60
+ // left open while navigation was missing — is closed again, so the desktop
61
+ // sidebar and its shade never reappear over the mobile chrome or a panel.
62
+ const drawerWanted = useRef(false);
63
+ useEffect(() => {
64
+ if (!state.drawer) {
65
+ drawerWanted.current = false;
66
+ return;
67
+ }
68
+ if (state.active && available && !drawerWanted.current)
69
+ toggleSidebar();
57
70
  }, [state.active, state.drawer, available, toggleSidebar]);
58
71
  useEffect(() => { reportChrome(state.active); return () => reportChrome(false); }, [state.active]);
59
72
  useEffect(() => {
@@ -79,6 +92,7 @@ export function MobileChrome({ presentation, toggleSidebar, connection, navigati
79
92
  toggleSidebar(); }, toggleSidebar);
80
93
  }, [state.active, available, settings, state.drawer, toggleSidebar]);
81
94
  const openLibrary = () => {
95
+ panels?.hide();
82
96
  if (!available) {
83
97
  toggleSidebar();
84
98
  return;
@@ -88,6 +102,7 @@ export function MobileChrome({ presentation, toggleSidebar, connection, navigati
88
102
  setLibrary(true);
89
103
  };
90
104
  const startSession = () => {
105
+ panels?.hide();
91
106
  presentation.prepareNavigation();
92
107
  available?.workspace.startSession();
93
108
  if (state.drawer)
@@ -119,6 +134,9 @@ export function MobileChrome({ presentation, toggleSidebar, connection, navigati
119
134
  }, [state.drawer, settings, library, toggleSidebar]);
120
135
  if (!state.active && !settings && state.mode !== 'desktop')
121
136
  return null;
122
- return _jsxs(_Fragment, { children: [!state.active && state.mode === 'desktop' && _jsx("button", { "data-mobile-restore": true, onClick: () => presentation.setMode('mobile'), children: t('restoreMobile') }), state.active && _jsxs(_Fragment, { children: [available && _jsxs("aside", { "data-mobile-library-pane": true, hidden: !library && !wide, children: [_jsxs("nav", { "data-mobile-library-toolbar": true, "aria-label": t('menu'), children: [_jsx("button", { "aria-label": t('settings'), onClick: openSettings, children: _jsx(MobileIcon, { name: "settings" }) }), _jsx("button", { "aria-label": t('newSession'), onClick: startSession, children: _jsx(MobileIcon, { name: "compose" }) })] }), _jsx(MobileLibrary, { ...(rooms ? { rooms } : {}), navigation: available, visible: library || wide, t: t, onBeforeOpen: presentation.prepareNavigation, onOpen: () => setLibrary(false) })] }), library && available && !wide && _jsx("button", { type: "button", "data-mobile-peek-close": true, "aria-label": t('returnToConversation'), onClick: () => { presentation.prepareNavigation(); setLibrary(false); } }), state.drawer && _jsx("button", { "data-mobile-shade": true, "aria-label": t('close'), onClick: toggleSidebar }), _jsxs("nav", { "data-mobile-toolbar": true, "data-library": false, "aria-label": t('menu'), children: [_jsx("button", { "aria-label": t('menu'), onClick: openLibrary, children: _jsx(MobileIcon, { name: "back" }) }), _jsxs("div", { "data-mobile-nav-title": true, children: [(available ? _jsx(ConversationTitle, { navigation: available, prepareNavigation: presentation.prepareNavigation, surface: presentation.surface, fallback: t('newSession'), t: t }) : _jsx("strong", { children: t('brand') })), wire !== 'connected' && !connectionExpanded && _jsx("small", { role: "status", children: t(wire ?? 'connecting') })] }), _jsx("button", { "data-mobile-new": !!available, "aria-label": available ? t('newSession') : t('settings'), onClick: available ? startSession : openSettings, children: _jsx(MobileIcon, { name: available ? "compose" : "settings" }) }), available && rooms && _jsx(MobileRoomNavigation, { rooms: rooms, navigation: available, prepareNavigation: presentation.prepareNavigation, t: t })] }), wire !== 'connected' && connectionExpanded && _jsxs("div", { "data-mobile-connection-status": true, role: "status", children: [_jsx("span", { children: t(wire ?? 'connecting') }), _jsx("button", { onClick: () => connection.reconnect(), children: t('reconnect') })] })] }), _jsxs("dialog", { ref: dialog, "data-mobile-dialog": true, onClose: () => setSettings(false), "aria-label": t('settings'), children: [_jsx("h2", { children: t('settings') }), available && _jsx("button", { "data-mobile-option": true, onClick: startSession, children: t('newSession') }), _jsx("button", { "data-mobile-option": true, onClick: () => { setSettings(false); setLibrary(false); if (!state.drawer)
123
- toggleSidebar(); }, children: t('workspaces') }), hasNativeAction('settings') ? _jsx("button", { "data-mobile-option": true, onClick: () => { setSettings(false); requestNativeAction('settings'); }, children: t('connectionSettings') }) : _jsxs(_Fragment, { children: [_jsx("h3", { children: t('connection') }), _jsx("p", { children: window.location.host }), _jsx("p", { children: t(wire ?? 'connecting') }), _jsx("button", { onClick: () => connection.reconnect(), children: t('reconnect') })] }), _jsx("h3", { children: t('display') }), _jsx("div", { role: "group", "aria-label": t('display'), children: ['auto', 'mobile', 'desktop'].map((mode) => _jsx("button", { "aria-pressed": state.mode === mode, onClick: () => presentation.setMode(mode), children: t(mode === 'auto' ? 'automatic' : mode) }, mode)) }), _jsx("p", { children: t('appearance') }), _jsx("button", { "data-mobile-done": true, onClick: () => setSettings(false), children: t('done') })] })] });
137
+ return _jsxs(_Fragment, { children: [!state.active && state.mode === 'desktop' && _jsx("button", { "data-mobile-restore": true, onClick: () => presentation.setMode('mobile'), children: t('restoreMobile') }), state.active && _jsxs(_Fragment, { children: [available && _jsxs("aside", { "data-mobile-library-pane": true, hidden: !library && !wide, children: [_jsxs("nav", { "data-mobile-library-toolbar": true, "aria-label": t('menu'), children: [_jsx("button", { "aria-label": t('settings'), onClick: openSettings, children: _jsx(MobileIcon, { name: "settings" }) }), _jsx("button", { "aria-label": t('newSession'), onClick: startSession, children: _jsx(MobileIcon, { name: "compose" }) })] }), _jsx(MobileLibrary, { ...(rooms ? { rooms } : {}), navigation: available, visible: library || wide, t: t, onBeforeOpen: presentation.prepareNavigation, onOpen: () => setLibrary(false) })] }), library && available && !wide && _jsx("button", { type: "button", "data-mobile-peek-close": true, "aria-label": t('returnToConversation'), onClick: () => { presentation.prepareNavigation(); setLibrary(false); } }), state.drawer && _jsx("button", { "data-mobile-shade": true, "aria-label": t('close'), onClick: toggleSidebar }), _jsxs("nav", { "data-mobile-toolbar": true, "data-library": false, "aria-label": t('menu'), children: [_jsx("button", { "aria-label": t('menu'), onClick: openLibrary, children: _jsx(MobileIcon, { name: "back" }) }), _jsxs("div", { "data-mobile-nav-title": true, children: [(available ? _jsx(ConversationTitle, { navigation: available, prepareNavigation: presentation.prepareNavigation, surface: presentation.surface, fallback: t('newSession'), t: t }) : _jsx("strong", { children: t('brand') })), wire !== 'connected' && !connectionExpanded && _jsx("small", { role: "status", children: t(wire ?? 'connecting') })] }), _jsxs("div", { "data-mobile-header-actions": true, children: [panels && _jsx(MobilePanelEntry, { panels: panels, t: t, beforeOpen: () => { presentation.prepareNavigation(); setLibrary(false); if (state.drawer)
138
+ toggleSidebar(); } }), _jsx("button", { "data-mobile-new": !!available, "aria-label": available ? t('newSession') : t('settings'), onClick: available ? startSession : openSettings, children: _jsx(MobileIcon, { name: available ? "compose" : "settings" }) })] }), available && rooms && _jsx(MobileRoomNavigation, { rooms: rooms, navigation: available, prepareNavigation: presentation.prepareNavigation, t: t })] }), panels && _jsx(MobilePanelNavigation, { panels: panels, active: state.active, prepareNavigation: presentation.prepareNavigation, t: t }), wire !== 'connected' && connectionExpanded && _jsxs("div", { "data-mobile-connection-status": true, role: "status", children: [_jsx("span", { children: t(wire ?? 'connecting') }), _jsx("button", { onClick: () => connection.reconnect(), children: t('reconnect') })] })] }), _jsxs("dialog", { ref: dialog, "data-mobile-dialog": true, onClose: () => setSettings(false), "aria-label": t('settings'), children: [_jsx("h2", { children: t('settings') }), available && _jsx("button", { "data-mobile-option": true, onClick: startSession, children: t('newSession') }), _jsx("button", { "data-mobile-option": true, onClick: () => { setSettings(false); setLibrary(false); if (!state.drawer) {
139
+ drawerWanted.current = true;
140
+ toggleSidebar();
141
+ } }, children: t('workspaces') }), hasNativeAction('settings') ? _jsx("button", { "data-mobile-option": true, onClick: () => { setSettings(false); requestNativeAction('settings'); }, children: t('connectionSettings') }) : _jsxs(_Fragment, { children: [_jsx("h3", { children: t('connection') }), _jsx("p", { children: window.location.host }), _jsx("p", { children: t(wire ?? 'connecting') }), _jsx("button", { onClick: () => connection.reconnect(), children: t('reconnect') })] }), _jsx("h3", { children: t('display') }), _jsx("div", { role: "group", "aria-label": t('display'), children: ['auto', 'mobile', 'desktop'].map((mode) => _jsx("button", { "aria-pressed": state.mode === mode, onClick: () => presentation.setMode(mode), children: t(mode === 'auto' ? 'automatic' : mode) }, mode)) }), _jsx("p", { children: t('appearance') }), _jsx("button", { "data-mobile-done": true, onClick: () => setSettings(false), children: t('done') })] })] });
124
142
  }
@@ -1,4 +1,4 @@
1
- export type MobileIconName = 'edit' | 'members' | 'back' | 'settings' | 'compose' | 'folder' | 'search' | 'scan' | 'close' | 'down' | 'right' | 'plus' | 'attachment' | 'commands' | 'shield';
1
+ export type MobileIconName = 'panel' | 'edit' | 'members' | 'back' | 'settings' | 'compose' | 'folder' | 'search' | 'scan' | 'close' | 'down' | 'right' | 'plus' | 'attachment' | 'commands' | 'shield';
2
2
  export declare function MobileIcon({ name, size }: {
3
3
  name: MobileIconName;
4
4
  size?: number;
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  const paths = {
3
+ panel: 'M4 4h16v16H4zM14 4v16',
3
4
  edit: 'm15 5 4 4M5 19l4-1L20 7a2 2 0 0 0-4-4L5 14l-1 6Z',
4
5
  members: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M22 21v-2a4 4 0 0 0-3-3.87M9 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8m8 .13a4 4 0 0 1 0 7.75',
5
6
  plus: 'M12 5v14M5 12h14', commands: 'm16 4-8 16', shield: 'm12 3 8 3v6c0 5-8 9-8 9s-8-4-8-9V6l8-3Zm-4 9 3 3 5-6',
@@ -0,0 +1,12 @@
1
+ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots';
2
+ import type { MobilePanels } from './panels.ts';
3
+ export declare function MobilePanelNavigation({ panels, active, prepareNavigation, t }: {
4
+ panels: MobilePanels;
5
+ active: boolean;
6
+ prepareNavigation: () => void;
7
+ } & PropsLocale<'mobile'>): import("react").JSX.Element | null;
8
+ export declare function MobilePanelEntry({ panels, beforeOpen, t }: {
9
+ panels: MobilePanels;
10
+ beforeOpen: () => void;
11
+ } & PropsLocale<'mobile'>): import("react").JSX.Element | null;
12
+ //# sourceMappingURL=MobilePanelNavigation.d.ts.map
@@ -0,0 +1,65 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useSyncExternalStore } from 'react';
3
+ import { MobileIcon } from './MobileIcon.js';
4
+ export function MobilePanelNavigation({ panels, active, prepareNavigation, t }) {
5
+ const state = useSyncExternalStore(panels.subscribe, panels.getSnapshot);
6
+ const managed = active && state.managed;
7
+ const navigation = useRef(null);
8
+ const prepare = useRef(prepareNavigation);
9
+ prepare.current = prepareNavigation;
10
+ useEffect(() => {
11
+ document.documentElement.toggleAttribute('data-mobile-panel-managed', managed);
12
+ document.documentElement.toggleAttribute('data-mobile-panel-open', managed && state.expanded);
13
+ return () => { document.documentElement.removeAttribute('data-mobile-panel-managed'); document.documentElement.removeAttribute('data-mobile-panel-open'); };
14
+ }, [managed, state.expanded]);
15
+ useEffect(() => {
16
+ if (!managed || !state.expanded)
17
+ return;
18
+ const main = document.querySelector('[data-mobile-frame] > div:has(> [data-slot="main"])');
19
+ const previous = main?.inert;
20
+ if (main)
21
+ main.inert = true;
22
+ const nav = navigation.current;
23
+ nav?.querySelector('button')?.focus({ preventScroll: true });
24
+ const escape = (event) => {
25
+ if (event.key !== 'Escape' || event.defaultPrevented || document.querySelector('dialog[open],[role=dialog],[role=menu]') || document.activeElement?.tagName === 'SELECT')
26
+ return;
27
+ prepare.current();
28
+ panels.hide();
29
+ };
30
+ window.addEventListener('keydown', escape);
31
+ return () => {
32
+ if (main)
33
+ main.inert = previous ?? false;
34
+ window.removeEventListener('keydown', escape);
35
+ if (document.activeElement === document.body || (document.activeElement && nav?.contains(document.activeElement))) {
36
+ document.querySelector('[data-mobile-panel-entry]')?.focus({ preventScroll: true });
37
+ }
38
+ };
39
+ }, [managed, state.expanded, panels]);
40
+ useEffect(() => {
41
+ if (!managed || !state.expanded)
42
+ return;
43
+ navigation.current?.querySelector('[data-mobile-panel-tab][data-active=true]')?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
44
+ }, [managed, state.expanded, state.activeId]);
45
+ if (!managed || !state.expanded)
46
+ return null;
47
+ return _jsxs("nav", { ref: navigation, "data-mobile-panel-navigation": true, "aria-label": t('panels'), children: [_jsx("button", { type: "button", "aria-label": t('backToChat'), onClick: () => { prepareNavigation(); panels.hide(); }, children: _jsx(MobileIcon, { name: "back" }) }), _jsx("div", { role: "tablist", "aria-label": t('switchPanel'), "data-mobile-panel-tabs": true, onKeyDown: event => {
48
+ const index = state.tabs.findIndex(tab => tab.id === state.activeId);
49
+ const next = event.key === 'ArrowRight' ? (index + 1) % state.tabs.length
50
+ : event.key === 'ArrowLeft' ? (index + state.tabs.length - 1) % state.tabs.length
51
+ : event.key === 'Home' ? 0 : event.key === 'End' ? state.tabs.length - 1 : -1;
52
+ const target = state.tabs[next];
53
+ if (!target)
54
+ return;
55
+ event.preventDefault();
56
+ panels.select(target.id);
57
+ event.currentTarget.querySelectorAll('[role=tab]')[next]?.focus({ preventScroll: true });
58
+ }, children: state.tabs.map(tab => _jsxs("div", { role: "presentation", "data-mobile-panel-tab": true, "data-active": tab.id === state.activeId, children: [_jsx("button", { type: "button", role: "tab", title: tab.title || tab.kind, "aria-selected": tab.id === state.activeId, tabIndex: tab.id === state.activeId ? 0 : -1, onClick: () => panels.select(tab.id), children: _jsx("span", { children: tab.title || tab.kind }) }), tab.id === state.activeId && _jsx("button", { type: "button", "data-mobile-panel-tab-close": true, "aria-label": t('closePanel'), disabled: state.tabs.length === 1 && tab.kind === 'guide', onClick: panels.close, children: _jsx(MobileIcon, { name: "close", size: 14 }) })] }, tab.id)) }), _jsx("button", { type: "button", "aria-label": t('addPanel'), onClick: panels.add, children: _jsx(MobileIcon, { name: "plus", size: 20 }) }), state.failed && _jsx("span", { role: "alert", "data-mobile-panel-error": true, children: t('panelError') })] });
59
+ }
60
+ export function MobilePanelEntry({ panels, beforeOpen, t }) {
61
+ const state = useSyncExternalStore(panels.subscribe, panels.getSnapshot);
62
+ if (!state.available)
63
+ return null;
64
+ return _jsx("button", { type: "button", "data-mobile-panel-entry": true, "aria-label": t('panels'), "aria-expanded": state.expanded, onClick: () => { beforeOpen(); panels.toggle(); }, children: _jsx(MobileIcon, { name: "panel" }) });
65
+ }
@@ -11,7 +11,7 @@ export function installNavigationGestures(doc, change, closeSidebar) {
11
11
  doc.documentElement.removeAttribute('data-mobile-gesture');
12
12
  doc.documentElement.style.removeProperty('--mobile-drag-offset');
13
13
  };
14
- const blocked = 'input,textarea,[contenteditable],select,pre,code,[role=dialog],[role=menu],[role=slider],[role=switch],[role=checkbox],[data-mobile-tools-dialog],[data-mobile-dialog]';
14
+ const blocked = 'input,textarea,[contenteditable],select,pre,code,[role=dialog],[role=menu],[role=slider],[role=switch],[role=checkbox],[data-mobile-tools-dialog],[data-mobile-dialog],[data-mobile-gesture-owner],.xterm';
15
15
  const down = (event) => {
16
16
  resetDrag();
17
17
  start = undefined;
@@ -34,6 +34,9 @@ export function installNavigationGestures(doc, change, closeSidebar) {
34
34
  if (viewport >= 960)
35
35
  return; // A pinned two-column layout is not a drawer.
36
36
  const right = target.closest('[data-sidebar-right-panel][data-sidebar-right-open]') ?? undefined;
37
+ // Panel bodies own horizontal canvas/terminal/file gestures. Return is explicit.
38
+ if (right && doc.documentElement.hasAttribute('data-mobile-panel-managed'))
39
+ return;
37
40
  if (right && !right.querySelector('[data-sidebar-right-toggle]'))
38
41
  return;
39
42
  const sidebar = closeSidebar ? target.closest('[data-slot=sidebar]')?.parentElement : undefined;
@@ -9,6 +9,7 @@ import { MobileSubmissionFocus } from './SubmissionFocus.js';
9
9
  import { openMobileCommands } from './commands.js';
10
10
  import { MobileTools } from './MobileTools.js';
11
11
  import { MobileChrome } from './MobileChrome.js';
12
+ import { MobilePanels } from './panels.js';
12
13
  import { MobileQueue } from './MobileQueue.js';
13
14
  import { MobileRooms } from './rooms.js';
14
15
  import { MessageMenu } from './messageMenu.js';
@@ -28,6 +29,12 @@ export function apply(ctx) {
28
29
  const messages = new MessageMenu(document);
29
30
  ctx.effect(() => () => messages.dispose(), 'mobile: message actions');
30
31
  const navigation = new MobileNavigation();
32
+ const panels = new MobilePanels(document);
33
+ ctx.effect(() => () => panels.dispose(), 'mobile: optional panels');
34
+ ctx.inject(['sidebarRight'], scoped => {
35
+ panels.set(scoped.get('sidebarRight'));
36
+ scoped.effect(() => () => panels.set(undefined), 'mobile: sidebar capability');
37
+ });
31
38
  const directory = new MobileDirectory();
32
39
  const presentation = new MobilePresentation(window, window.__DSH_MOBILE_SHELL__?.bridgeVersion === BRIDGE_VERSION);
33
40
  ctx.inject(['sessions', 'workspaces', 'uiWorkspace'], scoped => {
@@ -56,23 +63,28 @@ export function apply(ctx) {
56
63
  }, 'mobile: foreground reconnect');
57
64
  ctx.slots.inject('shell.overlay', () => ctx.slots.register({
58
65
  name: 'shell.overlay', id: 'mobile-navigation', locale: NS,
59
- inject: () => ({ presentation, connection, navigation, rooms, toggleSidebar: () => { ctx.layout.toggleSidebar(); } }),
66
+ inject: () => ({ presentation, connection, navigation, rooms, panels, toggleSidebar: () => { ctx.layout.toggleSidebar(); } }),
60
67
  }, MobileChrome));
61
68
  for (const name of ['conversation.input.dock', 'conversation.session.header.actions']) {
62
69
  ctx.slots.inject(name, () => ctx.slots.register({ name, id: 'mobile-send-focus' }, MobileSubmissionFocus));
63
70
  }
64
- // Room's first asynchronous cache fill does not bump its composer slot in
65
- // this revision. A declining mobile entry refreshes the public election;
71
+ // Older Room versions need a first-fill election bump. Refresh only when
72
+ // discovering a room, not on every roster/run change;
66
73
  // the original Room/approval/local-agent entries still decide the winner.
67
74
  ctx.slots.inject('conversation.composer', () => {
68
75
  let remove;
69
76
  let timer;
77
+ let lastRoom;
70
78
  const refresh = () => {
71
79
  const list = navigation.getSnapshot()?.sessions.list.getSnapshot();
72
80
  const current = list === undefined ? undefined : mainSessionId(list);
73
- if (!presentation.getSnapshot().active || !current || !rooms.get(current))
81
+ const room = current && rooms.get(current) ? current : undefined;
82
+ if (!presentation.getSnapshot().active || room === lastRoom)
74
83
  return;
84
+ lastRoom = room;
75
85
  clearTimeout(timer);
86
+ if (!room)
87
+ return;
76
88
  timer = setTimeout(() => {
77
89
  remove?.();
78
90
  remove = ctx.slots.register({ name: 'conversation.composer', priority: 10000, select: () => null }, () => null);
@@ -1,6 +1,12 @@
1
1
  /** Copy for controls owned by mobile; all conversation copy stays with its owner. */
2
2
  export declare const NS = "mobile";
3
3
  export declare const en: {
4
+ readonly panels: 'Panels';
5
+ readonly backToChat: 'Back to conversation';
6
+ readonly switchPanel: 'Switch panel';
7
+ readonly addPanel: 'Add panel';
8
+ readonly closePanel: 'Close current panel';
9
+ readonly panelError: 'Could not change this panel. Try again.';
4
10
  readonly connectPhone: "Connect phone";
5
11
  readonly connectIntro: "Continue your conversations in DSH Mobile.";
6
12
  readonly connectQR: "Scan with DSH Mobile to sign in";
@@ -1,6 +1,7 @@
1
1
  /** Copy for controls owned by mobile; all conversation copy stays with its owner. */
2
2
  export const NS = 'mobile';
3
3
  export const en = {
4
+ panels: 'Panels', backToChat: 'Back to conversation', switchPanel: 'Switch panel', addPanel: 'Add panel', closePanel: 'Close current panel', panelError: 'Could not change this panel. Try again.',
4
5
  connectPhone: "Connect phone",
5
6
  connectIntro: "Continue your conversations in DSH Mobile.",
6
7
  connectQR: "Scan with DSH Mobile to sign in",
@@ -41,6 +42,7 @@ export const en = {
41
42
  workspace: 'Open workspace', hostPath: 'Computer directory', hostPathHelp: 'Enter an existing absolute directory on your computer. This does not select a folder on your phone.', openWorkspace: 'Open', cancel: 'Cancel',
42
43
  };
43
44
  export const zh = {
45
+ panels: '面板', backToChat: '返回聊天', switchPanel: '切换面板', addPanel: '添加面板', closePanel: '关闭当前面板', panelError: '暂时无法操作面板,请重试。',
44
46
  connectPhone: "连接手机",
45
47
  connectIntro: "在 DSH Mobile 中继续你的会话。",
46
48
  connectQR: "使用 DSH Mobile 扫码登录",
@@ -1,16 +1,15 @@
1
- /** A touch presentation of the rendered message's own actions. The source
1
+ /** A pointer context-menu presentation of the rendered message's own actions. The source
2
2
  * button stays mounted; its plugin still owns edits, confirmations and errors. */
3
3
  export declare class MessageMenu {
4
4
  private doc;
5
- private timer;
6
- private point;
5
+ private touch;
7
6
  private menu;
8
7
  constructor(doc: Document);
9
8
  private row;
10
9
  private actions;
11
10
  private down;
12
- private move;
13
- private cancel;
11
+ private hasSelection;
12
+ private selectionChanged;
14
13
  private context;
15
14
  private close;
16
15
  private open;
@@ -1,17 +1,14 @@
1
- /** A touch presentation of the rendered message's own actions. The source
1
+ /** A pointer context-menu presentation of the rendered message's own actions. The source
2
2
  * button stays mounted; its plugin still owns edits, confirmations and errors. */
3
3
  export class MessageMenu {
4
4
  doc;
5
- timer;
6
- point;
5
+ touch = false;
7
6
  menu;
8
7
  constructor(doc) {
9
8
  this.doc = doc;
10
9
  doc.addEventListener('pointerdown', this.down, true);
11
- doc.addEventListener('pointermove', this.move, true);
12
- doc.addEventListener('pointerup', this.cancel, true);
13
- doc.addEventListener('pointercancel', this.cancel, true);
14
- doc.addEventListener('scroll', this.cancel, true);
10
+ doc.addEventListener('selectionchange', this.selectionChanged);
11
+ doc.addEventListener('scroll', this.close, true);
15
12
  doc.addEventListener('contextmenu', this.context, true);
16
13
  }
17
14
  row(target) {
@@ -31,19 +28,15 @@ export class MessageMenu {
31
28
  return [...row.querySelectorAll(row.hasAttribute('data-turn-tail') ? 'button[aria-label]' : ':scope > div > button[aria-label], :scope > div > span > button[aria-label]')]
32
29
  .filter(b => /^(复制|已复制|编辑|撤回|点赞|点踩|不喜欢|分支|好的回答|有问题的回答|在新对话中分支|Copy|Copied|Edit|Withdraw|Like|Dislike|Branch)/i.test(b.getAttribute('aria-label') ?? ''));
33
30
  }
34
- down = (e) => {
35
- this.cancel();
36
- const row = this.row(e.target);
37
- if (!row || e.pointerType === 'mouse' || !this.actions(row).length)
38
- return;
39
- this.point = { x: e.clientX, y: e.clientY, row };
40
- this.timer = setTimeout(() => { if (this.point)
41
- this.open(row, e.clientX, e.clientY); }, 500);
42
- };
43
- move = (e) => { if (this.point && Math.hypot(e.clientX - this.point.x, e.clientY - this.point.y) > 10)
44
- this.cancel(); };
45
- cancel = () => { clearTimeout(this.timer); this.timer = undefined; this.point = undefined; };
31
+ // Touch long-press belongs to native text selection. Message actions remain
32
+ // on their original action row; an emulated contextmenu must not compete.
33
+ down = (event) => { this.touch = event.pointerType === 'touch' || event.pointerType === 'pen'; };
34
+ hasSelection = () => !!this.doc.getSelection()?.toString().trim();
35
+ selectionChanged = () => { if (this.hasSelection())
36
+ this.close(); };
46
37
  context = (e) => {
38
+ if (this.touch || this.hasSelection())
39
+ return;
47
40
  const row = this.row(e.target);
48
41
  if (row && this.actions(row).length) {
49
42
  e.preventDefault();
@@ -52,7 +45,6 @@ export class MessageMenu {
52
45
  };
53
46
  close = () => { this.menu?.close(); this.menu?.remove(); this.menu = undefined; };
54
47
  open(row, x, y) {
55
- this.cancel();
56
48
  this.close();
57
49
  const actions = this.actions(row);
58
50
  if (!actions.length)
@@ -87,13 +79,10 @@ export class MessageMenu {
87
79
  menu.style.top = `${Math.max(12, Math.min(y + 12, height - bounds.height - 12))}px`;
88
80
  }
89
81
  dispose() {
90
- this.cancel();
91
82
  this.close();
92
83
  this.doc.removeEventListener('pointerdown', this.down, true);
93
- this.doc.removeEventListener('pointermove', this.move, true);
94
- this.doc.removeEventListener('pointerup', this.cancel, true);
95
- this.doc.removeEventListener('pointercancel', this.cancel, true);
96
- this.doc.removeEventListener('scroll', this.cancel, true);
84
+ this.doc.removeEventListener('selectionchange', this.selectionChanged);
85
+ this.doc.removeEventListener('scroll', this.close, true);
97
86
  this.doc.removeEventListener('contextmenu', this.context, true);
98
87
  }
99
88
  }
@@ -0,0 +1,56 @@
1
+ /** Optional public sidebar operations. Bodies and saved layouts stay with their owners. */
2
+ interface Feed<T> {
3
+ getSnapshot(): T;
4
+ subscribe(listener: () => void): () => void;
5
+ }
6
+ export interface MobilePanelTab {
7
+ readonly id: string;
8
+ readonly title: string;
9
+ readonly kind: string;
10
+ }
11
+ export interface MobilePanelService {
12
+ mounted: Feed<string | undefined>;
13
+ openTabs?: Feed<readonly unknown[]>;
14
+ toggleExpanded(): void;
15
+ isExpanded(): boolean;
16
+ active(): MobilePanelTab | undefined;
17
+ tabsIn?(sessionId: string): readonly MobilePanelTab[];
18
+ focus?(tabId: string): void;
19
+ close?(tabId: string): void;
20
+ openTab?(kind: string): void;
21
+ }
22
+ export interface MobilePanelSnapshot {
23
+ readonly available: boolean;
24
+ readonly managed: boolean;
25
+ readonly expanded: boolean;
26
+ readonly sessionId: string | undefined;
27
+ readonly activeId: string;
28
+ readonly tabs: readonly MobilePanelTab[];
29
+ readonly failed: boolean;
30
+ }
31
+ /** Public services do actions; checked DOM anchors supply presentation-only change notifications. */
32
+ export declare class MobilePanels {
33
+ private readonly doc;
34
+ private service;
35
+ private releases;
36
+ private listeners;
37
+ private snapshot;
38
+ private observer;
39
+ private disposed;
40
+ private scheduled;
41
+ private failed;
42
+ constructor(doc: Document);
43
+ readonly getSnapshot: () => MobilePanelSnapshot;
44
+ readonly subscribe: (listener: () => void) => () => void;
45
+ set(value: unknown): void;
46
+ private readonly sync;
47
+ private run;
48
+ readonly toggle: () => void;
49
+ readonly hide: () => void;
50
+ readonly select: (id: string) => void;
51
+ readonly close: () => void;
52
+ readonly add: () => void;
53
+ dispose(): void;
54
+ }
55
+ export {};
56
+ //# sourceMappingURL=panels.d.ts.map
@@ -0,0 +1,97 @@
1
+ const EMPTY = { available: false, managed: false, expanded: false, sessionId: undefined, activeId: '', tabs: [], failed: false };
2
+ /** Public services do actions; checked DOM anchors supply presentation-only change notifications. */
3
+ export class MobilePanels {
4
+ doc;
5
+ service;
6
+ releases = [];
7
+ listeners = new Set();
8
+ snapshot = EMPTY;
9
+ observer;
10
+ disposed = false;
11
+ scheduled = false;
12
+ failed = false;
13
+ constructor(doc) {
14
+ this.doc = doc;
15
+ this.observer = new MutationObserver(records => {
16
+ if (this.scheduled || !records.some(record => {
17
+ const target = record.target instanceof Element ? record.target : record.target.parentElement;
18
+ return !target?.closest('[data-conversation-scroll] [data-slot="conversation.session"]');
19
+ }))
20
+ return;
21
+ this.scheduled = true;
22
+ queueMicrotask(() => { this.scheduled = false; if (!this.disposed)
23
+ this.sync(); });
24
+ });
25
+ this.observer.observe(doc.body, { subtree: true, childList: true, attributes: true,
26
+ attributeFilter: ['data-sidebar-right-open', 'data-dockkit-pane-active', 'data-dockkit-float-active', 'aria-selected', 'hidden'] });
27
+ }
28
+ getSnapshot = () => this.snapshot;
29
+ subscribe = (listener) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; };
30
+ set(value) {
31
+ for (const release of this.releases)
32
+ release();
33
+ this.releases = [];
34
+ const service = value;
35
+ this.service = service && typeof service.toggleExpanded === 'function' && typeof service.isExpanded === 'function'
36
+ && typeof service.active === 'function' && typeof service.mounted?.subscribe === 'function'
37
+ && typeof service.mounted.getSnapshot === 'function' ? service : undefined;
38
+ this.failed = false;
39
+ if (this.service) {
40
+ this.releases.push(this.service.mounted.subscribe(this.sync));
41
+ if (typeof this.service.openTabs?.subscribe === 'function')
42
+ this.releases.push(this.service.openTabs.subscribe(this.sync));
43
+ }
44
+ this.sync();
45
+ }
46
+ sync = () => {
47
+ let next = EMPTY;
48
+ try {
49
+ const service = this.service, sessionId = service?.mounted.getSnapshot();
50
+ if (service && sessionId) {
51
+ const panel = Array.from(this.doc.querySelectorAll('[data-sidebar-right-panel][data-sidebar-right-session]'))
52
+ .find(element => element.dataset.sidebarRightSession === sessionId);
53
+ const tabs = service.tabsIn?.(sessionId) ?? [];
54
+ const active = service.active();
55
+ const managed = !!panel && !!panel.querySelector('[data-dockkit-host]') && tabs.length > 0
56
+ && typeof service.focus === 'function' && typeof service.close === 'function' && typeof service.openTab === 'function'
57
+ && !!active && tabs.some(tab => tab.id === active.id);
58
+ next = { available: true, managed, sessionId, expanded: service.isExpanded(), activeId: active?.id ?? '',
59
+ tabs: tabs.map(({ id, title, kind }) => ({ id, title, kind })), failed: this.failed };
60
+ }
61
+ }
62
+ catch { /* A newer host can withdraw this optional face during scope teardown. */ }
63
+ if (JSON.stringify(next) !== JSON.stringify(this.snapshot)) {
64
+ this.snapshot = next;
65
+ for (const listener of this.listeners)
66
+ listener();
67
+ }
68
+ };
69
+ run(action) {
70
+ if (!this.service || !this.snapshot.available)
71
+ return;
72
+ try {
73
+ action(this.service);
74
+ this.failed = false;
75
+ }
76
+ catch {
77
+ this.failed = true;
78
+ }
79
+ this.sync();
80
+ }
81
+ toggle = () => this.run(service => service.toggleExpanded());
82
+ hide = () => this.run(service => { if (service.isExpanded())
83
+ service.toggleExpanded(); });
84
+ select = (id) => this.run(service => {
85
+ if (this.snapshot.tabs.some(tab => tab.id === id))
86
+ service.focus?.(id);
87
+ });
88
+ close = () => this.run(service => { if (this.snapshot.activeId)
89
+ service.close?.(this.snapshot.activeId); });
90
+ add = () => this.run(service => service.openTab?.('guide'));
91
+ dispose() {
92
+ this.disposed = true;
93
+ this.observer.disconnect();
94
+ this.set(undefined);
95
+ this.listeners.clear();
96
+ }
97
+ }
@@ -41,7 +41,13 @@ export class MobilePresentation {
41
41
  this.style.dataset.mobileOwned = 'styles';
42
42
  this.style.textContent = MOBILE_CSS;
43
43
  win.document.head.append(this.style);
44
- this.observer = new MutationObserver(this.schedule);
44
+ this.observer = new MutationObserver(records => {
45
+ if (records.some(record => {
46
+ const target = record.target instanceof Element ? record.target : record.target.parentElement;
47
+ return !target?.closest('[data-conversation-scroll] [data-slot="conversation.session"]');
48
+ }))
49
+ this.schedule();
50
+ });
45
51
  this.observer.observe(win.document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['data-sidebar-collapsed', 'data-phase', 'aria-selected', 'aria-expanded', 'data-ds-dark-theme', 'src'], characterData: true });
46
52
  this.media.addEventListener('change', this.schedule);
47
53
  win.addEventListener('resize', this.schedule);
@@ -51,8 +51,19 @@ export class MobileRooms {
51
51
  publish(id, state) {
52
52
  if (!this.alive)
53
53
  return;
54
+ // Compare only the presentation we consume; server timestamps and journal
55
+ // metadata must not re-render the roster or re-elect the composer.
56
+ const signature = JSON.stringify(state === null ? null : {
57
+ members: state.members.map(({ name, kind, provider, instructions, cwd, childSessionId }) => [name, kind, provider, instructions, cwd, childSessionId]),
58
+ runs: state.runs.map(({ member, state }) => [member, state]),
59
+ });
60
+ const previous = this.cache.get(id);
61
+ if (previous?.signature === signature) {
62
+ previous.at = Date.now();
63
+ return;
64
+ }
54
65
  this.cache.delete(id);
55
- this.cache.set(id, { state, at: Date.now() });
66
+ this.cache.set(id, { state, at: Date.now(), signature });
56
67
  if (this.cache.size > 200)
57
68
  this.cache.delete(this.cache.keys().next().value);
58
69
  this.version++;