dsh-mobile 0.8.7 → 0.8.8

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.
@@ -24,19 +24,286 @@ window.__ModuleLoader__.load({
24
24
  it: Object.freeze({
25
25
  closePanels: "Chiudi pannelli",
26
26
  workspaceNavigation: "Navigazione area di lavoro e sessioni",
27
- backToConversation: "Torna alla conversazione"
27
+ backToConversation: "Torna alla conversazione",
28
+ composerRestore: "Mostra il campo di scrittura"
28
29
  }),
29
30
  en: Object.freeze({
30
31
  closePanels: "Close panels",
31
32
  workspaceNavigation: "Workspace and session navigation",
32
- backToConversation: "Back to conversation"
33
+ backToConversation: "Back to conversation",
34
+ composerRestore: "Show composer"
33
35
  }),
34
36
  zh: Object.freeze({
35
37
  closePanels: "关闭浮层",
36
38
  workspaceNavigation: "工作区与会话导航",
37
- backToConversation: "返回会话"
39
+ backToConversation: "返回会话",
40
+ composerRestore: "展开输入框"
38
41
  })
39
42
  });
43
+ // src/composer-collapse.ts
44
+ const COMPOSER_COLLAPSE_DEFAULTS = {
45
+ enabled: false,
46
+ collapseThresholdPx: 480,
47
+ reverseResetPx: 80
48
+ };
49
+ /**
50
+ * Track cumulative reading progress across scroll events. This is the only
51
+ * collapse policy: a decision table plus accumulated progress, kept out of the
52
+ * component so it stays testable without a DOM.
53
+ */
54
+ var ComposerCollapseTracker = class {
55
+ readingProgress = 0;
56
+ reverseProgress = 0;
57
+ state = "expanded";
58
+ config;
59
+ constructor(config) {
60
+ this.config = config;
61
+ }
62
+ /** Whether the opt-in switch is currently on. */
63
+ get enabled() {
64
+ return this.config.enabled;
65
+ }
66
+ /** Update whether collapse is enabled (the user's settings toggle). */
67
+ setEnabled(enabled) {
68
+ this.config = {
69
+ ...this.config,
70
+ enabled
71
+ };
72
+ if (!enabled) this.reset();
73
+ }
74
+ /**
75
+ * Feed one scroll sample; returns the resulting presentation state.
76
+ *
77
+ * reverseResetPx is a deliberate dead zone: a collapsed composer stays
78
+ * collapsed until the user scrolls back up at least that far, so a 1px jitter
79
+ * does not flip the layout on every frame.
80
+ */
81
+ sample(deltaY) {
82
+ if (!this.config.enabled) {
83
+ this.reset();
84
+ return this.state;
85
+ }
86
+ if (this.state === "collapsed") {
87
+ if (deltaY > 0) {
88
+ this.reverseProgress = 0;
89
+ return this.state;
90
+ }
91
+ this.reverseProgress += -deltaY;
92
+ if (this.reverseProgress >= Math.max(this.config.reverseResetPx, 1)) this.reset();
93
+ return this.state;
94
+ }
95
+ if (deltaY < 0) {
96
+ this.readingProgress = Math.max(0, this.readingProgress + deltaY);
97
+ this.reverseProgress = 0;
98
+ return this.state;
99
+ }
100
+ this.readingProgress += deltaY;
101
+ this.reverseProgress = 0;
102
+ if (this.readingProgress >= this.config.collapseThresholdPx) this.state = "collapsed";
103
+ return this.state;
104
+ }
105
+ /** User interacted with the composer (focus, tap, keyboard, draft): expand. */
106
+ expand() {
107
+ this.reset();
108
+ }
109
+ get stateValue() {
110
+ return this.state;
111
+ }
112
+ /** @internal exposed for tests */
113
+ progress() {
114
+ return this.readingProgress;
115
+ }
116
+ reset() {
117
+ this.state = "expanded";
118
+ this.readingProgress = 0;
119
+ this.reverseProgress = 0;
120
+ }
121
+ };
122
+ const COMPOSER_COLLAPSE_EVENT = "dsh-mobile:composer-collapse-change";
123
+ /** Read the persisted opt-in. Defaults to off; unreadable storage keeps it off. */
124
+ function readComposerCollapseEnabled(storage) {
125
+ try {
126
+ const stored = (storage === void 0 ? globalThis.localStorage : storage)?.getItem("dsh-mobile.composer-collapse.enabled") ?? null;
127
+ if (stored !== null) return stored === "true";
128
+ return storage === void 0 ? false : false;
129
+ } catch {
130
+ return storage === void 0 ? false : false;
131
+ }
132
+ }
133
+ /**
134
+ * Subscribe to opt-in changes coming from either bundle. The listener is not
135
+ * called with the initial value; read it once with readComposerCollapseEnabled().
136
+ */
137
+ function subscribeComposerCollapseEnabled(listener) {
138
+ const onStorage = (event) => {
139
+ if (event.key !== null && event.key !== "dsh-mobile.composer-collapse.enabled") return;
140
+ listener(readComposerCollapseEnabled());
141
+ };
142
+ const onCustom = (event) => {
143
+ const detail = event.detail;
144
+ listener(detail?.enabled ?? readComposerCollapseEnabled());
145
+ };
146
+ globalThis.addEventListener?.("storage", onStorage);
147
+ globalThis.addEventListener?.(COMPOSER_COLLAPSE_EVENT, onCustom);
148
+ return () => {
149
+ globalThis.removeEventListener?.("storage", onStorage);
150
+ globalThis.removeEventListener?.(COMPOSER_COLLAPSE_EVENT, onCustom);
151
+ };
152
+ }
153
+ /** Apply the persisted opt-in onto a base config. */
154
+ function composerCollapseConfigFrom(enabled, base = COMPOSER_COLLAPSE_DEFAULTS) {
155
+ return {
156
+ ...base,
157
+ enabled
158
+ };
159
+ }
160
+ /**
161
+ * 软键盘开合监听。
162
+ * 键盘弹起(visualViewport 缩小)意味着用户要输入,应展开收起态。
163
+ * 封装在这里而不是 layout,是为了让 layout 源码不含 visualViewport——
164
+ * 那里只用 window.innerWidth 判宽屏,否则软键盘缩小可视视口会把手机误判成宽屏。
165
+ */
166
+ function onViewportResize(handler) {
167
+ const viewport = globalThis.visualViewport;
168
+ if (viewport === null || viewport === void 0) return () => {};
169
+ viewport.addEventListener("resize", handler);
170
+ return () => {
171
+ viewport.removeEventListener("resize", handler);
172
+ };
173
+ }
174
+ const COLLAPSED_ATTRIBUTE = "data-dsh-mobile-composer-collapsed";
175
+ const RESTORE_LABEL_PROPERTY = "--dshm-composer-restore-label";
176
+ const CONVERSATION_SCROLL_SELECTOR = "[data-conversation-scroll]";
177
+ const COMPOSER_CARD_SELECTOR$1 = "[data-composer-card]";
178
+ const COMPOSER_EDITOR_SELECTOR$1 = `${COMPOSER_CARD_SELECTOR$1} :is([contenteditable="true"],[contenteditable="plaintext-only"],[contenteditable=""],textarea)`;
179
+ /**
180
+ * Install the reading-mode collapse. Opt-in through the persisted switch, off by
181
+ * default; the composer and its draft stay owned by DSH, this only toggles a
182
+ * presentation attribute on the document element.
183
+ */
184
+ function installComposerReadingMode(options) {
185
+ const doc = options.doc ?? globalThis.document;
186
+ const root = doc.documentElement;
187
+ const tracker = new ComposerCollapseTracker(composerCollapseConfigFrom(readComposerCollapseEnabled()));
188
+ let collapsed = false;
189
+ const view = doc.defaultView;
190
+ const apply = (next) => {
191
+ if (collapsed === next) return;
192
+ collapsed = next;
193
+ root.setAttribute(COLLAPSED_ATTRIBUTE, next ? "true" : "false");
194
+ };
195
+ const expand = () => {
196
+ tracker.expand();
197
+ apply(false);
198
+ };
199
+ root.style.setProperty(RESTORE_LABEL_PROPERTY, JSON.stringify(options.restoreLabel));
200
+ const scroller = () => {
201
+ const found = doc.querySelector(CONVERSATION_SCROLL_SELECTOR);
202
+ return found instanceof (view?.HTMLElement ?? Object) ? found : void 0;
203
+ };
204
+ let lastTop = scroller()?.scrollTop ?? 0;
205
+ let rafPending = false;
206
+ const sample = () => {
207
+ rafPending = false;
208
+ const port = scroller();
209
+ if (port === void 0) return;
210
+ const top = port.scrollTop;
211
+ const delta = top - lastTop;
212
+ lastTop = top;
213
+ if (busy()) {
214
+ expand();
215
+ return;
216
+ }
217
+ apply(tracker.sample(delta) === "collapsed");
218
+ };
219
+ const onScroll = (event) => {
220
+ const target = event.target;
221
+ if (target === null || !target.hasAttribute?.(CONVERSATION_SCROLL_SELECTOR.slice(1, -1))) return;
222
+ if (rafPending) return;
223
+ rafPending = true;
224
+ if (view === null) {
225
+ sample();
226
+ return;
227
+ }
228
+ view.requestAnimationFrame(sample);
229
+ };
230
+ const busy = () => {
231
+ if (options.isRunning()) return true;
232
+ const card = doc.querySelector(COMPOSER_CARD_SELECTOR$1);
233
+ const editor = card?.querySelector(COMPOSER_EDITOR_SELECTOR$1) ?? null;
234
+ if (editor === null) return false;
235
+ const phase = editor.dataset.phase ?? "";
236
+ if (phase !== "" && phase !== "plain") return true;
237
+ const focused = doc.activeElement;
238
+ if (focused !== null && card !== null && card.contains(focused)) return true;
239
+ return (editor.value ?? editor.textContent ?? "").trim() !== "";
240
+ };
241
+ const guard = () => {
242
+ if (busy()) expand();
243
+ };
244
+ const onPointerDown = (event) => {
245
+ if (!collapsed) return;
246
+ const target = event.target;
247
+ if (target === null || !(target instanceof (view?.Element ?? Object))) return;
248
+ if (target.closest(COMPOSER_CARD_SELECTOR$1) === null) return;
249
+ expand();
250
+ };
251
+ const onFocusIn = () => {
252
+ expand();
253
+ };
254
+ let observed = [];
255
+ const observeCards = () => {
256
+ const cards = [...doc.querySelectorAll(COMPOSER_CARD_SELECTOR$1)];
257
+ if (cards.length === observed.length && cards.every((card, index) => card === observed[index])) return;
258
+ observer.disconnect();
259
+ for (const card of cards) observer.observe(card, {
260
+ childList: true,
261
+ subtree: true,
262
+ characterData: true,
263
+ attributes: true,
264
+ attributeFilter: ["data-phase"]
265
+ });
266
+ observed = cards;
267
+ };
268
+ const observer = new ((view?.MutationObserver) ?? MutationObserver)(() => {
269
+ observeCards();
270
+ guard();
271
+ });
272
+ observeCards();
273
+ doc.addEventListener("scroll", onScroll, {
274
+ passive: true,
275
+ capture: true
276
+ });
277
+ doc.addEventListener("pointerdown", onPointerDown, true);
278
+ doc.addEventListener("focusin", onFocusIn, true);
279
+ const stopPreference = subscribeComposerCollapseEnabled((enabled) => {
280
+ tracker.setEnabled(enabled);
281
+ if (!enabled) expand();
282
+ });
283
+ const stopViewport = onViewportResize(onFocusIn);
284
+ return {
285
+ expand,
286
+ dispose() {
287
+ doc.removeEventListener("scroll", onScroll, { capture: true });
288
+ doc.removeEventListener("pointerdown", onPointerDown, true);
289
+ doc.removeEventListener("focusin", onFocusIn, true);
290
+ stopViewport();
291
+ stopPreference();
292
+ observer.disconnect();
293
+ root.style.removeProperty(RESTORE_LABEL_PROPERTY);
294
+ apply(false);
295
+ }
296
+ };
297
+ }
298
+ /**
299
+ * Whether the active session is streaming a reply. A running reply is not a
300
+ * composer phase: `data-phase` returns to `plain` as soon as the submission is
301
+ * accepted, so this flag is the only truthful signal for the streaming window.
302
+ */
303
+ function selectActiveSessionRunning(state) {
304
+ const current = state.current;
305
+ return current !== void 0 && state.byId[current]?.running === true;
306
+ }
40
307
  // src/session-prefetch.ts
41
308
  const SESSION_PREFETCH_DEFAULTS = {
42
309
  enabled: false,
@@ -739,6 +1006,17 @@ body:has(.dshm-shell) :is(input,textarea,[contenteditable=true],[contenteditable
739
1006
  .dshm-main[data-dsh-panel="1"] [data-slot="main"]>:not([data-dsh-adapt="off"]) :is(button,[role="button"],select,input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=hidden]):not([type=color]):not([type=file]):not([type=image])):not([role="switch"],[role="checkbox"],[role="radio"],[role="slider"],button[class*="_cardTitle"][class*="_cardOpen"],[data-dsh-adapt="off"],[data-dsh-adapt="off"] *){min-width:44px!important;min-height:44px!important}
740
1007
  .dshm-main[data-dsh-panel="1"] [data-slot="main"]>:not([data-dsh-adapt="off"]) :is(button,[role="button"],a):not([data-dsh-adapt="off"],[data-dsh-adapt="off"] *){touch-action:manipulation}
741
1008
  }
1009
+ /* Reading mode (issue #194): after scrolling down a long conversation the
1010
+ composer editor collapses so the reading area is unobstructed. The card stays
1011
+ and keeps the upstream Send/Stop row; its generated label is the way back, and
1012
+ the text comes from --dshm-composer-restore-label so nothing is injected into
1013
+ the React-managed card. Opt-in, off by default. */
1014
+ [data-dsh-mobile-composer-collapsed="true"] [data-composer-card] :is([contenteditable="true"],[contenteditable="plaintext-only"],[contenteditable=""],textarea){display:none!important}
1015
+ [data-dsh-mobile-composer-collapsed="true"] [data-composer-card]{
1016
+ padding-top:6px!important;padding-bottom:6px!important;min-height:0!important;cursor:pointer}
1017
+ [data-dsh-mobile-composer-collapsed="true"] [data-composer-card]::before{
1018
+ content:var(--dshm-composer-restore-label, "");display:flex;align-items:center;
1019
+ justify-content:center;min-height:44px;font-size:13px;opacity:.75;pointer-events:none}
742
1020
  `;
743
1021
  function MobileAppFrame(props) {
744
1022
  const state = (0, react.useSyncExternalStore)(props.controller.subscribe, props.controller.getSnapshot);
@@ -819,6 +1097,34 @@ body:has(.dshm-shell) :is(input,textarea,[contenteditable=true],[contenteditable
819
1097
  prefetchDispose.current = void 0;
820
1098
  };
821
1099
  }, [activeSessionId, prefetchEnabled]);
1100
+ const collapseEnabled = (0, react.useSyncExternalStore)(subscribeComposerCollapseEnabled, readComposerCollapseEnabled, () => false);
1101
+ const activeRunning = props.useSessions(selectActiveSessionRunning);
1102
+ const runningRef = (0, react.useRef)(activeRunning);
1103
+ runningRef.current = activeRunning;
1104
+ const active = collapseEnabled && hasSession;
1105
+ const readingMode = (0, react.useRef)(void 0);
1106
+ (0, react.useEffect)(() => {
1107
+ if (!active) return;
1108
+ const installed = installComposerReadingMode({
1109
+ restoreLabel: messages.composerRestore,
1110
+ isRunning: () => runningRef.current
1111
+ });
1112
+ readingMode.current = installed;
1113
+ if (runningRef.current) installed.expand();
1114
+ return () => {
1115
+ readingMode.current = void 0;
1116
+ installed.dispose();
1117
+ };
1118
+ }, [
1119
+ active,
1120
+ activeSessionId,
1121
+ messages.composerRestore
1122
+ ]);
1123
+ (0, react.useEffect)(() => {
1124
+ if (!activeRunning) return;
1125
+ readingMode.current?.expand();
1126
+ document.documentElement.setAttribute("data-dsh-mobile-composer-collapsed", "false");
1127
+ }, [activeRunning]);
822
1128
  (0, react.useEffect)(() => {
823
1129
  const observer = new MutationObserver(() => {
824
1130
  setDocumentLanguage(document.documentElement.lang);
@@ -1102,6 +1408,7 @@ body:has(.dshm-shell) :is(input,textarea,[contenteditable=true],[contenteditable
1102
1408
  exports.apply = apply;
1103
1409
  exports.closeDetailsFromScrim = closeDetailsFromScrim;
1104
1410
  exports.inject = inject;
1411
+ exports.installComposerReadingMode = installComposerReadingMode;
1105
1412
  exports.installComposerSendKeyboardPolicy = installComposerSendKeyboardPolicy;
1106
1413
  exports.isComposerActionTap = isComposerActionTap;
1107
1414
  exports.isComposerOwnedFocus = isComposerOwnedFocus;
@@ -1109,10 +1416,12 @@ body:has(.dshm-shell) :is(input,textarea,[contenteditable=true],[contenteditable
1109
1416
  exports.isSessionRowNavigation = isSessionRowNavigation;
1110
1417
  exports.isSidebarRightControl = isSidebarRightControl;
1111
1418
  exports.isWideViewportLayout = isWideViewportLayout;
1419
+ exports.readComposerCollapseEnabled = readComposerCollapseEnabled;
1112
1420
  exports.resolveComposerImePolicy = resolveComposerImePolicy;
1113
1421
  exports.resolveDeviceShortSide = resolveDeviceShortSide;
1114
1422
  exports.resolveMobileLayoutLanguage = resolveMobileLayoutLanguage;
1115
1423
  exports.resolveMobileRightbarLayout = resolveMobileRightbarLayout;
1424
+ exports.selectActiveSessionRunning = selectActiveSessionRunning;
1116
1425
  return module.exports;
1117
1426
  }
1118
1427
  });