dsh-mobile 0.8.7 → 0.8.9

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,
@@ -650,9 +917,22 @@ html,body,#root{width:100%;height:100%;overflow:hidden}
650
917
  .dshm-drawer{position:fixed;z-index:70;inset:0 auto 0 0;box-sizing:border-box;width:56px;max-width:100%;padding-top:env(safe-area-inset-top);overflow:hidden;background-color:Canvas;background-image:linear-gradient(var(--dsw-alias-bg-layer-1,#f8fafc),var(--dsw-alias-bg-layer-1,#f8fafc));box-shadow:none;will-change:width;transition:width 240ms cubic-bezier(.22,1,.36,1),box-shadow 240ms ease}
651
918
  .dshm-drawer[data-open=true]{width:min(88vw,340px);box-shadow:18px 0 46px rgb(15 23 42 / 18%)}
652
919
  .dshm-drawer[data-open=false]{pointer-events:auto;visibility:visible}
653
- .dshm-drawer>*{width:100%!important;height:100%!important;transform:translateX(-6px);opacity:.94;transition:transform 220ms cubic-bezier(.22,1,.36,1),opacity 160ms ease-out}
654
- .dshm-drawer[data-open=true]>*{transform:translateX(0);opacity:1}
655
- .dshm-drawer[data-open=false]>*{width:56px!important}
920
+ /* Slide the sidebar content only while opening or closing. A resting transform
921
+ would make this child the containing block for stock fixed-position surfaces
922
+ inside the sidebar and let the drawer's overflow clip them (issue #413: the
923
+ Cordis plugin panel anchors itself to the viewport to escape that clip). */
924
+ .dshm-drawer>*{width:100%!important;height:100%!important;opacity:.94;transition:opacity 160ms ease-out}
925
+ .dshm-drawer[data-open=true]>*{opacity:1;animation:dshm-drawer-slide-open 220ms cubic-bezier(.22,1,.36,1)}
926
+ .dshm-drawer[data-open=false]>*{width:56px!important;animation:dshm-drawer-slide-closed 220ms cubic-bezier(.22,1,.36,1)}
927
+ @keyframes dshm-drawer-slide-open{from{transform:translateX(-6px)}to{transform:translateX(0)}}
928
+ @keyframes dshm-drawer-slide-closed{from{transform:translateX(0)}to{transform:translateX(-6px)}}
929
+ /* The Cordis plugin panel opens from the sidebar footer as a stock fixed surface
930
+ (position:fixed, width:420px, anchored from its trigger) so the sidebar's
931
+ overflow cannot clip it. On phones the drawer must give it real containment:
932
+ span the viewport instead of the desktop width, sit above our drawer but below
933
+ the stock floating slot layer (300), and cap the height so its body scrolls
934
+ (issue #413). */
935
+ .dshm-shell:not([${WIDE_LAYOUT_ATTRIBUTE}="1"]) [data-cordis-panel]{position:fixed!important;inset:auto 8px max(8px,env(safe-area-inset-bottom)) 8px!important;width:auto!important;max-width:min(420px,calc(100vw - 16px))!important;max-height:min(60vh,calc(100dvh - 96px))!important;overflow-y:auto!important;z-index:210!important}
656
936
  /* Folded icon rail: on narrow viewports the drawer is fixed, so give the main
657
937
  content the same 56px offset the wide grid already provides. The width floor
658
938
  keeps phone-width headers on their own inset; the band itself is the root
@@ -727,7 +1007,7 @@ body:has(.dshm-shell) :is(input,textarea,[contenteditable=true],[contenteditable
727
1007
  dims and covers it. Only the wide grid needs this: on a phone the drawer is a
728
1008
  floating overlay and must keep covering the conversation. */
729
1009
  .dshm-shell[${WIDE_LAYOUT_ATTRIBUTE}="1"] .dshm-main{grid-area:1/2;z-index:1}
730
- @media(prefers-reduced-motion:reduce){.dshm-drawer,.dshm-details,.dshm-scrim,.dshm-drawer>*,.dshm-main{transition:none!important}}
1010
+ @media(prefers-reduced-motion:reduce){.dshm-drawer,.dshm-details,.dshm-scrim,.dshm-drawer>*,.dshm-main{transition:none!important}.dshm-drawer>*{animation:none!important}}
731
1011
  /* Touch adaptation changes control boxes, not invisible hit regions. Root opt-out
732
1012
  retains pane geometry too; nested opt-out preserves a widget's own controls.
733
1013
  DSH's plugin cards open through a cardTitle/cardOpen button whose ::after
@@ -739,6 +1019,17 @@ body:has(.dshm-shell) :is(input,textarea,[contenteditable=true],[contenteditable
739
1019
  .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
1020
  .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
1021
  }
1022
+ /* Reading mode (issue #194): after scrolling down a long conversation the
1023
+ composer editor collapses so the reading area is unobstructed. The card stays
1024
+ and keeps the upstream Send/Stop row; its generated label is the way back, and
1025
+ the text comes from --dshm-composer-restore-label so nothing is injected into
1026
+ the React-managed card. Opt-in, off by default. */
1027
+ [data-dsh-mobile-composer-collapsed="true"] [data-composer-card] :is([contenteditable="true"],[contenteditable="plaintext-only"],[contenteditable=""],textarea){display:none!important}
1028
+ [data-dsh-mobile-composer-collapsed="true"] [data-composer-card]{
1029
+ padding-top:6px!important;padding-bottom:6px!important;min-height:0!important;cursor:pointer}
1030
+ [data-dsh-mobile-composer-collapsed="true"] [data-composer-card]::before{
1031
+ content:var(--dshm-composer-restore-label, "");display:flex;align-items:center;
1032
+ justify-content:center;min-height:44px;font-size:13px;opacity:.75;pointer-events:none}
742
1033
  `;
743
1034
  function MobileAppFrame(props) {
744
1035
  const state = (0, react.useSyncExternalStore)(props.controller.subscribe, props.controller.getSnapshot);
@@ -819,6 +1110,34 @@ body:has(.dshm-shell) :is(input,textarea,[contenteditable=true],[contenteditable
819
1110
  prefetchDispose.current = void 0;
820
1111
  };
821
1112
  }, [activeSessionId, prefetchEnabled]);
1113
+ const collapseEnabled = (0, react.useSyncExternalStore)(subscribeComposerCollapseEnabled, readComposerCollapseEnabled, () => false);
1114
+ const activeRunning = props.useSessions(selectActiveSessionRunning);
1115
+ const runningRef = (0, react.useRef)(activeRunning);
1116
+ runningRef.current = activeRunning;
1117
+ const active = collapseEnabled && hasSession;
1118
+ const readingMode = (0, react.useRef)(void 0);
1119
+ (0, react.useEffect)(() => {
1120
+ if (!active) return;
1121
+ const installed = installComposerReadingMode({
1122
+ restoreLabel: messages.composerRestore,
1123
+ isRunning: () => runningRef.current
1124
+ });
1125
+ readingMode.current = installed;
1126
+ if (runningRef.current) installed.expand();
1127
+ return () => {
1128
+ readingMode.current = void 0;
1129
+ installed.dispose();
1130
+ };
1131
+ }, [
1132
+ active,
1133
+ activeSessionId,
1134
+ messages.composerRestore
1135
+ ]);
1136
+ (0, react.useEffect)(() => {
1137
+ if (!activeRunning) return;
1138
+ readingMode.current?.expand();
1139
+ document.documentElement.setAttribute("data-dsh-mobile-composer-collapsed", "false");
1140
+ }, [activeRunning]);
822
1141
  (0, react.useEffect)(() => {
823
1142
  const observer = new MutationObserver(() => {
824
1143
  setDocumentLanguage(document.documentElement.lang);
@@ -1102,6 +1421,7 @@ body:has(.dshm-shell) :is(input,textarea,[contenteditable=true],[contenteditable
1102
1421
  exports.apply = apply;
1103
1422
  exports.closeDetailsFromScrim = closeDetailsFromScrim;
1104
1423
  exports.inject = inject;
1424
+ exports.installComposerReadingMode = installComposerReadingMode;
1105
1425
  exports.installComposerSendKeyboardPolicy = installComposerSendKeyboardPolicy;
1106
1426
  exports.isComposerActionTap = isComposerActionTap;
1107
1427
  exports.isComposerOwnedFocus = isComposerOwnedFocus;
@@ -1109,10 +1429,12 @@ body:has(.dshm-shell) :is(input,textarea,[contenteditable=true],[contenteditable
1109
1429
  exports.isSessionRowNavigation = isSessionRowNavigation;
1110
1430
  exports.isSidebarRightControl = isSidebarRightControl;
1111
1431
  exports.isWideViewportLayout = isWideViewportLayout;
1432
+ exports.readComposerCollapseEnabled = readComposerCollapseEnabled;
1112
1433
  exports.resolveComposerImePolicy = resolveComposerImePolicy;
1113
1434
  exports.resolveDeviceShortSide = resolveDeviceShortSide;
1114
1435
  exports.resolveMobileLayoutLanguage = resolveMobileLayoutLanguage;
1115
1436
  exports.resolveMobileRightbarLayout = resolveMobileRightbarLayout;
1437
+ exports.selectActiveSessionRunning = selectActiveSessionRunning;
1116
1438
  return module.exports;
1117
1439
  }
1118
1440
  });