dsh-zen-remote 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/AGENTS.md +9 -1
  2. package/README.md +56 -16
  3. package/docs/interface.md +69 -6
  4. package/docs/remote-access.en.md +7 -5
  5. package/docs/remote-access.md +8 -6
  6. package/dsh-push.mjs +272 -64
  7. package/lib/client.js +550 -28
  8. package/lib/lan-gate-server.cjs +7 -1
  9. package/lib/types/client/MobileHome.d.ts.map +1 -1
  10. package/lib/types/client/MobileSessionHeader.d.ts +1 -1
  11. package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
  12. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
  13. package/lib/types/client/effects/modal-back.d.ts +22 -0
  14. package/lib/types/client/effects/modal-back.d.ts.map +1 -0
  15. package/lib/types/client/history-nav.d.ts +49 -0
  16. package/lib/types/client/history-nav.d.ts.map +1 -0
  17. package/lib/types/client/index.d.ts.map +1 -1
  18. package/lib/types/client/locales.d.ts +4 -2
  19. package/lib/types/client/locales.d.ts.map +1 -1
  20. package/lib/types/client/styles/base.css.d.ts +1 -1
  21. package/lib/types/client/styles/base.css.d.ts.map +1 -1
  22. package/lib/types/client/styles/chips.css.d.ts +1 -1
  23. package/lib/types/client/styles/chips.css.d.ts.map +1 -1
  24. package/lib/types/client/styles/compat.css.d.ts +1 -1
  25. package/lib/types/client/styles/compat.css.d.ts.map +1 -1
  26. package/lib/types/client/styles/composer.css.d.ts +1 -1
  27. package/lib/types/client/styles/composer.css.d.ts.map +1 -1
  28. package/lib/types/client/styles/content.css.d.ts +1 -1
  29. package/lib/types/client/styles/content.css.d.ts.map +1 -1
  30. package/lib/types/client/styles/header.css.d.ts +1 -1
  31. package/lib/types/client/styles/header.css.d.ts.map +1 -1
  32. package/lib/types/client/styles/home.css.d.ts +1 -1
  33. package/lib/types/client/styles/home.css.d.ts.map +1 -1
  34. package/llms.txt +1 -1
  35. package/package.json +23 -21
  36. package/pwa/inject.js +1 -0
  37. package/scripts/sync-doc-version.mjs +83 -0
  38. package/src/client/MobileHome.tsx +17 -1
  39. package/src/client/MobileSessionHeader.tsx +134 -1
  40. package/src/client/MobileSessionInfo.tsx +38 -8
  41. package/src/client/effects/modal-back.ts +98 -0
  42. package/src/client/history-nav.ts +121 -0
  43. package/src/client/index.tsx +2 -0
  44. package/src/client/locales.ts +8 -4
  45. package/src/client/styles/base.css.ts +6 -0
  46. package/src/client/styles/chips.css.ts +4 -1
  47. package/src/client/styles/compat.css.ts +8 -1
  48. package/src/client/styles/composer.css.ts +25 -0
  49. package/src/client/styles/content.css.ts +24 -0
  50. package/src/client/styles/header.css.ts +123 -0
  51. package/src/client/styles/home.css.ts +19 -10
package/lib/client.js CHANGED
@@ -385,6 +385,122 @@ function createNavStore() {
385
385
  });
386
386
  }
387
387
  };
388
+ __modules["history-nav.js"] = function (require, module, exports) {
389
+ "use strict";
390
+ /**
391
+ * Back-gesture plumbing: a stack of dismissible layers mirrored into
392
+ * `history`, so Android's system back gesture closes what is on top instead
393
+ * of leaving the PWA.
394
+ *
395
+ * Why this exists at all: the phone shell's page stack is pure store state
396
+ * (`nav-store.ts`), and nothing ever pushed a history entry. Android's edge
397
+ * swipe IS the browser's back, so with an empty history it exited the app and
398
+ * the next launch was a cold reload. The edge is also where the system
399
+ * gesture lives, which is why the plugin's own edge-swipe never fired on
400
+ * Android — those pixels never reach the page. `systemGestureExclusionRects`
401
+ * is a native-app API with no web equivalent, so the gesture cannot be
402
+ * blocked; it can only be *given something to do*. (An early version called
403
+ * `history.back()` from the custom gesture and was removed as "a no-op
404
+ * against an SPA" — the no-op was the missing history entry, not `back()`.)
405
+ *
406
+ * Contract, and the one rule that keeps this honest: **layers close in one
407
+ * direction only.** A surface never flips its own state to closed; it calls
408
+ * {@link popLayer}, which rewinds history, which fires `popstate`, which runs
409
+ * the layer's `close`. State driven from both ends drifts out of sync with
410
+ * the history stack, and the drift shows up as a back gesture that does
411
+ * nothing.
412
+ *
413
+ * Verified against the harness: DSH's own client never touches the History
414
+ * API (`pushState`/`popstate`/`replaceState` all absent from dsh-client-
415
+ * runtime, -ui-conversation and -ui-layout, 2026-08-20), so this is
416
+ * uncontested ground.
417
+ */
418
+ Object.defineProperty(exports, "__esModule", { value: true });
419
+ exports.pushLayer = pushLayer;
420
+ exports.popLayer = popLayer;
421
+ exports.hasLayer = hasLayer;
422
+ exports.layerDepth = layerDepth;
423
+ exports.resetLayers = resetLayers;
424
+ let stack = [];
425
+ let wired = false;
426
+ function depthOf(state) {
427
+ if (typeof state !== 'object' || state === null)
428
+ return 0;
429
+ const depth = state.dshNav;
430
+ return typeof depth === 'number' ? depth : 0;
431
+ }
432
+ /**
433
+ * Unwind to the depth the browser landed on, closing every layer above it.
434
+ * A single gesture can cross more than one entry (a long swipe, or a
435
+ * restored session), so this is a loop rather than one pop.
436
+ */
437
+ function onPopState(event) {
438
+ const target = depthOf(event.state);
439
+ while (stack.length > target) {
440
+ const layer = stack.pop();
441
+ try {
442
+ layer?.close();
443
+ }
444
+ catch {
445
+ // A layer that throws must not strand the ones underneath it.
446
+ }
447
+ }
448
+ }
449
+ function wire() {
450
+ if (wired)
451
+ return;
452
+ wired = true;
453
+ window.addEventListener('popstate', onPopState);
454
+ }
455
+ /** Open a layer: remember how to close it, and give back one entry to spend. */
456
+ function pushLayer(layer) {
457
+ wire();
458
+ if (stack.some((entry) => entry.id === layer.id))
459
+ return;
460
+ stack.push(layer);
461
+ // Same URL on purpose: these are view states, not addresses. A changed URL
462
+ // would show up in the PWA's address handling and survive a reload as a
463
+ // route this app cannot serve.
464
+ history.pushState({ dshNav: stack.length }, '', location.href);
465
+ }
466
+ /**
467
+ * Close a layer the app-side way (a close button, a nav action). Rewinds
468
+ * history instead of flipping state, so the two never diverge.
469
+ */
470
+ function popLayer(id) {
471
+ const index = stack.findIndex((entry) => entry.id === id);
472
+ if (index < 0)
473
+ return;
474
+ if (index === stack.length - 1) {
475
+ // The common case: let popstate do the closing so there is exactly one
476
+ // code path that ever calls `close`.
477
+ history.back();
478
+ return;
479
+ }
480
+ // Closed out of order (a deeper layer is still open above it). Drop it from
481
+ // the stack and close it here — this is the one case where `close` runs
482
+ // outside popstate, because rewinding would take the deeper layer with it.
483
+ const [layer] = stack.splice(index, 1);
484
+ try {
485
+ layer?.close();
486
+ }
487
+ catch {
488
+ // Same containment as the popstate path.
489
+ }
490
+ }
491
+ /** Whether a layer is currently open. */
492
+ function hasLayer(id) {
493
+ return stack.some((entry) => entry.id === id);
494
+ }
495
+ /** Test seam: current depth. */
496
+ function layerDepth() {
497
+ return stack.length;
498
+ }
499
+ /** Test seam: drop everything without touching history. */
500
+ function resetLayers() {
501
+ stack = [];
502
+ }
503
+ };
388
504
  __modules["session-dot.js"] = function (require, module, exports) {
389
505
  "use strict";
390
506
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -762,6 +878,9 @@ const jsx_runtime_1 = require("react/jsx-runtime");
762
878
  const react_1 = require("react");
763
879
  const dsh_client_ui_primitives_1 = require("@deepseek-ai/dsh-client-ui-primitives");
764
880
  const nav_store_ts_1 = require("./nav-store.js");
881
+ const history_nav_ts_1 = require("./history-nav.js");
882
+ /** The phone shell's one page-stack step: session list -> session. */
883
+ const SESSION_LAYER = 'session';
765
884
  const session_dot_ts_1 = require("./session-dot.js");
766
885
  const MobileHomeChips_tsx_1 = require("./MobileHomeChips.js");
767
886
  /** Phone breakpoint: below the tablet range, where the app-shell layout applies. */
@@ -881,8 +1000,21 @@ function MobileHome({ useSessions, useWorkspaces, useStore, actions, openSession
881
1000
  // store directly — a handle mounts under exactly one scope, and this one
882
1001
  // is already root-scoped here (see nav-store.ts) — so it dispatches
883
1002
  // GO_HOME_EVENT instead and this, the store's actual owner, applies it.
1003
+ //
1004
+ // Both directions go through the history layer (history-nav.ts) so
1005
+ // Android's system back gesture lands on the session→list step instead of
1006
+ // exiting the PWA. GO_HOME_EVENT rewinds rather than setting the store: the
1007
+ // store is only ever moved home by the layer's own close callback, so the
1008
+ // history stack and the page stack cannot drift apart. The direct
1009
+ // `show('home')` fallback covers a session view that was somehow entered
1010
+ // without a layer (a restored session, a host that blocks pushState).
884
1011
  (0, react_1.useEffect)(() => {
885
- const onGoHome = () => actions.show('home');
1012
+ const onGoHome = () => {
1013
+ if ((0, history_nav_ts_1.hasLayer)(SESSION_LAYER))
1014
+ (0, history_nav_ts_1.popLayer)(SESSION_LAYER);
1015
+ else
1016
+ actions.show('home');
1017
+ };
886
1018
  window.addEventListener(nav_store_ts_1.GO_HOME_EVENT, onGoHome);
887
1019
  return () => window.removeEventListener(nav_store_ts_1.GO_HOME_EVENT, onGoHome);
888
1020
  }, [actions]);
@@ -890,6 +1022,7 @@ function MobileHome({ useSessions, useWorkspaces, useStore, actions, openSession
890
1022
  start();
891
1023
  setSheet(null);
892
1024
  actions.show('session');
1025
+ (0, history_nav_ts_1.pushLayer)({ id: SESSION_LAYER, close: () => actions.show('home') });
893
1026
  };
894
1027
  // Opens the OFFICIAL settings modal (dsh-client-ui-settings-general's
895
1028
  // SettingsRoot) by clicking its real trigger button — its "open" state is
@@ -946,6 +1079,47 @@ const nav_store_ts_1 = require("./nav-store.js");
946
1079
  function IconInfoOutline16({ size = 16 }) {
947
1080
  return ((0, jsx_runtime_1.jsxs)("svg", { width: size, height: size, viewBox: "0 0 16 16", fill: "none", xmlns: "http://www.w3.org/2000/svg", "aria-hidden": "true", children: [(0, jsx_runtime_1.jsx)("circle", { cx: "8", cy: "8", r: "6.7", stroke: "currentColor", strokeWidth: "1.3" }), (0, jsx_runtime_1.jsx)("circle", { cx: "8", cy: "4.7", r: "0.95", fill: "currentColor" }), (0, jsx_runtime_1.jsx)("rect", { x: "7.25", y: "6.9", width: "1.5", height: "4.7", rx: "0.75", fill: "currentColor" })] }));
948
1081
  }
1082
+ /**
1083
+ * Two more hand-built 14px glyphs, same reason as IconInfoOutline16 above:
1084
+ * the primitives family has no subagent or background-task icon. Drawn on
1085
+ * the same 16-box with the same 1.3 stroke so the activity chip reads as
1086
+ * part of the header icon family.
1087
+ */
1088
+ /** Three linked nodes — a parent delegating to children. */
1089
+ function IconSubagentOutline14({ size = 14 }) {
1090
+ return ((0, jsx_runtime_1.jsxs)("svg", { width: size, height: size, viewBox: "0 0 16 16", fill: "none", xmlns: "http://www.w3.org/2000/svg", "aria-hidden": "true", children: [(0, jsx_runtime_1.jsx)("circle", { cx: "8", cy: "3.2", r: "2", stroke: "currentColor", strokeWidth: "1.3" }), (0, jsx_runtime_1.jsx)("circle", { cx: "3.4", cy: "12.6", r: "2", stroke: "currentColor", strokeWidth: "1.3" }), (0, jsx_runtime_1.jsx)("circle", { cx: "12.6", cy: "12.6", r: "2", stroke: "currentColor", strokeWidth: "1.3" }), (0, jsx_runtime_1.jsx)("path", { d: "M8 5.4v2.2M8 7.6H3.4v3M8 7.6h4.6v3", stroke: "currentColor", strokeWidth: "1.3", strokeLinecap: "round", strokeLinejoin: "round" })] }));
1091
+ }
1092
+ /** A clock face — work still ticking in the background. */
1093
+ function IconTaskOutline14({ size = 14 }) {
1094
+ return ((0, jsx_runtime_1.jsxs)("svg", { width: size, height: size, viewBox: "0 0 16 16", fill: "none", xmlns: "http://www.w3.org/2000/svg", "aria-hidden": "true", children: [(0, jsx_runtime_1.jsx)("circle", { cx: "8", cy: "8", r: "6.1", stroke: "currentColor", strokeWidth: "1.3" }), (0, jsx_runtime_1.jsx)("path", { d: "M8 4.4V8l2.5 1.7", stroke: "currentColor", strokeWidth: "1.3", strokeLinecap: "round", strokeLinejoin: "round" })] }));
1095
+ }
1096
+ /**
1097
+ * The official triggers our pills stand in for. Both are hidden and parked
1098
+ * as invisible anchors at the right end of the view-switch band
1099
+ * (styles/header.css.ts) so their own popovers still mount and position from
1100
+ * there; the pill is the visible control and forwards the tap.
1101
+ *
1102
+ * Told apart by `aria-haspopup`, not by class: the subagent entry declares
1103
+ * `tree` (its menu is a session tree) while the jobs entry declares none.
1104
+ * Both roots/triggers only expose build-hashed class names, so the ARIA
1105
+ * contract is the one stable discriminator between two otherwise identical
1106
+ * `[class$="_root"] > button[class$="_trigger"]` shapes.
1107
+ */
1108
+ const ACTIONS_SLOT = '[data-slot="conversation.session.header.actions"]';
1109
+ const SUBAGENT_TRIGGER = `${ACTIONS_SLOT} button[aria-haspopup="tree"]`;
1110
+ const JOBS_TRIGGER = `${ACTIONS_SLOT} > [class$="_root"] > button[class$="_trigger"]:not([aria-haspopup])`;
1111
+ /** One glanceable group: icon + count + state dot; opens the official popover. */
1112
+ function ActivityPill({ kind, count, state, label, trigger, onFallback, Icon }) {
1113
+ if (count === 0)
1114
+ return null;
1115
+ return ((0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "activity-pill", "data-activity-kind": kind, "data-activity-state": state, "aria-label": label, title: label, onClick: () => {
1116
+ const el = document.querySelector(trigger);
1117
+ if (el === null)
1118
+ onFallback();
1119
+ else
1120
+ el.click();
1121
+ }, children: [(0, jsx_runtime_1.jsx)(Icon, {}), (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "activity-count", children: count }), (0, jsx_runtime_1.jsx)("i", { "data-mobile-nav": "activity-dot", "aria-hidden": "true" })] }));
1122
+ }
949
1123
  /**
950
1124
  * Reads the official session-header tablist by role/aria only (no hashed
951
1125
  * classes) — the plan's one sanctioned official-DOM read: ChatStore's view
@@ -1002,10 +1176,28 @@ function useViewTabs() {
1002
1176
  * (styles/header.css.ts) keeps them hidden at >= 768px so the tablet drawer
1003
1177
  * and the desktop layout stay exactly as they were.
1004
1178
  */
1005
- function MobileHeaderActions({ t }) {
1179
+ function MobileHeaderActions({ sessionId, useSessions, t }) {
1006
1180
  const tabs = useViewTabs();
1007
1181
  const active = tabs.find((tab) => tab.active) ?? tabs[0];
1008
- return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("button", { type: "button", "data-mobile-nav": "header-back", "aria-label": t('backToList'), title: t('backToList'), onClick: () => window.dispatchEvent(new CustomEvent(nav_store_ts_1.GO_HOME_EVENT)), children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconChevronLeftOutline14, { size: 20 }) }), tabs.length > 1 && active !== undefined && ((0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "header-viewrow", "aria-label": t('switchView'), onClick: () => tabs.find((tab) => !tab.active)?.el.click(), children: [(0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "header-viewrow-label", children: active.label }), (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "header-viewrow-dots", "aria-hidden": "true", children: tabs.map((tab, index) => ((0, jsx_runtime_1.jsx)("i", { "data-active": tab.active ? '' : undefined }, index))) })] }))] }));
1182
+ /* At-a-glance activity, right end of the view-switch row. The native
1183
+ header entries these replace were hidden on phone: both are ~103px wide
1184
+ and the header's left grid column is 92px, so they overflowed straight
1185
+ across the centred session title. Reading the counts from the sessions
1186
+ snapshot instead of re-homing the official DOM keeps React's ownership
1187
+ of its own nodes intact — the detail lives one tap away in the info
1188
+ card, which this chip opens. */
1189
+ const subagents = useSessions((s) => s.subagentsByParent[sessionId]?.entries ?? []);
1190
+ const jobs = useSessions((s) => s.jobsBySession[sessionId] ?? []);
1191
+ const subagentRunning = subagents.some((e) => e.kind === 'child' && e.activity === 'running');
1192
+ const jobRunning = jobs.some((j) => j.status === 'running' || j.status === 'stopping');
1193
+ const jobBad = jobs.some((j) => j.status === 'failed' || j.status === 'killed');
1194
+ const subagentState = subagentRunning ? 'running' : 'done';
1195
+ const jobState = jobRunning ? 'running' : jobBad ? 'warning' : 'done';
1196
+ const hasActivity = subagents.length > 0 || jobs.length > 0;
1197
+ /* Only reached when the official entry is missing entirely — the info
1198
+ card still carries both counts, so the tap explains something. */
1199
+ const openInfoCard = () => { window.dispatchEvent(new CustomEvent(nav_store_ts_1.SESSION_INFO_EVENT)); };
1200
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("button", { type: "button", "data-mobile-nav": "header-back", "aria-label": t('backToList'), title: t('backToList'), onClick: () => window.dispatchEvent(new CustomEvent(nav_store_ts_1.GO_HOME_EVENT)), children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconChevronLeftOutline14, { size: 20 }) }), tabs.length > 1 && active !== undefined && ((0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "header-viewrow", "aria-label": t('switchView'), onClick: () => tabs.find((tab) => !tab.active)?.el.click(), children: [(0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "header-viewrow-label", children: active.label }), (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "header-viewrow-dots", "aria-hidden": "true", children: tabs.map((tab, index) => ((0, jsx_runtime_1.jsx)("i", { "data-active": tab.active ? '' : undefined }, index))) })] })), hasActivity && ((0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "header-activity", children: [(0, jsx_runtime_1.jsx)(ActivityPill, { kind: "subagent", count: subagents.length, state: subagentState, label: t('infoSubagents', { count: subagents.length }), trigger: SUBAGENT_TRIGGER, onFallback: openInfoCard, Icon: IconSubagentOutline14 }), (0, jsx_runtime_1.jsx)(ActivityPill, { kind: "job", count: jobs.length, state: jobState, label: t('infoJobs', { count: jobs.length }), trigger: JOBS_TRIGGER, onFallback: openInfoCard, Icon: IconTaskOutline14 })] }))] }));
1009
1201
  }
1010
1202
  /**
1011
1203
  * Session header, right lane: the session-info entry (S4 owns the actual
@@ -1096,6 +1288,9 @@ const react_1 = require("react");
1096
1288
  const dsh_client_ui_primitives_1 = require("@deepseek-ai/dsh-client-ui-primitives");
1097
1289
  const client_1 = require("@deepseek-ai/dsh-client-runtime/client");
1098
1290
  const nav_store_ts_1 = require("./nav-store.js");
1291
+ const history_nav_ts_1 = require("./history-nav.js");
1292
+ /** Layer id for the info sheet, so Android back closes it before leaving the session. */
1293
+ const INFO_LAYER = 'session-info';
1099
1294
  const MobileSessionHeader_tsx_1 = require("./MobileSessionHeader.js");
1100
1295
  /* ---- StatsLine-identical formatting -------------------------------------
1101
1296
  * Ported (not imported — the source functions are module-private to
@@ -1123,10 +1318,14 @@ function formatDuration(ms) {
1123
1318
  function billedInputTokens(usage) {
1124
1319
  return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
1125
1320
  }
1126
- /** Cache-hit share of prompt-side input over the whole durable log; null when nothing was billed. */
1321
+ /**
1322
+ * Cache-hit share of prompt-side input over the whole durable log; null when
1323
+ * nothing was billed. Returned unrounded — the cell is the headline figure
1324
+ * now and shows one decimal, so rounding here would throw that digit away.
1325
+ */
1127
1326
  function cacheHitPercent(usage) {
1128
1327
  const denominator = billedInputTokens(usage);
1129
- return denominator === 0 ? null : Math.round((usage.cacheReadTokens / denominator) * 100);
1328
+ return denominator === 0 ? null : (usage.cacheReadTokens / denominator) * 100;
1130
1329
  }
1131
1330
  /** Data-missing / not-yet-observed placeholder for a stat cell. */
1132
1331
  const NA = '—';
@@ -1161,6 +1360,9 @@ function MobileSessionInfo({ sessionId, useSessions, useProjection, forkSession,
1161
1360
  const onOpen = () => {
1162
1361
  setError(null);
1163
1362
  setOpen(true);
1363
+ // Stack a history entry on top of the session's: Android back now
1364
+ // closes this sheet first and only then leaves the session.
1365
+ (0, history_nav_ts_1.pushLayer)({ id: INFO_LAYER, close: () => setOpen(false) });
1164
1366
  };
1165
1367
  window.addEventListener(nav_store_ts_1.SESSION_INFO_EVENT, onOpen);
1166
1368
  return () => window.removeEventListener(nav_store_ts_1.SESSION_INFO_EVENT, onOpen);
@@ -1168,11 +1370,20 @@ function MobileSessionInfo({ sessionId, useSessions, useProjection, forkSession,
1168
1370
  const tabs = (0, MobileSessionHeader_tsx_1.useViewTabs)();
1169
1371
  const row = useSessions((s) => s.byId[sessionId]);
1170
1372
  const subagentCount = useSessions((s) => s.subagentsByParent[sessionId]?.entries.length ?? 0);
1373
+ const jobCount = useSessions((s) => s.jobsBySession[sessionId]?.length ?? 0);
1171
1374
  const stats = useProjection('sessionStats');
1172
1375
  const usage = useProjection('tokenUsage');
1173
1376
  if (!open)
1174
1377
  return null;
1175
- const close = () => setOpen(false);
1378
+ // Rewind rather than flip the flag — see the one-direction rule in
1379
+ // history-nav.ts. Falls back to a plain close if the layer is gone (the
1380
+ // sheet was opened before this wiring existed, or pushState is unavailable).
1381
+ const close = () => {
1382
+ if ((0, history_nav_ts_1.hasLayer)(INFO_LAYER))
1383
+ (0, history_nav_ts_1.popLayer)(INFO_LAYER);
1384
+ else
1385
+ setOpen(false);
1386
+ };
1176
1387
  const run = async (action) => {
1177
1388
  setBusy(true);
1178
1389
  setError(null);
@@ -1241,19 +1452,26 @@ function MobileSessionInfo({ sessionId, useSessions, useProjection, forkSession,
1241
1452
  value: stats === undefined || stats.toolMs === 0 ? NA : formatDuration(stats.toolMs),
1242
1453
  sub: undefined,
1243
1454
  },
1455
+ /* Cache hit is the headline, token flow the sub-line (2026-08-20): on a
1456
+ long session the ratio is the number worth glancing at — it moves, and
1457
+ it is what the bill turns on — while the absolute in→out figure is
1458
+ reference detail. One decimal, because "100%" and "99.6%" are very
1459
+ different answers and integer rounding hid that. */
1244
1460
  {
1245
- label: t('infoStatTokens'),
1246
- value: tokensEmpty || usage === undefined
1247
- ? NA
1248
- : `${formatTokens(billedInputTokens(usage))}→${formatTokens(usage.outputTokens)}`,
1249
- sub: cacheHit === null ? undefined : t('infoCacheHit', { percent: cacheHit }),
1461
+ label: t('infoStatCacheHit'),
1462
+ value: cacheHit === null ? NA : `${cacheHit.toFixed(1)}%`,
1463
+ sub: tokensEmpty || usage === undefined
1464
+ ? undefined
1465
+ : t('infoTokenFlow', {
1466
+ io: `${formatTokens(billedInputTokens(usage))}→${formatTokens(usage.outputTokens)}`,
1467
+ }),
1250
1468
  },
1251
1469
  ];
1252
1470
  return ((0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "info-layer", children: [(0, jsx_runtime_1.jsx)("div", { "data-mobile-nav": "info-mask", role: "button", tabIndex: -1, "aria-label": t('infoClose'), onClick: close }), (0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "info-sheet", role: "dialog", "aria-modal": "true", children: [(0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "info-head", children: [tabs.length > 1 && ((0, jsx_runtime_1.jsx)("div", { "data-mobile-nav": "info-tabs", role: "group", "aria-label": t('switchView'), children: tabs.map((tab) => ((0, jsx_runtime_1.jsx)("button", { type: "button", "aria-pressed": tab.active, "data-mobile-nav": "info-tab", "data-selected": tab.active ? '' : undefined, onClick: () => {
1253
1471
  if (!tab.active)
1254
1472
  tab.el.click();
1255
1473
  close();
1256
- }, children: tab.label }, tab.label))) })), (0, jsx_runtime_1.jsx)("button", { type: "button", "data-mobile-nav": "info-close", "aria-label": t('infoClose'), onClick: close, children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconCloseOutline16, { size: 16 }) })] }), (0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "info-badges", children: [row?.agentPreset !== undefined && (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-badge", children: row.agentPreset }), subagentCount > 0 && ((0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-badge", children: t('infoSubagents', { count: subagentCount }) })), (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-badge-cwd", children: cwd ?? t('infoCwdFallback') })] }), (0, jsx_runtime_1.jsx)("div", { "data-mobile-nav": "info-stats", children: cells.map((cell) => ((0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "info-stat", children: [(0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-stat-value", children: cell.value }), (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-stat-label", children: cell.label }), cell.sub !== undefined && (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-stat-sub", children: cell.sub })] }, cell.label))) }), error !== null && (0, jsx_runtime_1.jsx)("div", { "data-mobile-nav": "info-error", children: t('infoActionError', { message: error }) }), (0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "info-actions", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "info-action", disabled: busy, onClick: onExport, children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconDownloadOutline16, { size: 16 }), (0, jsx_runtime_1.jsx)("span", { children: t('infoExport') })] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "info-action", disabled: busy, onClick: onRename, children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconEditOutline16, { size: 16 }), (0, jsx_runtime_1.jsx)("span", { children: t('infoRename') })] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "info-action", disabled: busy, onClick: onFork, children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconBranchOutline16, { size: 16 }), (0, jsx_runtime_1.jsx)("span", { children: t('infoFork') })] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "info-action", "data-mobile-nav-danger": "", disabled: busy, onClick: onArchive, children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconArchiveOutline20, { size: 20 }), (0, jsx_runtime_1.jsx)("span", { children: t('infoArchive') })] })] })] })] }));
1474
+ }, children: tab.label }, tab.label))) })), (0, jsx_runtime_1.jsx)("button", { type: "button", "data-mobile-nav": "info-close", "aria-label": t('infoClose'), onClick: close, children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconCloseOutline16, { size: 16 }) })] }), (0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "info-badges", children: [row?.agentPreset !== undefined && (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-badge", children: row.agentPreset }), subagentCount > 0 && ((0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-badge", children: t('infoSubagents', { count: subagentCount }) })), jobCount > 0 && ((0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-badge", children: t('infoJobs', { count: jobCount }) })), (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-badge-cwd", children: cwd ?? t('infoCwdFallback') })] }), (0, jsx_runtime_1.jsx)("div", { "data-mobile-nav": "info-stats", children: cells.map((cell) => ((0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "info-stat", children: [(0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-stat-value", children: cell.value }), (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-stat-label", children: cell.label }), cell.sub !== undefined && (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-stat-sub", children: cell.sub })] }, cell.label))) }), error !== null && (0, jsx_runtime_1.jsx)("div", { "data-mobile-nav": "info-error", children: t('infoActionError', { message: error }) }), (0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "info-actions", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "info-action", disabled: busy, onClick: onExport, children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconDownloadOutline16, { size: 16 }), (0, jsx_runtime_1.jsx)("span", { children: t('infoExport') })] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "info-action", disabled: busy, onClick: onRename, children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconEditOutline16, { size: 16 }), (0, jsx_runtime_1.jsx)("span", { children: t('infoRename') })] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "info-action", disabled: busy, onClick: onFork, children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconBranchOutline16, { size: 16 }), (0, jsx_runtime_1.jsx)("span", { children: t('infoFork') })] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "info-action", "data-mobile-nav-danger": "", disabled: busy, onClick: onArchive, children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconArchiveOutline20, { size: 20 }), (0, jsx_runtime_1.jsx)("span", { children: t('infoArchive') })] })] })] })] }));
1257
1475
  }
1258
1476
  };
1259
1477
  __modules["attach-upload.js"] = function (require, module, exports) {
@@ -1712,6 +1930,12 @@ exports.BASE_CSS = `
1712
1930
  align-items: center;
1713
1931
  justify-content: center;
1714
1932
  height: 36px;
1933
+ /* The notice's action row is a flex row and the official confirm button
1934
+ takes the slack; without an explicit shrink guard this button collapsed
1935
+ to about two characters wide on a phone. Content width, never squeezed. */
1936
+ flex: 0 0 auto;
1937
+ min-width: max-content;
1938
+ white-space: nowrap;
1715
1939
  padding: 0 14px;
1716
1940
  margin-right: 8px;
1717
1941
  border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, .15));
@@ -2898,7 +3122,14 @@ exports.COMPAT_CSS = ` /* ---------- dsh-web-ui family compatibility ----------
2898
3122
  border: none;
2899
3123
  border-radius: 999px;
2900
3124
  background: var(--dsw-alias-bg-base, #fff);
2901
- box-shadow: 0 4px 16px rgba(0, 0, 0, .2);
3125
+ /* Drop shadow alone does not draw an edge: the fill is bg-base, which in
3126
+ dark theme is the same colour as the surface behind it, and a black
3127
+ shadow on a dark ground is invisible — the pill read as floating text
3128
+ with no boundary. The inset hairline is the edge; the drop shadow only
3129
+ lifts it. Same token the session rows use for their dark-mode ring. */
3130
+ box-shadow:
3131
+ inset 0 0 0 1px var(--dsw-alias-border-l2, rgba(0, 0, 0, .14)),
3132
+ 0 4px 16px rgba(0, 0, 0, .2);
2902
3133
  color: var(--dsw-alias-label-primary, inherit);
2903
3134
  font-family: inherit;
2904
3135
  font-size: 14px;
@@ -3207,14 +3438,19 @@ exports.HOME_CSS = `/* ---------- phone app shell (< 768px) ---------- */
3207
3438
  values, not source-read guesses, are what actually matter here —
3208
3439
  --dsw-alias-bg-base/-layer-1/-layer-2 all resolve to the exact same
3209
3440
  color in the LIGHT theme (neutral-bluish-00, i.e. plain white); they
3210
- only diverge in dark mode. A layer-* token would have made the page
3211
- and its cards indistinguishable in light mode specifically — the
3212
- opposite of what was asked. --dsw-specific-sidebar-fill differs from
3213
- bg-base in BOTH themes (bluish-50 vs -00 light, bluish-900 vs -950
3214
- dark) and is the exact token dsh-better-sidebar's own panel already
3215
- uses for this same "secondary surface next to bg-base content"
3216
- role, so it is the correct reuse rather than a new hardcoded gray. */
3217
- background: var(--dsw-specific-sidebar-fill, #f5f5f5);
3441
+ only diverge in dark mode.
3442
+
3443
+ 2026-08-20: the page and the CARDS swapped tokens. This surface used
3444
+ to be --dsw-specific-sidebar-fill, which made the list page a
3445
+ different shade from the session page — measured on-device in dark
3446
+ theme, rgb(27,27,28) here against rgb(21,21,23) there, a visible step
3447
+ every time you opened a session. The page now takes bg-base, the same
3448
+ token the session page and <body> resolve to, so the two screens are
3449
+ literally the same colour; the cards took sidebar-fill in exchange, so
3450
+ the contrast that separates a row from the page is preserved — it
3451
+ just runs the other way (raised card on a base page, the more
3452
+ conventional reading of the two anyway). */
3453
+ background: var(--dsw-alias-bg-base, #ffffff);
3218
3454
  color: var(--dsw-alias-label-primary, inherit);
3219
3455
  padding-top: var(--mnav-sat);
3220
3456
  transform: translateX(0);
@@ -3332,7 +3568,10 @@ exports.HOME_CSS = `/* ---------- phone app shell (< 768px) ---------- */
3332
3568
  padding: 14px 16px;
3333
3569
  border: none;
3334
3570
  border-radius: 22px;
3335
- background: var(--dsw-alias-bg-base, #ffffff);
3571
+ /* The page's old colour (see the swap note on [data-mobile-nav="home"]):
3572
+ differs from bg-base in BOTH themes, and is the token
3573
+ dsh-better-sidebar's panel already uses for a secondary surface. */
3574
+ background: var(--dsw-specific-sidebar-fill, #f5f5f5);
3336
3575
  color: inherit;
3337
3576
  font-family: inherit;
3338
3577
  text-align: left;
@@ -3435,7 +3674,8 @@ exports.HOME_CSS = `/* ---------- phone app shell (< 768px) ---------- */
3435
3674
  margin: 12px 16px;
3436
3675
  padding: 48px 24px;
3437
3676
  border-radius: 22px;
3438
- background: var(--dsw-alias-bg-base, #ffffff);
3677
+ /* Stands in for the rows, so it carries the rows' surface. */
3678
+ background: var(--dsw-specific-sidebar-fill, #f5f5f5);
3439
3679
  color: var(--dsw-alias-label-secondary, rgba(0, 0, 0, .5));
3440
3680
  font-size: 15px;
3441
3681
  text-align: center;
@@ -3611,6 +3851,7 @@ exports.HEADER_CSS = `/* ---------- session header five-piece reflow (< 768px) -
3611
3851
  list in misc.css.ts. */
3612
3852
  [data-mobile-nav="header-back"],
3613
3853
  [data-mobile-nav="header-viewrow"],
3854
+ [data-mobile-nav="header-activity"],
3614
3855
  [data-mobile-nav="header-info"],
3615
3856
  [data-mobile-nav="header-workbench"],
3616
3857
  /* The workbench close pill's default-hidden used to live in compat.css.ts —
@@ -3853,6 +4094,48 @@ exports.HEADER_CSS = `/* ---------- session header five-piece reflow (< 768px) -
3853
4094
  [data-phase] header [data-slot="conversation.session.header.utilities"] > * {
3854
4095
  display: none !important;
3855
4096
  }
4097
+ /* Native background-task / subagent entries: kept mounted, but parked as
4098
+ ZERO-WIDTH INVISIBLE ANCHORS at the right end of the view-switch band.
4099
+ Our activity pill is the visible control and forwards the tap to the
4100
+ official trigger, so the official popover — a child of this root — still
4101
+ mounts and positions from here.
4102
+
4103
+ Why not just leave them visible in the header: each is ~103px wide while
4104
+ headerActions' grid column is 92px, and flex children that cannot shrink
4105
+ simply overflow — measured at 375px the subagent entry ran x=60..163
4106
+ straight across the centred title at x=100..275.
4107
+
4108
+ Why not display:none them either: the menu lives inside the root, so
4109
+ hiding the root takes the popover with it and the pill would have
4110
+ nothing to open. Hide the TRIGGER (below) and keep the root as a box. */
4111
+ [data-phase] header [data-slot="conversation.session.header.actions"] > [class$="_root"]:has(> button[class$="_trigger"]) {
4112
+ display: block !important;
4113
+ position: absolute !important;
4114
+ right: 0 !important;
4115
+ top: 100% !important;
4116
+ width: 0 !important;
4117
+ height: 28px !important;
4118
+ overflow: visible !important;
4119
+ order: 0 !important;
4120
+ }
4121
+ /* The official trigger never shows — the pill replaced it. Programmatic
4122
+ .click() still reaches React's handler on a display:none button. */
4123
+ [data-phase] header [data-slot="conversation.session.header.actions"] > [class$="_root"] > button[class$="_trigger"] {
4124
+ display: none !important;
4125
+ }
4126
+ /* Anchor the popover to the RIGHT edge and clamp it to the viewport. The
4127
+ root now sits at the right edge with zero width, so the official
4128
+ \`left: 0\` would start the 336px panel off-screen; layout.css.ts's
4129
+ \`left: 8px\` re-anchor was written for a viewport-width containing block
4130
+ that this element is not. Right-anchoring is the one that holds for both
4131
+ (2026-08-20: the old pairing put the panel 29px past the right edge). */
4132
+ [data-phase] header [data-slot="conversation.session.header.actions"] [class$="_menu"] {
4133
+ left: auto !important;
4134
+ right: 0 !important;
4135
+ width: min(336px, calc(100vw - 32px)) !important;
4136
+ max-width: none !important;
4137
+ max-height: min(420px, calc(100dvh - 140px)) !important;
4138
+ }
3856
4139
  /* The agent-preset mode badge specifically survives the blanket hide
3857
4140
  above: layout.css.ts's shared <=1023px block targets it directly
3858
4141
  (header [class$="_label"]:has(> svg) { display: block !important }),
@@ -3870,6 +4153,12 @@ exports.HEADER_CSS = `/* ---------- session header five-piece reflow (< 768px) -
3870
4153
  [data-phase] header [data-slot="conversation.session.header.actions"] > [data-mobile-nav="header-viewrow"] {
3871
4154
  display: flex !important;
3872
4155
  }
4156
+ /* Our own chip lives in the same slot, so the blanket hide above swallows
4157
+ it unless it is named here too (it was, on the first build: the chip
4158
+ rendered with the right counts and painted nothing). */
4159
+ [data-phase] header [data-slot="conversation.session.header.actions"] > [data-mobile-nav="header-activity"] {
4160
+ display: inline-flex !important;
4161
+ }
3873
4162
  [data-phase] header [data-slot="conversation.session.header.utilities"] > [data-mobile-nav="header-info"],
3874
4163
  [data-phase] header [data-slot="conversation.session.header.utilities"] > [data-mobile-nav="header-workbench"] {
3875
4164
  display: inline-flex !important;
@@ -3947,6 +4236,80 @@ exports.HEADER_CSS = `/* ---------- session header five-piece reflow (< 768px) -
3947
4236
  display:contents on titleCluster does not break that search — so
3948
4237
  top:100% sits directly under the title regardless of the title row's
3949
4238
  actual height, with no hardcoded offset to keep in sync. */
4239
+ /* Activity chip (subagents / background tasks) rides the right end of the
4240
+ same band. It is a SIBLING of the switch button, not a child — that
4241
+ button spans the full row, so nesting would make every tap on the chip
4242
+ switch views too — and it shares titleRow as its containing block, so
4243
+ top:100% lands both on the same 28px line. z-index puts it above the
4244
+ row it overlaps. */
4245
+ [data-mobile-nav="header-activity"] {
4246
+ position: absolute;
4247
+ right: 0;
4248
+ top: 100%;
4249
+ z-index: 1;
4250
+ display: inline-flex;
4251
+ align-items: center;
4252
+ gap: 9px;
4253
+ height: 28px;
4254
+ padding: 0 2px 0 10px;
4255
+ border: none;
4256
+ background: transparent;
4257
+ color: var(--dsw-alias-label-secondary, rgba(0, 0, 0, .5));
4258
+ font-family: inherit;
4259
+ cursor: pointer;
4260
+ touch-action: manipulation;
4261
+ -webkit-tap-highlight-color: transparent;
4262
+ }
4263
+ [data-mobile-nav="activity-pill"] {
4264
+ display: inline-flex;
4265
+ align-items: center;
4266
+ gap: 3px;
4267
+ height: 28px;
4268
+ padding: 0;
4269
+ border: none;
4270
+ background: transparent;
4271
+ color: inherit;
4272
+ font-family: inherit;
4273
+ font-size: 12px;
4274
+ line-height: 18px;
4275
+ cursor: pointer;
4276
+ touch-action: manipulation;
4277
+ -webkit-tap-highlight-color: transparent;
4278
+ }
4279
+ [data-mobile-nav="activity-pill"]:active {
4280
+ opacity: .55;
4281
+ }
4282
+ [data-mobile-nav="activity-count"] {
4283
+ font-variant-numeric: tabular-nums;
4284
+ }
4285
+ /* Steady green = everything settled. */
4286
+ [data-mobile-nav="activity-dot"] {
4287
+ display: block;
4288
+ width: 6px;
4289
+ height: 6px;
4290
+ margin-left: 1px;
4291
+ border-radius: 50%;
4292
+ background: var(--dsw-alias-state-success-primary, #16a34a);
4293
+ }
4294
+ /* A job that failed or was killed is not a success — never paint it green. */
4295
+ [data-activity-state="warning"] [data-mobile-nav="activity-dot"] {
4296
+ background: var(--dsw-alias-state-warn-primary, #d97706);
4297
+ }
4298
+ /* Pulsing blue = still running. Same state palette as the session dot. */
4299
+ [data-activity-state="running"] [data-mobile-nav="activity-dot"] {
4300
+ background: var(--dsw-alias-state-business-primary, #4f6ef7);
4301
+ animation: dsh-zen-activity-pulse 1.4s ease-in-out infinite;
4302
+ }
4303
+ @keyframes dsh-zen-activity-pulse {
4304
+ 0%, 100% { opacity: 1; transform: scale(1); }
4305
+ 50% { opacity: .45; transform: scale(.72); }
4306
+ }
4307
+ @media (prefers-reduced-motion: reduce) {
4308
+ [data-activity-state="running"] [data-mobile-nav="activity-dot"] {
4309
+ animation: none;
4310
+ }
4311
+ }
4312
+
3950
4313
  [data-mobile-nav="header-viewrow"] {
3951
4314
  position: absolute;
3952
4315
  left: 0;
@@ -4278,6 +4641,31 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
4278
4641
  padding: 0 10px !important;
4279
4642
  font-size: 11.5px !important;
4280
4643
  }
4644
+ /* --- 7a. the native to-do card opts OUT of the 26px pill cage ---
4645
+ conversation.input.dock is not only a chip rail: DSH's own TodoPanel
4646
+ (dsh-client-ui-conversation) registers here, and it is an expandable
4647
+ card, not a pill — tapping its header renders the to-do list INSIDE
4648
+ itself. The cage's max-height:26px + overflow:hidden left that list
4649
+ rendered but clipped to nothing, so on a phone the bar looked dead:
4650
+ aria-expanded flipped, 8 rows mounted at 216px, and the card still
4651
+ painted 26px tall (measured live, 2026-08-20). Give it the official
4652
+ geometry back — full row, natural height, 12px radius. Its list caps
4653
+ itself at 180px with its own scroller, so this cannot run into the
4654
+ composer. Anchored on the official data-testid, a stable contract
4655
+ marker rather than a hashed class name. */
4656
+ [data-slot="conversation.input.dock"] > [data-testid="todo-panel"] {
4657
+ flex: 1 1 auto !important;
4658
+ min-width: 0 !important;
4659
+ max-width: none !important;
4660
+ min-height: 0 !important;
4661
+ max-height: none !important;
4662
+ overflow: visible !important;
4663
+ border-radius: 12px !important;
4664
+ /* The cage shrinks dock text to 11.5px/18px; the card's rows inherit it
4665
+ and turn cramped. Restore the official 13px body scale. */
4666
+ font-size: 13px !important;
4667
+ line-height: normal !important;
4668
+ }
4281
4669
 
4282
4670
  /* --- 7b. attachment chips (S7.1) ---
4283
4671
  Our own dock entry opts OUT of the 26px pill cage above: an image chip is
@@ -4972,7 +5360,10 @@ exports.CHIPS_CSS = `/* ---------- chips row + settings entry (S5) ---------- */
4972
5360
  padding: 0 14px;
4973
5361
  border: none;
4974
5362
  border-radius: 17px;
4975
- background: var(--dsw-alias-bg-base, #ffffff);
5363
+ /* Same surface as the list rows: the home page is bg-base since the
5364
+ 2026-08-20 swap (see home.css.ts), so a bg-base chip would disappear
5365
+ into it. */
5366
+ background: var(--dsw-specific-sidebar-fill, #f5f5f5);
4976
5367
  color: var(--dsw-alias-label-primary, inherit);
4977
5368
  font-family: inherit;
4978
5369
  font-size: 13px;
@@ -5112,6 +5503,30 @@ exports.CONTENT_CSS = `/* ---------- chat markdown content (< 768px) ----------
5112
5503
  overflow-wrap: anywhere;
5113
5504
  }
5114
5505
 
5506
+ /* Transcript density (2026-08-20): the desktop prose rhythm is 28px of
5507
+ leading on 15px text — a 1.87 ratio that reads generously on a wide
5508
+ column but wastes a phone screen, where the same paragraph is three
5509
+ times as many lines. Unitless 1.65 so nested blocks (code, quotes,
5510
+ lists) recompute from their OWN font-size instead of inheriting a fixed
5511
+ 28px that is far too loose for 12-13px chrome text. 1.65 is the floor I
5512
+ am willing to go to for mixed CJK/Latin: the glyphs are full-height with
5513
+ no descender relief, so tighter starts to look stacked. Phone only —
5514
+ the desktop column keeps the official rhythm. */
5515
+ [class*="_markdown_"] {
5516
+ line-height: 1.65;
5517
+ }
5518
+ /* Paragraph rhythm. Each paragraph is its own markdown block and the
5519
+ separation comes from the flex container's row-gap, not from margins —
5520
+ at the stock 16px the paragraph baseline step was 40.75px against a
5521
+ 24.75px line step, i.e. 1.65x, which is what reads as airy. 12px brings
5522
+ the step to ~36.75px: still clearly more than one line so paragraphs
5523
+ never run together, but noticeably denser. Scoped by :has() to
5524
+ containers that actually hold prose blocks, so tool cards and other
5525
+ flex bodies with the same hashed suffix keep their own spacing. */
5526
+ [data-phase] [class$="_body"]:has(> [class*="_markdown_"]) {
5527
+ row-gap: 12px !important;
5528
+ }
5529
+
5115
5530
  /* Markdown tables: the official _tableScroll_ wrapper is already a
5116
5531
  horizontal scroll viewport around a width:max-content table, but its
5117
5532
  cells are capped at min(30vw, 320px) — barely 120px on a phone, which
@@ -6274,13 +6689,15 @@ exports.zh = {
6274
6689
  'infoMode': '模式',
6275
6690
  'infoCwdFallback': '未知目录',
6276
6691
  'infoSubagents': '{count} 个子代理',
6692
+ 'infoJobs': '{count} 个后台任务',
6693
+ 'activityOpen': '查看子代理与后台任务',
6277
6694
  'infoStatTurns': '轮次',
6278
6695
  'infoStatSteps': '步骤',
6279
6696
  'infoStatTtft': '首字延迟',
6280
6697
  'infoStatLlm': '模型耗时',
6281
6698
  'infoStatTool': '工具耗时',
6282
- 'infoStatTokens': 'Token',
6283
- 'infoCacheHit': '缓存 {percent}%',
6699
+ 'infoStatCacheHit': '缓存命中',
6700
+ 'infoTokenFlow': 'Token {io}',
6284
6701
  'infoExport': '导出日志',
6285
6702
  'infoRename': '重命名',
6286
6703
  'infoRenamePrompt': '会话新名称',
@@ -6325,13 +6742,15 @@ exports.en = {
6325
6742
  'infoMode': 'Mode',
6326
6743
  'infoCwdFallback': 'Unknown directory',
6327
6744
  'infoSubagents': '{count} subagents',
6745
+ 'infoJobs': '{count} background tasks',
6746
+ 'activityOpen': 'View subagents and background tasks',
6328
6747
  'infoStatTurns': 'Turns',
6329
6748
  'infoStatSteps': 'Steps',
6330
6749
  'infoStatTtft': 'TTFT',
6331
6750
  'infoStatLlm': 'LLM time',
6332
6751
  'infoStatTool': 'Tool time',
6333
- 'infoStatTokens': 'Tokens',
6334
- 'infoCacheHit': 'Cache {percent}%',
6752
+ 'infoStatCacheHit': 'Cache hit',
6753
+ 'infoTokenFlow': 'Tokens {io}',
6335
6754
  'infoExport': 'Export log',
6336
6755
  'infoRename': 'Rename',
6337
6756
  'infoRenamePrompt': 'New session name',
@@ -6628,6 +7047,107 @@ function installTurnFold(ctx) {
6628
7047
  }, 'dsh-mobile-nav: turn process fold');
6629
7048
  }
6630
7049
  };
7050
+ __modules["effects/modal-back.js"] = function (require, module, exports) {
7051
+ "use strict";
7052
+ /**
7053
+ * Give Android's back gesture something to close for modals this plugin does
7054
+ * NOT own — the official settings/export dialogs, the composer's permission
7055
+ * and model sheets, third-party panels like dsh-better-sidebar's workbench.
7056
+ *
7057
+ * Those all keep their open state in their own React components with no
7058
+ * public setter, so there is nothing to call. What they do share is a
7059
+ * contract the DOM exposes: while open they are `[aria-modal="true"]`, and
7060
+ * they close on Escape (the standard dismissal every one of these primitives
7061
+ * implements). So: watch for one appearing, stack a history layer, and close
7062
+ * it by sending Escape.
7063
+ *
7064
+ * Anchored on the ARIA contract rather than on any hashed class or plugin
7065
+ * name, so a modal from a plugin nobody has written compat for still gets a
7066
+ * working back gesture.
7067
+ *
7068
+ * Phone only. On desktop the back gesture is not a thing and the official
7069
+ * dialogs keep their own behaviour untouched (the repo's desktop no-op rule).
7070
+ */
7071
+ Object.defineProperty(exports, "__esModule", { value: true });
7072
+ exports.installModalBack = installModalBack;
7073
+ const history_nav_ts_1 = require("./history-nav.js");
7074
+ const PHONE = '(max-width: 767px)';
7075
+ /**
7076
+ * Modals this plugin does NOT own. Every surface the plugin builds itself
7077
+ * carries a `data-mobile-nav` marker and registers its own layer with a real
7078
+ * close callback, so excluding them here is what keeps a sheet from being
7079
+ * counted twice.
7080
+ *
7081
+ * It was counted twice once (2026-08-20, caught on-device): the info card
7082
+ * pushed its own layer AND matched here, so one back press unwound two
7083
+ * entries, the history landed at the root while the session view stayed on
7084
+ * screen, and the next press did nothing at all.
7085
+ */
7086
+ const MODAL = '[aria-modal="true"]:not([data-mobile-nav])';
7087
+ /** One id per open modal, so nesting (a dialog over a sheet) still stacks. */
7088
+ let seq = 0;
7089
+ function escapeTo(el) {
7090
+ const init = { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true, cancelable: true };
7091
+ el.dispatchEvent(new KeyboardEvent('keydown', init));
7092
+ el.dispatchEvent(new KeyboardEvent('keyup', init));
7093
+ }
7094
+ function installModalBack(ctx) {
7095
+ ctx.effect(() => {
7096
+ const phone = window.matchMedia(PHONE);
7097
+ /** Modal element -> the layer id standing in for it. */
7098
+ const tracked = new Map();
7099
+ const sync = () => {
7100
+ if (!phone.matches) {
7101
+ // Leaving the phone breakpoint: drop the layers, keep the modals.
7102
+ for (const id of tracked.values())
7103
+ if ((0, history_nav_ts_1.hasLayer)(id))
7104
+ (0, history_nav_ts_1.popLayer)(id);
7105
+ tracked.clear();
7106
+ return;
7107
+ }
7108
+ const open = new Set(document.querySelectorAll(MODAL));
7109
+ for (const el of open) {
7110
+ if (tracked.has(el))
7111
+ continue;
7112
+ seq += 1;
7113
+ const id = `modal-${seq}`;
7114
+ tracked.set(el, id);
7115
+ (0, history_nav_ts_1.pushLayer)({
7116
+ id,
7117
+ close: () => {
7118
+ tracked.delete(el);
7119
+ // Only meaningful while it is still mounted; a modal the user
7120
+ // already dismissed just consumes its entry silently.
7121
+ if (el.isConnected)
7122
+ escapeTo(el);
7123
+ },
7124
+ });
7125
+ }
7126
+ for (const [el, id] of [...tracked]) {
7127
+ if (open.has(el))
7128
+ continue;
7129
+ // Closed by its own affordance (X, backdrop, Escape). Spend the entry
7130
+ // so the next back press moves the page stack instead of no-opping.
7131
+ tracked.delete(el);
7132
+ if ((0, history_nav_ts_1.hasLayer)(id))
7133
+ (0, history_nav_ts_1.popLayer)(id);
7134
+ }
7135
+ };
7136
+ const observer = new MutationObserver(sync);
7137
+ observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['aria-modal'] });
7138
+ phone.addEventListener('change', sync);
7139
+ sync();
7140
+ return () => {
7141
+ observer.disconnect();
7142
+ phone.removeEventListener('change', sync);
7143
+ for (const id of tracked.values())
7144
+ if ((0, history_nav_ts_1.hasLayer)(id))
7145
+ (0, history_nav_ts_1.popLayer)(id);
7146
+ tracked.clear();
7147
+ };
7148
+ }, 'dsh-zen-remote: modal back gesture');
7149
+ }
7150
+ };
6631
7151
  __modules["effects/welcome-notice.js"] = function (require, module, exports) {
6632
7152
  "use strict";
6633
7153
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -6841,6 +7361,7 @@ const aionui_compat_ts_1 = require("./effects/aionui-compat.js");
6841
7361
  const header_status_ts_1 = require("./effects/header-status.js");
6842
7362
  const gestures_ts_1 = require("./effects/gestures.js");
6843
7363
  const turn_fold_ts_1 = require("./effects/turn-fold.js");
7364
+ const modal_back_ts_1 = require("./effects/modal-back.js");
6844
7365
  const welcome_notice_ts_1 = require("./effects/welcome-notice.js");
6845
7366
  const keyboard_guard_ts_1 = require("./effects/keyboard-guard.js");
6846
7367
  const locales_ts_1 = require("./locales.js");
@@ -6886,6 +7407,7 @@ function apply(ctx) {
6886
7407
  // effects/turn-fold.ts for why the keyed conversation.chat.node seat could
6887
7408
  // not carry this.
6888
7409
  (0, turn_fold_ts_1.installTurnFold)(ctx);
7410
+ (0, modal_back_ts_1.installModalBack)(ctx);
6889
7411
  // "内测声明" first-run notice: keep it visible (CSS-hiding it leaked the
6890
7412
  // dialog's #root inert lock — see effects/welcome-notice.ts) and offer a
6891
7413
  // per-browser "不再弹出" opt-out instead.