@khorsheed/dsh-mobile 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,9 +32,16 @@ export function ConnectPhone({ t }) {
32
32
  try {
33
33
  const response = await fetch(CONNECT_PATH, { method: generate ? 'POST' : 'GET', credentials: 'same-origin', cache: 'no-store', signal: controller.signal,
34
34
  ...(generate ? { headers: { 'content-type': 'application/json' }, body: '{}' } : {}) });
35
- if (!response.ok)
36
- throw new Error('unavailable');
37
35
  const result = await response.json();
36
+ if (controller.signal.aborted)
37
+ return;
38
+ if (!response.ok) {
39
+ if (result.state === 'unreachable') {
40
+ setInfo(result);
41
+ return;
42
+ }
43
+ throw new Error('unavailable');
44
+ }
38
45
  if (controller.signal.aborted)
39
46
  return;
40
47
  if (generate) {
@@ -64,7 +71,39 @@ export function ConnectPhone({ t }) {
64
71
  window.addEventListener('pagehide', hide);
65
72
  return () => { request.current?.abort(); clearTimeout(timer.current); document.removeEventListener('visibilitychange', conceal); window.removeEventListener('pagehide', hide); };
66
73
  }, []);
67
- return _jsxs("section", { "data-mobile-connect": true, children: [_jsx("style", { children: styles }), _jsx("h2", { children: t('connectPhone') }), _jsx("p", { children: t('connectIntro') }), info?.origin && _jsxs("div", { "data-connect-host": true, children: [_jsx("span", { children: t('server') }), _jsx("strong", { children: new URL(info.origin).host })] }), info && info.state !== 'ready' && _jsx("div", { role: "status", children: _jsx("p", { children: t(info.state === 'untrusted-origin' ? 'connectUntrusted' : info.state === 'unsupported' ? 'connectUnsupported' : 'connectSetup') }) }), failed && _jsx("p", { role: "alert", children: t('connectError') }), busy && _jsx("p", { role: "status", children: t('connectLoading') }), login ? _jsxs("div", { "data-connect-code": true, children: [_jsx(LoginQR, { value: login, label: t('connectQR') }), _jsx("p", { children: t('connectConceal') }), _jsx("button", { type: "button", onClick: hide, children: t('connectHide') })] })
74
+ useEffect(() => {
75
+ if (!login)
76
+ return;
77
+ const controller = new AbortController();
78
+ let checking = false;
79
+ const poll = setInterval(async () => {
80
+ if (checking)
81
+ return;
82
+ checking = true;
83
+ try {
84
+ const response = await fetch(CONNECT_PATH, { credentials: 'same-origin', cache: 'no-store', signal: controller.signal });
85
+ if (!response.ok)
86
+ throw new Error('unavailable');
87
+ const next = await response.json();
88
+ if (controller.signal.aborted)
89
+ return;
90
+ setInfo(next);
91
+ if (next.state !== 'ready' || next.origin !== new URL(login).origin)
92
+ hide();
93
+ }
94
+ catch {
95
+ if (!controller.signal.aborted) {
96
+ hide();
97
+ setFailed(true);
98
+ }
99
+ }
100
+ finally {
101
+ checking = false;
102
+ }
103
+ }, 15_000);
104
+ return () => { controller.abort(); clearInterval(poll); };
105
+ }, [login]);
106
+ return _jsxs("section", { "data-mobile-connect": true, children: [_jsx("style", { children: styles }), _jsx("h2", { children: t('connectPhone') }), _jsx("p", { children: t('connectIntro') }), info?.origin && _jsxs("div", { "data-connect-host": true, children: [_jsx("span", { children: t('server') }), _jsx("strong", { children: new URL(info.origin).host })] }), info && info.state !== 'ready' && _jsx("div", { role: "status", children: _jsx("p", { children: t(info.state === 'unreachable' ? 'connectUnreachable' : info.state === 'untrusted-origin' ? 'connectUntrusted' : info.state === 'unsupported' ? 'connectUnsupported' : 'connectSetup') }) }), failed && _jsx("p", { role: "alert", children: t('connectError') }), busy && _jsx("p", { role: "status", children: t('connectLoading') }), login ? _jsxs("div", { "data-connect-code": true, children: [_jsx(LoginQR, { value: login, label: t('connectQR') }), _jsx("p", { children: t('connectConceal') }), _jsx("button", { type: "button", onClick: hide, children: t('connectHide') })] })
68
107
  : _jsx("button", { type: "button", disabled: busy, onClick: () => { void load(info?.state === 'ready'); }, children: t(info?.state === 'ready' ? 'connectGenerate' : 'directoryRetry') }), _jsxs("ol", { children: [_jsx("li", { children: t('connectStepOpen') }), _jsx("li", { children: t('connectStepScan') }), _jsx("li", { children: t('connectStepConfirm') })] }), _jsx("p", { "data-connect-note": true, children: t('connectPrivacy') }), _jsx("p", { "data-connect-note": true, children: t('connectNetwork') })] });
69
108
  }
70
109
  const styles = `
@@ -119,6 +119,6 @@ export function MobileChrome({ presentation, toggleSidebar, connection, navigati
119
119
  }, [state.drawer, settings, library, toggleSidebar]);
120
120
  if (!state.active && !settings && state.mode !== 'desktop')
121
121
  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, 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)
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
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') })] })] });
124
124
  }
@@ -6,8 +6,9 @@ type Props = PropsLocale<'mobile'> & {
6
6
  navigation: NavigationCapabilities;
7
7
  onOpen: () => void;
8
8
  onBeforeOpen?: () => void;
9
+ visible?: boolean;
9
10
  };
10
11
  /** Browse official metadata; the official workspace service remains the only navigation writer. */
11
- export declare function MobileLibrary({ rooms, navigation, onOpen, onBeforeOpen, t }: Props): import("react").JSX.Element;
12
+ export declare function MobileLibrary({ rooms, navigation, onOpen, onBeforeOpen, visible, t }: Props): import("react").JSX.Element;
12
13
  export {};
13
14
  //# sourceMappingURL=MobileLibrary.d.ts.map
@@ -1,10 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
2
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
3
3
  import { GROUPING_KEY, groupSessions, mainSessionId, recentSessions } from './navigation.js';
4
+ import { CatalogReads } from './catalogReads.js';
4
5
  import { MobileIcon } from './MobileIcon.js';
5
6
  import { hasNativeAction, requestNativeAction } from './native.js';
6
7
  /** Browse official metadata; the official workspace service remains the only navigation writer. */
7
- export function MobileLibrary({ rooms, navigation, onOpen, onBeforeOpen, t }) {
8
+ export function MobileLibrary({ rooms, navigation, onOpen, onBeforeOpen, visible = true, t }) {
9
+ const reads = useMemo(() => new CatalogReads(), [navigation.sessions]);
8
10
  const sessionFeed = navigation.sessions.list, workspaceFeed = navigation.workspaces.list;
9
11
  // Official feeds may expose prototype methods. Keep their receiver and stable subscriptions.
10
12
  const sessions = useSyncExternalStore(useCallback(listener => sessionFeed.subscribe(listener), [sessionFeed]), useCallback(() => sessionFeed.getSnapshot(), [sessionFeed]));
@@ -46,28 +48,45 @@ export function MobileLibrary({ rooms, navigation, onOpen, onBeforeOpen, t }) {
46
48
  catch {
47
49
  setError(true);
48
50
  }
49
- }, children: [_jsxs("span", { "data-mobile-session-copy": true, children: [_jsx("strong", { children: row.title || (row.blank ? t('newSession') : row.displayTitle) }), _jsx(SessionMetadata, { id: row.id, cwd: row.cwd ?? '', ...(rooms ? { rooms } : {}), t: t })] }), row.running ? _jsx("span", { "data-mobile-activity": true, role: "status", children: t('running') }) : _jsx("time", { dateTime: new Date(row.updatedAt).toISOString(), children: new Date(row.updatedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) })] }) }, row.id)) })] }, group.key);
51
+ }, children: [_jsxs("span", { "data-mobile-session-copy": true, children: [_jsx("strong", { children: row.title || (row.blank ? t('newSession') : row.displayTitle) }), _jsx(SessionMetadata, { row: row, sessions: navigation.sessions, reads: reads, visible: visible, ...(rooms ? { rooms } : {}), t: t })] }), row.running ? _jsx("span", { "data-mobile-activity": true, role: "status", children: t('running') }) : _jsx("time", { dateTime: new Date(row.updatedAt).toISOString(), children: new Date(row.updatedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) })] }) }, row.id)) })] }, group.key);
50
52
  })] }), _jsxs("footer", { "data-mobile-search-dock": true, children: [_jsxs("label", { "data-mobile-search": true, children: [_jsx(MobileIcon, { name: "search", size: 20 }), _jsx("input", { ref: input, type: "search", enterKeyHint: "search", autoComplete: "off", value: query, onFocus: () => setSearching(true), onChange: e => setQuery(e.target.value), placeholder: t('searchTitles'), "aria-label": t('searchTitles') })] }), searching ? _jsx("button", { "data-mobile-search-cancel": true, "aria-label": t('cancel'), onClick: () => { setQuery(''); setSearching(false); input.current?.blur(); }, children: _jsx(MobileIcon, { name: "close" }) }) : hasNativeAction('scan') && _jsx("button", { "data-mobile-scan": true, "aria-label": t('scan'), onClick: () => requestNativeAction('scan'), children: _jsx(MobileIcon, { name: "scan" }) })] })] });
51
53
  }
52
54
  const emptyRoomSubscribe = () => () => { };
53
55
  const emptyRoomSnapshot = () => 0;
54
- function SessionMetadata({ id, cwd, rooms, t }) {
56
+ function SessionMetadata({ row, sessions, reads, visible, rooms, t }) {
55
57
  const ref = useRef(null);
56
58
  useSyncExternalStore(rooms?.subscribe ?? emptyRoomSubscribe, rooms?.getSnapshot ?? emptyRoomSnapshot);
57
59
  useEffect(() => {
58
- if (!rooms?.available() || !ref.current)
60
+ if (!visible || !ref.current)
59
61
  return;
62
+ let cancel;
63
+ const enter = () => {
64
+ if (cancel)
65
+ return;
66
+ // Cold list hints may omit titles. Read the official projection store without
67
+ // retaining/opening a Session; queued title reads take priority over Room scans.
68
+ const cancelTitle = !row.title && !row.blank && typeof sessions.refreshProjections === 'function'
69
+ ? reads.add(async () => { await sessions.refreshProjections(row.id); }, 0)
70
+ : undefined;
71
+ const cancelRoom = rooms?.available()
72
+ ? reads.add(async () => { await rooms.refresh(row.id); })
73
+ : undefined;
74
+ cancel = () => { cancelTitle?.(); cancelRoom?.(); };
75
+ };
76
+ const leave = () => { cancel?.(); cancel = undefined; };
60
77
  if (typeof IntersectionObserver === 'undefined') {
61
- void rooms.refresh(id);
62
- return;
78
+ enter();
79
+ return leave;
63
80
  }
64
- const observer = new IntersectionObserver(entries => { if (entries.some(e => e.isIntersecting)) {
65
- void rooms.refresh(id);
66
- observer.disconnect();
67
- } });
81
+ const observer = new IntersectionObserver(entries => {
82
+ if (entries.some(entry => entry.isIntersecting))
83
+ enter();
84
+ else
85
+ leave();
86
+ }, { root: ref.current.closest('[data-mobile-recents]') });
68
87
  observer.observe(ref.current);
69
- return () => observer.disconnect();
70
- }, [rooms, id]);
71
- const state = rooms?.get(id), count = state ? state.members.length : state === null || !rooms?.available() ? 1 : '…';
72
- return _jsxs("small", { ref: ref, children: [cwd.split(/[\\/]/).filter(Boolean).at(-1) || t('unassignedWorkspace'), " \u00B7 ", count, " ", t('memberUnit')] });
88
+ return () => { observer.disconnect(); leave(); };
89
+ }, [rooms, row.id, row.title, row.blank, sessions, reads, visible]);
90
+ const state = rooms?.get(row.id), count = state ? state.members.length : state === null || !rooms?.available() ? 1 : '…';
91
+ return _jsxs("small", { ref: ref, children: [(row.cwd ?? '').split(/[\\/]/).filter(Boolean).at(-1) || t('unassignedWorkspace'), " \u00B7 ", count, " ", t('memberUnit')] });
73
92
  }
@@ -1,7 +1,17 @@
1
1
  import type { PropsRuntime, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots';
2
+ import { ComposerActions } from './composerActions.ts';
2
3
  import type { MobileRooms } from './rooms.ts';
3
- /** Own only the launcher and grouped navigation; action targets stay host-owned. */
4
- export declare function MobileTools({ t, useSession, rooms }: PropsLocale<'mobile'> & PropsRuntime<'conversation.input.left'> & {
4
+ import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client';
5
+ import type { TokenSpan } from '@deepseek-ai/dsh-client-ui-conversation/client';
6
+ export interface MobileToolsInjected {
5
7
  rooms: MobileRooms;
6
- }): import("react").JSX.Element;
8
+ openCommands: (id: SessionSummary['id'], draft: string, span: TokenSpan) => boolean;
9
+ }
10
+ /** Own only the launcher and grouped navigation; action targets stay host-owned. */
11
+ export declare function MobileTools({ t, useSession, useInput, inputActions, rooms, openCommands }: PropsLocale<'mobile'> & PropsRuntime<'conversation.input.left'> & MobileToolsInjected): import("react").JSX.Element;
12
+ export declare function Tools({ actions, t, invite, openCommands }: {
13
+ actions: ComposerActions;
14
+ openCommands?: (() => boolean) | undefined;
15
+ invite: (() => void) | undefined;
16
+ } & PropsLocale<'mobile'>): import("react").JSX.Element | null;
7
17
  //# sourceMappingURL=MobileTools.d.ts.map
@@ -4,8 +4,9 @@ import { ComposerActions } from './composerActions.js';
4
4
  import { openMobileMembers } from './MobileRooms.js';
5
5
  import { MobileIcon } from './MobileIcon.js';
6
6
  /** Own only the launcher and grouped navigation; action targets stay host-owned. */
7
- export function MobileTools({ t, useSession, rooms }) {
7
+ export function MobileTools({ t, useSession, useInput, inputActions, rooms, openCommands }) {
8
8
  const sessionId = useSession(s => s.sessionId);
9
+ const draft = useInput(s => s.draft);
9
10
  const seat = useRef(null);
10
11
  const [actions, setActions] = useState();
11
12
  useLayoutEffect(() => {
@@ -15,9 +16,9 @@ export function MobileTools({ t, useSession, rooms }) {
15
16
  setActions(controller);
16
17
  return () => controller.dispose();
17
18
  }, []);
18
- return _jsx("span", { ref: seat, "data-mobile-tools-seat": true, children: actions && _jsx(Tools, { actions: actions, t: t, invite: rooms.available() ? () => openMobileMembers(sessionId, true) : undefined }) });
19
+ return _jsx("span", { ref: seat, "data-mobile-tools-seat": true, children: actions && _jsx(Tools, { actions: actions, t: t, openCommands: openCommands ? () => openCommands(sessionId, draft, inputActions.captureInsertion()) : undefined, invite: rooms.available() ? () => openMobileMembers(sessionId, true) : undefined }) });
19
20
  }
20
- function Tools({ actions, t, invite }) {
21
+ export function Tools({ actions, t, invite, openCommands }) {
21
22
  const targets = useSyncExternalStore(actions.subscribe, actions.getSnapshot);
22
23
  const dialog = useRef(null), launcher = useRef(null);
23
24
  const close = () => { dialog.current?.close(); launcher.current?.setAttribute('aria-expanded', 'false'); };
@@ -25,8 +26,9 @@ function Tools({ actions, t, invite }) {
25
26
  close(); }, [targets.commands, targets.attachments]);
26
27
  if (!targets.commands || !targets.attachments)
27
28
  return null;
28
- const invoke = (action) => { close(); actions.invoke(action); };
29
- return _jsxs(_Fragment, { children: [_jsx("button", { ref: launcher, "data-mobile-tools-open": true, "aria-label": t('inputTools'), "aria-haspopup": "dialog", "aria-expanded": "false", onClick: () => { dialog.current?.showModal(); launcher.current?.setAttribute('aria-expanded', 'true'); }, children: _jsx(MobileIcon, { name: "plus" }) }), _jsxs("dialog", { ref: dialog, "data-mobile-tools-dialog": true, "aria-label": t('inputTools'), onClose: () => launcher.current?.setAttribute('aria-expanded', 'false'), onClick: event => { if (event.target === dialog.current) {
29
+ const invoke = (action) => { close(); actions.invoke(action, openCommands); };
30
+ return _jsxs(_Fragment, { children: [_jsx("button", { ref: launcher, "data-mobile-tools-open": true, "aria-label": t('inputTools'), "aria-haspopup": "dialog", "aria-expanded": "false", onClick: () => { if (document.activeElement instanceof HTMLElement)
31
+ document.activeElement.blur(); launcher.current?.focus({ preventScroll: true }); dialog.current?.showModal(); launcher.current?.setAttribute('aria-expanded', 'true'); }, children: _jsx(MobileIcon, { name: "plus" }) }), _jsxs("dialog", { ref: dialog, "data-mobile-tools-dialog": true, "aria-label": t('inputTools'), onClose: () => launcher.current?.setAttribute('aria-expanded', 'false'), onClick: event => { if (event.target === dialog.current) {
30
32
  const r = dialog.current.getBoundingClientRect();
31
33
  if (event.clientY < r.top || event.clientX < r.left || event.clientX > r.right || event.clientY > r.bottom)
32
34
  close();
@@ -0,0 +1,10 @@
1
+ /** Bound optional catalog enrichment so cold log reads cannot fan out across the library. */
2
+ export declare class CatalogReads {
3
+ private readonly limit;
4
+ private pending;
5
+ private running;
6
+ constructor(limit?: number);
7
+ add(work: (active: () => boolean) => Promise<void>, priority?: number): () => void;
8
+ private pump;
9
+ }
10
+ //# sourceMappingURL=catalogReads.d.ts.map
@@ -0,0 +1,28 @@
1
+ /** Bound optional catalog enrichment so cold log reads cannot fan out across the library. */
2
+ export class CatalogReads {
3
+ limit;
4
+ pending = [];
5
+ running = 0;
6
+ constructor(limit = 2) {
7
+ this.limit = limit;
8
+ }
9
+ add(work, priority = 1) {
10
+ const item = { cancelled: false, priority, work };
11
+ this.pending.push(item);
12
+ this.pending.sort((a, b) => a.priority - b.priority);
13
+ this.pump();
14
+ return () => { item.cancelled = true; this.pending = this.pending.filter(entry => entry !== item); };
15
+ }
16
+ pump() {
17
+ while (this.running < this.limit && this.pending.length) {
18
+ const item = this.pending.shift();
19
+ if (item.cancelled)
20
+ continue;
21
+ this.running++;
22
+ // The owner APIs have no caller cancellation. Keep their slot until they settle;
23
+ // cancelling only suppresses queued reads and subsequent optional work.
24
+ void Promise.resolve().then(() => item.cancelled ? undefined : item.work(() => !item.cancelled))
25
+ .catch(() => { }).finally(() => { this.running--; this.pump(); });
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,18 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import type { TokenSpan } from '@deepseek-ai/dsh-client-ui-conversation/client';
3
+ /** Narrow structural face of the host's public inputTriggers.sessionOf API.
4
+ * Candidate discovery, filtering and picks remain owned by the host. */
5
+ export interface MobileCommandController {
6
+ toggleSource(source: string, hit: {
7
+ trigger: '/';
8
+ query: string;
9
+ quoted: boolean;
10
+ position: 'leading' | 'inline';
11
+ span: TokenSpan;
12
+ }): void;
13
+ }
14
+ export interface MobileCommandService {
15
+ sessionOf(ctx: Context): MobileCommandController;
16
+ }
17
+ export declare function openMobileCommands(controller: MobileCommandController | undefined, draft: string, span: TokenSpan): boolean;
18
+ //# sourceMappingURL=commands.d.ts.map
@@ -0,0 +1,6 @@
1
+ export function openMobileCommands(controller, draft, span) {
2
+ if (!controller || typeof controller.toggleSource !== 'function')
3
+ return false;
4
+ controller.toggleSource('command', { trigger: '/', query: '', quoted: false, position: draft.slice(0, span.start).trim() === '' ? 'leading' : 'inline', span });
5
+ return true;
6
+ }
@@ -1,8 +1,8 @@
1
- /** Checked rc1 button bridge. The official editor, chooser, permission menu and
1
+ /** Checked legacy and 0.1.7 composer bridge. The official editor, chooser, permission menu and
2
2
  * confirmation remain mounted and retain their callbacks. Unknown markup falls back. */
3
3
  export type ComposerAction = 'commands' | 'attachments' | 'permissions';
4
4
  export interface ActionTarget {
5
- button: HTMLButtonElement;
5
+ button: HTMLButtonElement | HTMLInputElement;
6
6
  label: string;
7
7
  disabled: boolean;
8
8
  }
@@ -18,7 +18,7 @@ export declare class ComposerActions {
18
18
  readonly subscribe: (listener: () => void) => () => void;
19
19
  private sync;
20
20
  private target;
21
- invoke(action: ComposerAction): boolean;
21
+ invoke(action: ComposerAction, openCommands?: () => boolean): boolean;
22
22
  dispose(): void;
23
23
  }
24
24
  //# sourceMappingURL=composerActions.d.ts.map
@@ -9,7 +9,7 @@ export class ComposerActions {
9
9
  this.observer = new MutationObserver(() => this.sync());
10
10
  const card = seat.closest('[data-composer-card]');
11
11
  if (card)
12
- this.observer.observe(card, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['disabled', 'aria-label'] });
12
+ this.observer.observe(card, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['disabled', 'aria-label', 'contenteditable'] });
13
13
  this.sync();
14
14
  }
15
15
  getSnapshot = () => this.snapshot;
@@ -17,14 +17,21 @@ export class ComposerActions {
17
17
  sync() {
18
18
  const card = this.seat.closest('[data-composer-card]');
19
19
  const file = card?.querySelector(':scope > div:last-child > div:first-child > input[type=file][hidden]');
20
- const attachments = file?.previousElementSibling;
21
- const commands = attachments?.previousElementSibling;
20
+ const previous = file?.previousElementSibling;
21
+ const unified = previous instanceof HTMLButtonElement && previous.getAttribute('aria-haspopup') === 'listbox';
22
+ const attachments = unified ? file : previous;
23
+ const commands = unified ? previous : previous?.previousElementSibling;
22
24
  const next = {};
23
- if (commands instanceof HTMLButtonElement && commands.getAttribute('aria-haspopup') === 'listbox' && attachments instanceof HTMLButtonElement) {
25
+ if (commands instanceof HTMLButtonElement && commands.getAttribute('aria-haspopup') === 'listbox' && (attachments instanceof HTMLButtonElement || attachments instanceof HTMLInputElement)) {
24
26
  next.commands = this.target(commands);
25
27
  next.attachments = this.target(attachments);
28
+ // The new host retains its file input and onChange validation, but folds
29
+ // its button into commands. Match the owner picker admission: editable
30
+ // composer (live, unlocked, not committing) and input enabled (not child).
31
+ if (unified)
32
+ next.attachments.disabled ||= commands.disabled || !card?.querySelector('[contenteditable="true"]');
26
33
  const modes = file?.nextElementSibling;
27
- const permission = modes?.querySelector(':scope > span > button[aria-label]');
34
+ const permission = modes?.querySelector('[data-slot="conversation.input.permission"] button[aria-label], :scope > span > button[aria-label]');
28
35
  // PermissionSelect has a text label between optional glyph and chevron spans.
29
36
  if (permission?.querySelector(':scope > span:not([aria-hidden])') && permission.querySelector(':scope > span[aria-hidden] svg'))
30
37
  next.permissions = this.target(permission);
@@ -35,7 +42,7 @@ export class ComposerActions {
35
42
  this.marked.delete(button);
36
43
  }
37
44
  for (const value of Object.values(next))
38
- if (!this.marked.has(value.button)) {
45
+ if (value.button instanceof HTMLButtonElement && !this.marked.has(value.button)) {
39
46
  value.button.setAttribute('data-mobile-folded-action', '');
40
47
  this.marked.add(value.button);
41
48
  }
@@ -48,13 +55,15 @@ export class ComposerActions {
48
55
  target(button) {
49
56
  return { button, label: button.textContent?.trim() || button.getAttribute('aria-label') || '', disabled: button.disabled };
50
57
  }
51
- invoke(action) {
58
+ invoke(action, openCommands) {
52
59
  this.sync();
53
60
  const target = this.snapshot[action];
54
61
  if (!target || target.disabled || !target.button.isConnected)
55
62
  return false;
56
- // The official command list is a combobox whose keyboard handling stays
57
- // with Lexical. Only an explicit command-menu choice may focus the editor.
63
+ // Open the public source without focusing Lexical. Older hosts retain
64
+ // their original button behavior when the optional service is absent.
65
+ if (action === 'commands' && openCommands?.())
66
+ return true;
58
67
  if (action === 'commands')
59
68
  this.seat.closest('[data-composer-card]')?.querySelector('[contenteditable="true"]')?.focus({ preventScroll: true });
60
69
  // Synchronous with the user's click, including the native file-picker gesture.
@@ -6,11 +6,13 @@ import { MobileDirectory, installMobileDirectoryPicker } from './directory.js';
6
6
  import { MobileNavigation, mainSessionId } from './navigation.js';
7
7
  import { MobileWelcome } from './MobileSeats.js';
8
8
  import { MobileSubmissionFocus } from './SubmissionFocus.js';
9
+ import { openMobileCommands } from './commands.js';
9
10
  import { MobileTools } from './MobileTools.js';
10
11
  import { MobileChrome } from './MobileChrome.js';
11
12
  import { MobileQueue } from './MobileQueue.js';
12
13
  import { MobileRooms } from './rooms.js';
13
14
  import { MessageMenu } from './messageMenu.js';
15
+ import { installSheetGestures } from './sheetGestures.js';
14
16
  import { installForegroundRecovery } from './foreground.js';
15
17
  import { NS, en, zh } from './locales.js';
16
18
  export const inject = ['slots', 'locale', 'layout', 'connection'];
@@ -36,6 +38,7 @@ export function apply(ctx) {
36
38
  ctx.effect(() => { const off = presentation.subscribe(() => { if (!presentation.getSnapshot().active)
37
39
  directory.finish(null); }); return () => { off(); directory.finish(null); }; }, 'mobile: pending directory');
38
40
  ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'mobile-directory', locale: NS, inject: () => ({ directory, navigation }) }, MobileDirectoryOverlay));
41
+ ctx.effect(() => installSheetGestures(document), 'mobile: sheet dismissal');
39
42
  ctx.effect(() => () => { presentation.dispose(); }, 'mobile: presentation');
40
43
  ctx.effect(() => {
41
44
  let remove;
@@ -104,7 +107,11 @@ export function apply(ctx) {
104
107
  let remove;
105
108
  const sync = () => {
106
109
  if (presentation.getSnapshot().active)
107
- remove ??= ctx.slots.register({ name: 'conversation.input.left', id: 'mobile-input-tools', order: -100, locale: NS, inject: () => ({ rooms }) }, MobileTools);
110
+ remove ??= ctx.slots.register({ name: 'conversation.input.left', id: 'mobile-input-tools', order: -100, locale: NS, inject: () => ({ rooms, openCommands: (id, draft, span) => {
111
+ const actx = ctx.get('sessions')?.scope(id);
112
+ const service = ctx.get('inputTriggers');
113
+ return actx !== undefined && typeof service?.sessionOf === 'function' && openMobileCommands(service.sessionOf(actx), draft, span);
114
+ } }) }, MobileTools);
108
115
  else {
109
116
  remove?.();
110
117
  remove = undefined;
@@ -12,6 +12,7 @@ export declare const en: {
12
12
  readonly connectStepConfirm: "Scan this code, check the host address, then confirm the connection.";
13
13
  readonly connectPrivacy: "This QR code grants access to this host. Keep it private. Hiding the code does not revoke the official login link; it remains valid until the host process restarts.";
14
14
  readonly connectNetwork: "Keep the computer and its HTTPS entry online. Scanning reconnects the App; it cannot repair a disconnected tunnel.";
15
+ readonly connectUnreachable: "The public connection is unavailable. Wait for the computer to restore it, then retry to get a new QR code.";
15
16
  readonly connectSetup: "The operator must configure a reachable HTTPS origin using mobile.publicOrigin or DSH_MOBILE_PUBLIC_ORIGIN, and add its hostname to the host trusted hosts.";
16
17
  readonly connectUntrusted: "The configured address is not trusted by this host. Update --trusted-host and restart the host before generating a code.";
17
18
  readonly connectUnsupported: "This host version cannot provide official login links. Update the supported host/plugin combination.";
@@ -12,6 +12,7 @@ export const en = {
12
12
  connectStepConfirm: "Scan this code, check the host address, then confirm the connection.",
13
13
  connectPrivacy: "This QR code grants access to this host. Keep it private. Hiding the code does not revoke the official login link; it remains valid until the host process restarts.",
14
14
  connectNetwork: "Keep the computer and its HTTPS entry online. Scanning reconnects the App; it cannot repair a disconnected tunnel.",
15
+ connectUnreachable: "The public connection is unavailable. Wait for the computer to restore it, then retry to get a new QR code.",
15
16
  connectSetup: "The operator must configure a reachable HTTPS origin using mobile.publicOrigin or DSH_MOBILE_PUBLIC_ORIGIN, and add its hostname to the host trusted hosts.",
16
17
  connectUntrusted: "The configured address is not trusted by this host. Update --trusted-host and restart the host before generating a code.",
17
18
  connectUnsupported: "This host version cannot provide official login links. Update the supported host/plugin combination.",
@@ -51,6 +52,7 @@ export const zh = {
51
52
  connectStepConfirm: "扫描二维码,核对主机地址后确认连接。",
52
53
  connectPrivacy: "二维码可授予这台主机的访问权限,请勿分享。隐藏二维码不会撤销官方登录链接;链接在宿主进程重启前仍然有效。",
53
54
  connectNetwork: "请保持电脑和 HTTPS 入口在线。扫码用于重新连接 App,无法修复已经断开的隧道。",
55
+ connectUnreachable: "公网入口暂不可用。请等待电脑端恢复后重试,获取新的二维码。",
54
56
  connectSetup: "请由部署者通过 mobile.publicOrigin 或 DSH_MOBILE_PUBLIC_ORIGIN 配置手机可达的 HTTPS 地址,并将域名加入宿主信任列表。",
55
57
  connectUntrusted: "宿主尚未信任配置的地址。请更新 --trusted-host 并重启宿主后再生成二维码。",
56
58
  connectUnsupported: "此宿主版本不能提供官方登录链接,请更新到支持的宿主和插件组合。",
@@ -0,0 +1,3 @@
1
+ /** Mobile dismissal delegates to native dialog.close or the Room owner's close handler. */
2
+ export declare function installSheetGestures(doc: Document): () => void;
3
+ //# sourceMappingURL=sheetGestures.d.ts.map
@@ -0,0 +1,81 @@
1
+ /** Mobile dismissal delegates to native dialog.close or the Room owner's close handler. */
2
+ export function installSheetGestures(doc) {
3
+ let drag;
4
+ const reset = () => { if (drag)
5
+ drag.sheet.style.transform = drag.transform; drag = undefined; };
6
+ const close = (overlay) => {
7
+ if (overlay.isConnected && doc.documentElement.hasAttribute('data-dsh-mobile')) {
8
+ if (overlay instanceof HTMLDialogElement) {
9
+ overlay.close();
10
+ return;
11
+ }
12
+ overlay.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
13
+ }
14
+ };
15
+ const down = (event) => {
16
+ reset();
17
+ if (!doc.documentElement.hasAttribute('data-dsh-mobile') || event.touches.length !== 1)
18
+ return;
19
+ const target = event.target;
20
+ const native = target.closest('dialog[data-mobile-tools-dialog][open]');
21
+ const overlay = native ?? target.closest('[data-mobile-room-overlay]');
22
+ const sheet = native ?? overlay?.querySelector('[data-mobile-room-form]');
23
+ if (!sheet || !overlay || !native && doc.querySelector('dialog[open]') || doc.getSelection()?.toString())
24
+ return;
25
+ const point = event.touches[0], bounds = sheet.getBoundingClientRect();
26
+ const outside = native ? target === native && (point.clientY < bounds.top || point.clientY > bounds.bottom || point.clientX < bounds.left || point.clientX > bounds.right) : target === overlay;
27
+ // Only the handle/title band claims drags; the form body keeps native scrolling.
28
+ if (!outside && (sheet.scrollTop > 0 || point.clientY > sheet.getBoundingClientRect().top + 96
29
+ || target.closest('button,input,textarea,select,details,a,[contenteditable],[role=menu]')))
30
+ return;
31
+ drag = { sheet, overlay, x: point.clientX, y: point.clientY, outside, dragging: false, transform: sheet.style.transform };
32
+ };
33
+ const move = (event) => {
34
+ if (!drag)
35
+ return;
36
+ if (event.touches.length !== 1 || !drag.sheet.isConnected || !doc.documentElement.hasAttribute('data-dsh-mobile')) {
37
+ reset();
38
+ return;
39
+ }
40
+ const dx = event.touches[0].clientX - drag.x, dy = event.touches[0].clientY - drag.y;
41
+ if (drag.outside) {
42
+ if (Math.hypot(dx, dy) > 10)
43
+ reset();
44
+ return;
45
+ }
46
+ if (!drag.dragging && (dy < -10 || Math.abs(dx) > 12 && Math.abs(dx) > Math.abs(dy))) {
47
+ reset();
48
+ return;
49
+ }
50
+ if (!drag.dragging && (dy < 12 || dy < Math.abs(dx) * 1.5))
51
+ return;
52
+ drag.dragging = true;
53
+ drag.sheet.style.transform = `translate3d(0,${Math.max(0, dy)}px,0)`;
54
+ if (event.cancelable)
55
+ event.preventDefault();
56
+ };
57
+ const up = (event) => {
58
+ const from = drag, point = event.changedTouches[0];
59
+ reset();
60
+ if (!from || !point)
61
+ return;
62
+ const dx = point.clientX - from.x, dy = point.clientY - from.y;
63
+ if (from.outside && Math.hypot(dx, dy) <= 10 || from.dragging && dy >= 72 && dy > Math.abs(dx) * 1.5) {
64
+ // Suppress the compatibility mouse sequence; the owner receives one close action.
65
+ if (event.cancelable)
66
+ event.preventDefault();
67
+ close(from.overlay);
68
+ }
69
+ };
70
+ doc.addEventListener('touchstart', down, { passive: true });
71
+ doc.addEventListener('touchmove', move, { passive: false });
72
+ doc.addEventListener('touchend', up, { passive: false });
73
+ doc.addEventListener('touchcancel', reset);
74
+ return () => {
75
+ reset();
76
+ doc.removeEventListener('touchstart', down);
77
+ doc.removeEventListener('touchmove', move);
78
+ doc.removeEventListener('touchend', up);
79
+ doc.removeEventListener('touchcancel', reset);
80
+ };
81
+ }