@linxin666/dsh-remote-web-ui 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +22 -8
  3. package/README.zh.md +12 -7
  4. package/assets/mobile-service-worker.js +1 -2
  5. package/lib/client.js +177 -19
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +222 -15
  8. package/lib/mobile.js +64 -13
  9. package/lib/mobile.js.map +1 -1
  10. package/lib/types/client/PluginSettingsCard.d.ts +2 -0
  11. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  12. package/lib/types/client/PluginSettingsCard.js +2 -2
  13. package/lib/types/client/RemoteEntry.js +1 -1
  14. package/lib/types/client/UpdatePanel.d.ts.map +1 -1
  15. package/lib/types/client/UpdatePanel.js +20 -3
  16. package/lib/types/client/index.d.ts +1 -1
  17. package/lib/types/client/index.d.ts.map +1 -1
  18. package/lib/types/client/index.js +4 -0
  19. package/lib/types/client/locales.d.ts +12 -0
  20. package/lib/types/client/locales.d.ts.map +1 -1
  21. package/lib/types/client/locales.js +22 -10
  22. package/lib/types/client/settings-form.d.ts +1 -1
  23. package/lib/types/client/settings-form.js +1 -1
  24. package/lib/types/client/telemetry.d.ts +27 -0
  25. package/lib/types/client/telemetry.d.ts.map +1 -0
  26. package/lib/types/client/telemetry.js +97 -0
  27. package/lib/types/dsh-home.d.ts.map +1 -1
  28. package/lib/types/dsh-home.js +9 -3
  29. package/lib/types/http.d.ts +1 -1
  30. package/lib/types/http.js +1 -1
  31. package/lib/types/index.d.ts.map +1 -1
  32. package/lib/types/index.js +34 -5
  33. package/lib/types/loopback.d.ts +1 -1
  34. package/lib/types/loopback.js +1 -1
  35. package/lib/types/mobile/api.d.ts +7 -0
  36. package/lib/types/mobile/api.d.ts.map +1 -1
  37. package/lib/types/mobile/api.js +9 -3
  38. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  39. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  40. package/lib/types/mobile/mobile-styles.js +10 -0
  41. package/lib/types/mobile/views/ChatView.d.ts +1 -0
  42. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  43. package/lib/types/mobile/views/ChatView.js +32 -5
  44. package/lib/types/mobile/views/ChatView.stream.test.js +28 -2
  45. package/lib/types/mobile/views/ChatView.test.js +152 -2
  46. package/lib/types/mobile-api.d.ts +14 -0
  47. package/lib/types/mobile-api.d.ts.map +1 -1
  48. package/lib/types/mobile-api.js +56 -1
  49. package/lib/types/mobile-routes.d.ts.map +1 -1
  50. package/lib/types/mobile-routes.js +25 -2
  51. package/lib/types/mount-once.d.ts +1 -1
  52. package/lib/types/mount-once.js +2 -2
  53. package/lib/types/update.d.ts +35 -4
  54. package/lib/types/update.d.ts.map +1 -1
  55. package/lib/types/update.js +98 -4
  56. package/package.json +4 -2
  57. package/src/client/PluginSettingsCard.tsx +4 -2
  58. package/src/client/RemoteEntry.tsx +1 -1
  59. package/src/client/UpdatePanel.tsx +43 -3
  60. package/src/client/index.ts +6 -1
  61. package/src/client/locales.ts +22 -10
  62. package/src/client/remote.module.css +56 -3
  63. package/src/client/settings-card.module.css +5 -0
  64. package/src/client/settings-form.ts +1 -1
  65. package/src/client/telemetry.ts +105 -0
  66. package/src/dsh-home.ts +9 -3
  67. package/src/http.ts +1 -1
  68. package/src/index.ts +36 -4
  69. package/src/loopback.ts +1 -1
  70. package/src/mobile/api.ts +10 -3
  71. package/src/mobile/mobile-styles.ts +10 -0
  72. package/src/mobile/views/ChatView.stream.test.tsx +34 -2
  73. package/src/mobile/views/ChatView.test.tsx +177 -2
  74. package/src/mobile/views/ChatView.tsx +44 -6
  75. package/src/mobile-api.ts +61 -0
  76. package/src/mobile-routes.ts +24 -2
  77. package/src/mount-once.ts +2 -2
  78. package/src/update.ts +117 -4
package/lib/index.js CHANGED
@@ -6,9 +6,11 @@ import z from "schemastery";
6
6
  import { randomBytes } from "node:crypto";
7
7
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
8
8
  import { homedir, networkInterfaces } from "node:os";
9
+ import { isAbsolute as isAbsolute$1, join as join$1 } from "node:path/posix";
9
10
  import { z as z$1 } from "zod";
10
11
  import { readFile } from "node:fs/promises";
11
12
  import { fileURLToPath } from "node:url";
13
+ import { gzipSync } from "node:zlib";
12
14
  import { RpcId } from "@deepseek-ai/dsh-host-apiproxy/api/rpc";
13
15
  import http, { request } from "node:http";
14
16
  import { connect } from "node:net";
@@ -650,8 +652,9 @@ function sanitizeUserAgent(raw) {
650
652
  */
651
653
  /** Expand a leading ~ (or ~user) in a path, platform-style. */
652
654
  function expandHome(path, home = homedir()) {
655
+ const j = home.startsWith("/") ? join$1 : join;
653
656
  if (path === "~") return home;
654
- if (path.startsWith("~/") || path.startsWith("~\\")) return join(home, path.slice(2));
657
+ if (path.startsWith("~/") || path.startsWith("~\\")) return j(home, path.slice(2));
655
658
  return path;
656
659
  }
657
660
  /**
@@ -661,12 +664,15 @@ function expandHome(path, home = homedir()) {
661
664
  * @returns the absolute DSH home path.
662
665
  */
663
666
  function resolveDshHome(env = process.env, home = homedir()) {
667
+ const isPosix = home.startsWith("/");
668
+ const j = isPosix ? join$1 : join;
669
+ const isAbs = isPosix ? isAbsolute$1 : isAbsolute;
664
670
  const raw = env.DSH_HOME;
665
671
  if (raw !== void 0 && raw.trim() !== "") {
666
672
  const expanded = expandHome(raw.trim(), home);
667
- return isAbsolute(expanded) ? expanded : join(process.cwd(), expanded);
673
+ return isAbs(expanded) ? expanded : j(process.cwd(), expanded);
668
674
  }
669
- return join(home, ".dsh");
675
+ return j(home, ".dsh");
670
676
  }
671
677
  /** Resolve the DSH home directory from the live environment. */
672
678
  function dshHome() {
@@ -1442,7 +1448,8 @@ function makeMobileRoutes() {
1442
1448
  writeStatic(res, 200, "text/html", offlineHtml());
1443
1449
  };
1444
1450
  let bundleBody;
1445
- const handleBundle = async (_req, res) => {
1451
+ let bundleGzip;
1452
+ const handleBundle = async (req, res) => {
1446
1453
  if (bundleBody === void 0) {
1447
1454
  const path = mobileBundlePath();
1448
1455
  if (!existsSync(path)) {
@@ -1451,12 +1458,31 @@ function makeMobileRoutes() {
1451
1458
  }
1452
1459
  try {
1453
1460
  bundleBody = await readFile(path, "utf8");
1461
+ bundleGzip = gzipSync(Buffer.from(bundleBody, "utf8"));
1454
1462
  } catch {
1455
1463
  writeStatic(res, 500, "text/plain", "failed to read the mobile bundle");
1456
1464
  return;
1457
1465
  }
1458
1466
  }
1459
- writeStatic(res, 200, "text/javascript", bundleBody);
1467
+ const acceptEncoding = req.headers["accept-encoding"] ?? "";
1468
+ if (typeof acceptEncoding === "string" && acceptEncoding.includes("gzip") && bundleGzip !== void 0) {
1469
+ res.writeHead(200, {
1470
+ "content-type": "text/javascript; charset=utf-8",
1471
+ "content-encoding": "gzip",
1472
+ "cache-control": "no-cache",
1473
+ vary: "Accept-Encoding",
1474
+ "referrer-policy": "no-referrer"
1475
+ });
1476
+ res.end(bundleGzip);
1477
+ } else {
1478
+ res.writeHead(200, {
1479
+ "content-type": "text/javascript; charset=utf-8",
1480
+ "cache-control": "no-cache",
1481
+ vary: "Accept-Encoding",
1482
+ "referrer-policy": "no-referrer"
1483
+ });
1484
+ res.end(bundleBody);
1485
+ }
1460
1486
  };
1461
1487
  let workerBody;
1462
1488
  const handleWorker = async (_req, res) => {
@@ -1557,7 +1583,8 @@ const MOBILE_ALLOWLIST = /* @__PURE__ */ new Set([
1557
1583
  "session.prompt",
1558
1584
  "session.models",
1559
1585
  "session.selectModel",
1560
- "session.rename"
1586
+ "session.rename",
1587
+ "session.cancel"
1561
1588
  ]);
1562
1589
  /**
1563
1590
  * Locally answered display-preference method (the phone's read-only
@@ -1567,6 +1594,8 @@ const MOBILE_ALLOWLIST = /* @__PURE__ */ new Set([
1567
1594
  const MOBILE_PREFERENCES_METHOD = "mobile.preferences";
1568
1595
  const MOBILE_PENDING_METHOD = "mobile.pending";
1569
1596
  const MOBILE_RESPOND_METHOD = "mobile.respond";
1597
+ /** Execute a slash command through the host command registry (#1125). */
1598
+ const MOBILE_COMMAND_METHOD = "mobile.command";
1570
1599
  /** One session.list page (thin phones load incrementally). */
1571
1600
  const SESSION_PAGE_SIZE = 20;
1572
1601
  /** SSE keep-alive ping cadence for the live mux stream (single connection). */
@@ -1649,7 +1678,7 @@ function makeMobileApiRoutes(deps) {
1649
1678
  return;
1650
1679
  }
1651
1680
  const method = pathname.slice(MOBILE_API_METHOD_PREFIX.length);
1652
- const local = method === MOBILE_PREFERENCES_METHOD || method === MOBILE_PENDING_METHOD || method === MOBILE_RESPOND_METHOD;
1681
+ const local = method === MOBILE_PREFERENCES_METHOD || method === MOBILE_PENDING_METHOD || method === MOBILE_RESPOND_METHOD || method === MOBILE_COMMAND_METHOD;
1653
1682
  if (!MOBILE_ALLOWLIST.has(method) && !local) {
1654
1683
  writeJson(res, 403, {
1655
1684
  ok: false,
@@ -1735,6 +1764,73 @@ function makeMobileApiRoutes(deps) {
1735
1764
  }
1736
1765
  });
1737
1766
  }
1767
+ } else if (method === MOBILE_COMMAND_METHOD) {
1768
+ const payload = parsed.payload;
1769
+ const sessionId = payload?.sessionId;
1770
+ const line = payload?.line;
1771
+ if (typeof sessionId !== "string" || typeof line !== "string") writeJson(res, 200, {
1772
+ type: "server-response",
1773
+ rpcId,
1774
+ result: {
1775
+ ok: false,
1776
+ error: {
1777
+ code: "bad-request",
1778
+ message: "missing sessionId or line"
1779
+ }
1780
+ }
1781
+ });
1782
+ else if (deps.commandDispatcher === void 0) writeJson(res, 200, {
1783
+ type: "server-response",
1784
+ rpcId,
1785
+ result: {
1786
+ ok: false,
1787
+ error: {
1788
+ code: "unavailable",
1789
+ message: "command dispatcher is not available"
1790
+ }
1791
+ }
1792
+ });
1793
+ else try {
1794
+ const abort = new AbortController();
1795
+ res.on("close", () => {
1796
+ if (!res.writableEnded) abort.abort();
1797
+ });
1798
+ const outcome = await deps.commandDispatcher.execute(sessionId, line, abort.signal);
1799
+ if ("error" in outcome) writeJson(res, 200, {
1800
+ type: "server-response",
1801
+ rpcId,
1802
+ result: {
1803
+ ok: false,
1804
+ error: {
1805
+ code: outcome.error,
1806
+ message: outcome.error
1807
+ }
1808
+ }
1809
+ });
1810
+ else writeJson(res, 200, {
1811
+ type: "server-response",
1812
+ rpcId,
1813
+ result: {
1814
+ ok: true,
1815
+ value: {
1816
+ matched: true,
1817
+ result: outcome.result
1818
+ }
1819
+ }
1820
+ });
1821
+ } catch (error) {
1822
+ writeJson(res, 200, {
1823
+ type: "server-response",
1824
+ rpcId,
1825
+ result: {
1826
+ ok: false,
1827
+ error: {
1828
+ code: "internal",
1829
+ message: error instanceof Error ? error.message : String(error)
1830
+ }
1831
+ }
1832
+ });
1833
+ }
1738
1834
  }
1739
1835
  return;
1740
1836
  }
@@ -1874,6 +1970,7 @@ async function dispatch(apiProxy, method, payload, rpcId, signal) {
1874
1970
  if (method === "session.models") return wrap(await apiProxy.sessions.models(request));
1875
1971
  if (method === "session.selectModel") return wrap(await apiProxy.sessions.selectModel(request));
1876
1972
  if (method === "session.rename") return wrap(await apiProxy.sessions.rename(request));
1973
+ if (method === "session.cancel") return wrap(await apiProxy.sessions.cancel(request));
1877
1974
  throw new Error(`unhandled allowlisted method ${method}`);
1878
1975
  }
1879
1976
  //#endregion
@@ -2952,9 +3049,11 @@ var TunnelManager = class {
2952
3049
  }
2953
3050
  };
2954
3051
  /** The aggregate package that is the canonical update entry point. */
2955
- const AGGREGATE_PACKAGE = "@linxin666/dsh-web-ui-all";
3052
+ const AGGREGATE_PACKAGE = "@linxin666/dsh-web-all";
2956
3053
  /** Fallback anchor: this plugin's own package when the aggregate is absent. */
2957
3054
  const SELF_PACKAGE = "@linxin666/dsh-remote-web-ui";
3055
+ /** GitHub repository used to surface human-readable release notes. */
3056
+ const UPDATE_RELEASE_REPO = "zhu1090093659/dsh-web";
2958
3057
  /** A profile manifest `name` prefix (e.g. `dsh-profile-web`). */
2959
3058
  const PROFILE_NAME_PREFIX = "dsh-profile-";
2960
3059
  /** How many ancestor directories a profile search walks before giving up. */
@@ -3135,6 +3234,83 @@ async function fetchLatestVersion(name, fetchImpl, timeoutMs = 1e4) {
3135
3234
  return;
3136
3235
  }
3137
3236
  }
3237
+ /** Normalize one release-note bullet. */
3238
+ function cleanNoteItem(text) {
3239
+ return text.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1").replace(/^[-*]\s+/, "").trim();
3240
+ }
3241
+ /** Determine the section kind for a Markdown heading line. */
3242
+ function noteSectionOf(line) {
3243
+ const heading = /^#{1,6}\s+(.+)$/.exec(line.trim());
3244
+ if (heading === null) return void 0;
3245
+ const title = heading[1].replace(/[::].*$/, "").trim().toLowerCase();
3246
+ if (title === "新功能" || title === "new features") return "features";
3247
+ if (title === "修复" || title === "bug fixes" || title === "bugfixes") return "fixes";
3248
+ if (title === "其他改动" || title === "other changes") return "other";
3249
+ }
3250
+ /**
3251
+ * Parse the GitHub Release body into the three conventional sections.
3252
+ *
3253
+ * The committed release notes are bilingual: Chinese is the default view and
3254
+ * English lives inside a `<details>` block. Prefer the Chinese sections when
3255
+ * present and fall back to the whole body for English-only releases. Bullets
3256
+ * are escaped as plain text for the panel (no Markdown renderer).
3257
+ */
3258
+ function parseReleaseNotesSource(version, source) {
3259
+ const notes = {
3260
+ version,
3261
+ features: [],
3262
+ fixes: [],
3263
+ other: []
3264
+ };
3265
+ let current;
3266
+ for (const rawLine of source.split(/\r?\n/)) {
3267
+ const line = rawLine.trim();
3268
+ const section = noteSectionOf(line);
3269
+ if (section !== void 0) {
3270
+ current = section;
3271
+ continue;
3272
+ }
3273
+ if (current === void 0 || !/^-\s+/.test(line)) continue;
3274
+ const item = cleanNoteItem(line);
3275
+ if (item !== "") notes[current].push(item);
3276
+ }
3277
+ if (notes.features.length === 0 && notes.fixes.length === 0 && notes.other.length === 0) return void 0;
3278
+ return notes;
3279
+ }
3280
+ function parseReleaseNotesBody(version, body) {
3281
+ return parseReleaseNotesSource(version, body.split("<details>")[0] ?? body) ?? parseReleaseNotesSource(version, body);
3282
+ }
3283
+ /**
3284
+ * Fetch and parse the GitHub Release for one `dsh-web` version.
3285
+ * @param version - the npm release version (without the `v` prefix).
3286
+ * @param fetchImpl - injected fetch; global `fetch` in the host.
3287
+ * @param timeoutMs - hard timeout for the GitHub API probe.
3288
+ * @returns structured notes, or undefined when unavailable.
3289
+ */
3290
+ async function fetchGitHubReleaseNotes(version, fetchImpl = fetch, timeoutMs = 1e4) {
3291
+ const controller = new AbortController();
3292
+ const timer = setTimeout(() => {
3293
+ controller.abort();
3294
+ }, timeoutMs);
3295
+ try {
3296
+ const response = await fetchImpl(`https://api.github.com/repos/${UPDATE_RELEASE_REPO}/releases/tags/v${version}`, {
3297
+ headers: {
3298
+ accept: "application/vnd.github+json",
3299
+ "user-agent": "dsh-web-update"
3300
+ },
3301
+ signal: controller.signal
3302
+ });
3303
+ if (!response.ok) return void 0;
3304
+ const body = await response.json();
3305
+ if (typeof body !== "object" || body === null) return void 0;
3306
+ const text = body.body;
3307
+ return typeof text === "string" ? parseReleaseNotesBody(version, text) : void 0;
3308
+ } catch {
3309
+ return;
3310
+ } finally {
3311
+ clearTimeout(timer);
3312
+ }
3313
+ }
3138
3314
  /**
3139
3315
  * Sentinel for an installed version that could not be read (missing manifest
3140
3316
  * or resolve failure). It is a real-looking version so `checkUpdates` can
@@ -3202,12 +3378,15 @@ async function checkUpdates(deps) {
3202
3378
  });
3203
3379
  });
3204
3380
  const error = probeFailures === names.length && names.length > 0 ? "registry-unreachable" : void 0;
3381
+ const targetVersion = packages.find((packageStatus) => packageStatus.latest !== void 0)?.latest;
3382
+ const notes = targetVersion !== void 0 && deps.fetchReleaseNotes !== void 0 ? await deps.fetchReleaseNotes(targetVersion).catch(() => void 0) : void 0;
3205
3383
  return {
3206
3384
  mode: linked ? "link" : "npm",
3207
3385
  profileName: profile.name,
3208
3386
  anchor,
3209
3387
  packages,
3210
3388
  outdated: packages.some((packageStatus) => packageStatus.outdated),
3389
+ ...notes !== void 0 ? { notes } : {},
3211
3390
  ...error !== void 0 ? { error } : {}
3212
3391
  };
3213
3392
  }
@@ -3497,7 +3676,7 @@ function makeUpdateRoutes(deps) {
3497
3676
  //#region src/mount-once.ts
3498
3677
  /**
3499
3678
  * Host single-instance guard shared by the plugin family. The family bundle
3500
- * (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
3679
+ * (dsh-web-all / dsh-skins) namespaces every child row id (web-ui-*), so
3501
3680
  * the loader accepts a standalone install of the same package side by side;
3502
3681
  * without this guard the second instance would still re-register the same
3503
3682
  * webserver routes, tools, settings namespaces, and system-prompt sections
@@ -3510,7 +3689,7 @@ function makeUpdateRoutes(deps) {
3510
3689
  * `ctx.effect` runs its callback immediately and treats the callback's
3511
3690
  * return value as the fiber disposer, so the unmarker is returned, not run.
3512
3691
  */
3513
- const MOUNTED = Symbol.for("dsh-web-ui.mounted-plugins");
3692
+ const MOUNTED = Symbol.for("dsh-web.mounted-plugins");
3514
3693
  function mountedSet() {
3515
3694
  const registry = globalThis;
3516
3695
  return registry[MOUNTED] ??= /* @__PURE__ */ new Set();
@@ -3628,7 +3807,12 @@ const UUID_POLYFILL_SCRIPT = [
3628
3807
  /** Stable cordis plugin name. */
3629
3808
  const name = "remote-web-ui";
3630
3809
  /** Services required before the pairing surfaces can mount. */
3631
- const inject = ["webServer", "apiProxy"];
3810
+ const inject = [
3811
+ "webServer",
3812
+ "apiProxy",
3813
+ "commands",
3814
+ "agents"
3815
+ ];
3632
3816
  /**
3633
3817
  * Settings namespace of the remote-control capability — the section the web
3634
3818
  * settings surface edits. Spelled here rather than imported: the browser
@@ -3765,6 +3949,17 @@ function applyImpl(ctx, config) {
3765
3949
  return;
3766
3950
  }
3767
3951
  });
3952
+ const releaseNotesCache = /* @__PURE__ */ new Map();
3953
+ const fetchReleaseNotesCached = async (version) => {
3954
+ const cached = releaseNotesCache.get(version);
3955
+ if (cached !== void 0 && Date.now() - cached.at < 6e5) return cached.notes;
3956
+ const notes = await fetchGitHubReleaseNotes(version, fetch);
3957
+ releaseNotesCache.set(version, {
3958
+ at: Date.now(),
3959
+ notes
3960
+ });
3961
+ return notes;
3962
+ };
3768
3963
  const updateRoutes = makeUpdateRoutes({
3769
3964
  fence: (request) => isTrustedApiRequest(request, []),
3770
3965
  check: () => checkUpdates({
@@ -3776,7 +3971,8 @@ function applyImpl(ctx, config) {
3776
3971
  return;
3777
3972
  }
3778
3973
  },
3779
- fetchLatest: (name) => fetchLatestVersion(name, fetch)
3974
+ fetchLatest: (name) => fetchLatestVersion(name, fetch),
3975
+ fetchReleaseNotes: fetchReleaseNotesCached
3780
3976
  }),
3781
3977
  run: async () => {
3782
3978
  const target = resolveUpdateTarget({ anchorManifestPath: resolveAnchorPath() });
@@ -3786,7 +3982,7 @@ function applyImpl(ctx, config) {
3786
3982
  ok: false,
3787
3983
  exitCode: null,
3788
3984
  output: "",
3789
- error: code === "not-found" ? "dsh-web-ui aggregate not installed" : "local link install — update unavailable",
3985
+ error: code === "not-found" ? "dsh-web aggregate not installed" : "local link install — update unavailable",
3790
3986
  errorCode: code
3791
3987
  };
3792
3988
  }
@@ -3804,7 +4000,8 @@ function applyImpl(ctx, config) {
3804
4000
  return;
3805
4001
  }
3806
4002
  },
3807
- fetchLatest: (name) => fetchLatestVersion(name, fetch)
4003
+ fetchLatest: (name) => fetchLatestVersion(name, fetch),
4004
+ fetchReleaseNotes: fetchReleaseNotesCached
3808
4005
  }
3809
4006
  });
3810
4007
  }
@@ -3820,7 +4017,17 @@ function applyImpl(ctx, config) {
3820
4017
  service,
3821
4018
  apiProxy,
3822
4019
  pendingTracker: new PendingTracker(),
3823
- mobileEnterToSend: () => resolve().mobileEnterToSend
4020
+ mobileEnterToSend: () => resolve().mobileEnterToSend,
4021
+ commandDispatcher: ctx.commands !== void 0 && ctx.agents !== void 0 ? { async execute(sessionId, line, signal) {
4022
+ const agent = ctx.agents.get(sessionId);
4023
+ if (agent === void 0) return { error: "session-not-found" };
4024
+ const execution = await ctx.commands.execute(agent, line, [], signal);
4025
+ if (execution === void 0) return { error: "unknown-command" };
4026
+ return {
4027
+ ok: true,
4028
+ result: execution.result
4029
+ };
4030
+ } } : void 0
3824
4031
  }) : [],
3825
4032
  ...apiProxy !== void 0 ? makePairedModelCatalogRoutes({
3826
4033
  service,
package/lib/mobile.js CHANGED
@@ -7610,15 +7610,18 @@ async function prompt(sessionId, text) {
7610
7610
  }
7611
7611
  /** Send one slash command line (e.g. `/permission workspace-write`). */
7612
7612
  async function sendCommand(sessionId, line) {
7613
- return await callUnary("session.prompt", {
7613
+ return await callUnary("mobile.command", {
7614
7614
  sessionId,
7615
- mode: "queue",
7616
- content: [{
7617
- type: "text",
7618
- text: line
7619
- }]
7615
+ line
7620
7616
  });
7621
7617
  }
7618
+ /**
7619
+ * Stop the session's active turn (the mobile stop button). Pending queued
7620
+ * work is preserved and resumes in FIFO order once cancellation settles.
7621
+ */
7622
+ async function cancelSession(sessionId) {
7623
+ return await callUnary("session.cancel", { sessionId });
7624
+ }
7622
7625
  /** Fresh advisory model directory for one session (current + groups + failures). */
7623
7626
  async function models(sessionId) {
7624
7627
  return await callUnary("session.models", { sessionId });
@@ -14039,6 +14042,8 @@ function ChatView({ session, mux, onBack }) {
14039
14042
  const [mobileEnterToSend, setMobileEnterToSend] = (0, import_react.useState)(true);
14040
14043
  /** Whether the assistant is currently generating (turn/start..turn/end). */
14041
14044
  const [running, setRunning] = (0, import_react.useState)(false);
14045
+ /** Whether a stop request is in flight (guards the composer's stop button). */
14046
+ const [stopping, setStopping] = (0, import_react.useState)(false);
14042
14047
  /** Pending tool approvals awaiting user decision (#1025). */
14043
14048
  const [pendingApprovals, setPendingApprovals] = (0, import_react.useState)([]);
14044
14049
  /** Pending questions awaiting user answer (#1025). */
@@ -14245,6 +14250,22 @@ function ChatView({ session, mux, onBack }) {
14245
14250
  sending,
14246
14251
  session.sessionId
14247
14252
  ]);
14253
+ /**
14254
+ * Stop the active turn (desktop parity: the composer's primary button
14255
+ * becomes a stop button while running). The turn/end frame arriving over
14256
+ * mux flips the button back; a failed request surfaces through the chat
14257
+ * error line.
14258
+ */
14259
+ const stopTurn = (0, import_react.useCallback)(() => {
14260
+ if (stopping) return;
14261
+ setStopping(true);
14262
+ cancelSession(session.sessionId).then(() => {
14263
+ setStopping(false);
14264
+ }, (reason) => {
14265
+ setStopping(false);
14266
+ setError(errorText(reason));
14267
+ });
14268
+ }, [stopping, session.sessionId]);
14248
14269
  const modelLabel = currentModel?.model ?? "模型";
14249
14270
  const permissionLabel = permissions === void 0 ? void 0 : permissions.options.find((option) => option.value === permissions.currentValue)?.name ?? displayName(permissions.currentValue);
14250
14271
  const contextUsage = (0, import_react.useMemo)(() => {
@@ -14434,12 +14455,28 @@ function ChatView({ session, mux, onBack }) {
14434
14455
  }
14435
14456
  }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
14436
14457
  type: "button",
14437
- className: "chat-send",
14438
- disabled: sending || input.trim() === "",
14458
+ className: running ? "chat-send chat-send-stop" : "chat-send",
14459
+ ...running ? { "aria-label": stopping ? "停止中" : "停止" } : {},
14460
+ disabled: running ? stopping : sending || input.trim() === "",
14439
14461
  onClick: () => {
14440
- send();
14462
+ if (running) stopTurn();
14463
+ else send();
14441
14464
  },
14442
- children: sending ? "发送中…" : "发送"
14465
+ children: running ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", {
14466
+ viewBox: "0 0 16 16",
14467
+ width: "14",
14468
+ height: "14",
14469
+ "aria-hidden": "true",
14470
+ focusable: "false",
14471
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("rect", {
14472
+ x: "3",
14473
+ y: "3",
14474
+ width: "10",
14475
+ height: "10",
14476
+ rx: "3",
14477
+ fill: "currentColor"
14478
+ })
14479
+ }) : sending ? "发送中…" : "发送"
14443
14480
  })]
14444
14481
  }),
14445
14482
  sheet === "model" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ModelSheet, {
@@ -14490,6 +14527,11 @@ function ChatView({ session, mux, onBack }) {
14490
14527
  */
14491
14528
  const MessageRow = (0, import_react.memo)(function MessageRow({ message, showToolCalls, showSystemMessages }) {
14492
14529
  if (message.kind === "user" && message.sourceKind !== void 0 && message.sourceKind !== "user" && !showSystemMessages) return null;
14530
+ const hasReasoning = message.kind === "assistant" && message.reasoning !== void 0 && message.reasoning !== "";
14531
+ const hasTools = showToolCalls && message.kind === "assistant" && message.tools !== void 0 && message.tools.length > 0;
14532
+ const hasText = message.text !== "";
14533
+ const hasFailTag = message.failed === true;
14534
+ if (!hasReasoning && !hasTools && !hasText && !hasFailTag) return null;
14493
14535
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
14494
14536
  className: `chat-msg chat-msg-${message.kind}${message.pending === true ? " chat-msg-pending" : ""}${message.failed === true ? " chat-msg-failed" : ""}`,
14495
14537
  children: [
@@ -14648,7 +14690,7 @@ function MarkdownText({ text, pending }) {
14648
14690
  if (timerRef.current !== void 0) clearTimeout(timerRef.current);
14649
14691
  };
14650
14692
  }, []);
14651
- const long = text.length > LONG_TEXT_LIMIT;
14693
+ const long = !pending && text.length > 6e3;
14652
14694
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
14653
14695
  className: "chat-msg-text chat-md" + (long && !open ? " chat-md-collapsed" : ""),
14654
14696
  children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
@@ -14667,7 +14709,7 @@ function MarkdownText({ text, pending }) {
14667
14709
  /** Long assistant text collapses behind an explicit expand toggle. */
14668
14710
  function CollapsibleText({ text }) {
14669
14711
  const [open, setOpen] = (0, import_react.useState)(false);
14670
- if (text.length <= LONG_TEXT_LIMIT) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
14712
+ if (text.length <= 6e3) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
14671
14713
  className: "chat-msg-text",
14672
14714
  children: text
14673
14715
  });
@@ -14687,7 +14729,6 @@ function CollapsibleText({ text }) {
14687
14729
  ]
14688
14730
  });
14689
14731
  }
14690
- const LONG_TEXT_LIMIT = 1600;
14691
14732
  const LONG_TEXT_PREVIEW = 800;
14692
14733
  /** Latest non-empty line of a streaming reasoning buffer. */
14693
14734
  function lastLine(text) {
@@ -17270,6 +17311,16 @@ body {
17270
17311
  opacity: 0.5;
17271
17312
  }
17272
17313
 
17314
+ /* Running state: the primary button carries only the square stop icon, so
17315
+ it centers the glyph instead of sizing to text. */
17316
+ .chat-send-stop {
17317
+ display: inline-flex;
17318
+ align-items: center;
17319
+ justify-content: center;
17320
+ min-width: 44px;
17321
+ padding: 0 12px;
17322
+ }
17323
+
17273
17324
  .chat-load-older {
17274
17325
  align-self: center;
17275
17326
  margin: 4px 0;