@linxin666/dsh-remote-web-ui 0.1.16 → 0.1.18

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 (48) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +5 -2
  3. package/README.zh.md +2 -2
  4. package/assets/apple-touch-icon.png +0 -0
  5. package/lib/client.js +48 -58
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +76 -14
  8. package/lib/mobile.js +649 -45
  9. package/lib/mobile.js.map +1 -1
  10. package/lib/types/client/PluginSettingsCard.d.ts +11 -0
  11. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  12. package/lib/types/client/PluginSettingsCard.js +11 -5
  13. package/lib/types/mobile/markdown.d.ts +26 -0
  14. package/lib/types/mobile/markdown.d.ts.map +1 -0
  15. package/lib/types/mobile/markdown.js +272 -0
  16. package/lib/types/mobile/markdown.test.d.ts +2 -0
  17. package/lib/types/mobile/markdown.test.d.ts.map +1 -0
  18. package/lib/types/mobile/markdown.test.js +67 -0
  19. package/lib/types/mobile/messages.d.ts +14 -0
  20. package/lib/types/mobile/messages.d.ts.map +1 -1
  21. package/lib/types/mobile/messages.js +54 -2
  22. package/lib/types/mobile/messages.test.js +99 -0
  23. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  24. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  25. package/lib/types/mobile/mobile-styles.js +167 -0
  26. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  27. package/lib/types/mobile/views/ChatView.js +81 -5
  28. package/lib/types/mobile/views/ChatView.test.js +105 -0
  29. package/lib/types/mobile-api.d.ts.map +1 -1
  30. package/lib/types/mobile-api.js +19 -2
  31. package/lib/types/mobile-routes.d.ts.map +1 -1
  32. package/lib/types/mobile-routes.js +32 -0
  33. package/lib/types/update.d.ts +5 -3
  34. package/lib/types/update.d.ts.map +1 -1
  35. package/lib/types/update.js +22 -6
  36. package/package.json +2 -1
  37. package/src/client/PluginSettingsCard.tsx +50 -47
  38. package/src/client/settings-card.module.css +9 -0
  39. package/src/mobile/markdown.test.ts +77 -0
  40. package/src/mobile/markdown.ts +278 -0
  41. package/src/mobile/messages.test.ts +106 -0
  42. package/src/mobile/messages.ts +66 -2
  43. package/src/mobile/mobile-styles.ts +167 -0
  44. package/src/mobile/views/ChatView.test.tsx +121 -0
  45. package/src/mobile/views/ChatView.tsx +164 -6
  46. package/src/mobile-api.ts +19 -2
  47. package/src/mobile-routes.ts +33 -0
  48. package/src/update.ts +23 -6
@@ -461,3 +461,108 @@ describe('ChatView scrolling', () => {
461
461
  await waitFor(() => { expect(scrollWrites.length).toBe(writesBefore); });
462
462
  });
463
463
  });
464
+ describe('ChatView display toggles and context usage', () => {
465
+ /** jsdom in this setup ships a bare localStorage object; install a real fake. */
466
+ const makeStorage = () => {
467
+ const map = new Map();
468
+ return {
469
+ getItem: (key) => map.get(key) ?? null,
470
+ setItem: (key, value) => { map.set(key, value); },
471
+ removeItem: (key) => { map.delete(key); },
472
+ clear: () => { map.clear(); },
473
+ key: (index) => [...map.keys()][index] ?? null,
474
+ get length() { return map.size; },
475
+ };
476
+ };
477
+ /** A user/message whose source.kind is 'plugin' (injected system message). */
478
+ const systemEvents = () => [
479
+ makeEntry('user/message', {
480
+ id: 'u-plugin',
481
+ role: 'user',
482
+ content: [{ type: 'text', text: '系统注入消息' }],
483
+ source: { kind: 'plugin', name: 'react-extension' },
484
+ }, 0),
485
+ makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: '普通消息' }] }, 1),
486
+ ];
487
+ const toolEvents = () => [
488
+ makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: '改文件' }] }, 0),
489
+ makeEntry('assistant/message', {
490
+ turn: 0,
491
+ step: 0,
492
+ message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: '完成' }] },
493
+ }, 1),
494
+ makeEntry('tool/call', { turn: 0, step: 0, callId: 'c1', name: 'bash', arguments: '{}' }, 2),
495
+ ];
496
+ beforeEach(() => {
497
+ vi.stubGlobal('localStorage', makeStorage());
498
+ });
499
+ afterEach(() => {
500
+ vi.unstubAllGlobals();
501
+ });
502
+ it('hides injected user messages by default and reveals them via the display sheet', async () => {
503
+ loadHistoryMock.mockResolvedValue(historyPage(systemEvents()));
504
+ render(_jsx(ChatView, { session: session, onBack: () => { } }));
505
+ // The plugin-injected message is hidden by default; the real user message shows.
506
+ expect(await screen.findByText('普通消息')).toBeTruthy();
507
+ expect(screen.queryByText('系统注入消息')).toBeNull();
508
+ // Open the display sheet and flip the system-message switch on.
509
+ fireEvent.click(screen.getByRole('button', { name: /显示/ }));
510
+ const systemSwitch = await screen.findByRole('switch', { name: '系统提示词' });
511
+ expect(systemSwitch.getAttribute('aria-checked')).toBe('false');
512
+ fireEvent.click(systemSwitch);
513
+ expect(await screen.findByText('系统注入消息')).toBeTruthy();
514
+ // Flip it back off: the injected row disappears again.
515
+ const systemSwitchAfter = screen.getByRole('switch', { name: '系统提示词' });
516
+ expect(systemSwitchAfter.getAttribute('aria-checked')).toBe('true');
517
+ fireEvent.click(systemSwitchAfter);
518
+ expect(screen.queryByText('系统注入消息')).toBeNull();
519
+ });
520
+ it('hides the tool disclosure when the tool-call toggle is off', async () => {
521
+ loadHistoryMock.mockResolvedValue(historyPage(toolEvents()));
522
+ render(_jsx(ChatView, { session: session, onBack: () => { } }));
523
+ // Tool disclosure visible by default.
524
+ expect(await screen.findByRole('button', { name: /工具/ })).toBeTruthy();
525
+ fireEvent.click(screen.getByRole('button', { name: /显示/ }));
526
+ const toolSwitch = await screen.findByRole('switch', { name: '工具调用' });
527
+ expect(toolSwitch.getAttribute('aria-checked')).toBe('true');
528
+ fireEvent.click(toolSwitch);
529
+ // The disclosure is gone while reasoning/text remain.
530
+ expect(screen.queryByRole('button', { name: /工具/ })).toBeNull();
531
+ expect(screen.getByText('完成')).toBeTruthy();
532
+ });
533
+ it('renders the context usage chip from request/context plus assistant usage', async () => {
534
+ loadHistoryMock.mockResolvedValue(historyPage([
535
+ makeEntry('request/context', { provider: 'fx', model: 'fx-1', contextWindow: 100_000 }, 0),
536
+ makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: 'hi' }] }, 1),
537
+ makeEntry('assistant/message', {
538
+ turn: 0,
539
+ step: 0,
540
+ message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
541
+ usage: { inputTokens: 30_000, outputTokens: 1_000 },
542
+ }, 2),
543
+ ]));
544
+ render(_jsx(ChatView, { session: session, onBack: () => { } }));
545
+ expect(await screen.findByText('上下文 30%')).toBeTruthy();
546
+ });
547
+ it('adds the warn class when context usage is at or above 80%', async () => {
548
+ loadHistoryMock.mockResolvedValue(historyPage([
549
+ makeEntry('request/context', { provider: 'fx', model: 'fx-1', contextWindow: 100_000 }, 0),
550
+ makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: 'hi' }] }, 1),
551
+ makeEntry('assistant/message', {
552
+ turn: 0,
553
+ step: 0,
554
+ message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
555
+ usage: { inputTokens: 80_000, outputTokens: 0 },
556
+ }, 2),
557
+ ]));
558
+ render(_jsx(ChatView, { session: session, onBack: () => { } }));
559
+ const chip = await screen.findByText('上下文 80%');
560
+ expect(chip.className).toContain('chat-context-warn');
561
+ });
562
+ it('renders no context chip when there is no usage/context data', async () => {
563
+ loadHistoryMock.mockResolvedValue(historyPage(turnEvents()));
564
+ render(_jsx(ChatView, { session: session, onBack: () => { } }));
565
+ await screen.findByText('已完成修改');
566
+ expect(screen.queryByText(/上下文/)).toBeNull();
567
+ });
568
+ });
@@ -1 +1 @@
1
- {"version":3,"file":"mobile-api.d.ts","sourceRoot":"","sources":["../../src/mobile-api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAGH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAA;AAG9D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAgDlD,iCAAiC;AACjC,MAAM,WAAW,aAAa;IAC5B,uDAAuD;IACvD,OAAO,EAAE,cAAc,CAAA;IACvB,0DAA0D;IAC1D,QAAQ,EAAE,QAAQ,CAAA;IAClB,kEAAkE;IAClE,iBAAiB,EAAE,MAAM,OAAO,CAAA;IAChC,oFAAoF;IACpF,iBAAiB,CAAC,EAAE,MAAM,CAAA;CAC3B;AAED,8BAA8B;AAC9B,eAAO,MAAM,gBAAgB;;CAEnB,CAAA;AAOV;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,QAAQ,EAAE,CA4HnE"}
1
+ {"version":3,"file":"mobile-api.d.ts","sourceRoot":"","sources":["../../src/mobile-api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAGH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAA;AAG9D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAgDlD,iCAAiC;AACjC,MAAM,WAAW,aAAa;IAC5B,uDAAuD;IACvD,OAAO,EAAE,cAAc,CAAA;IACvB,0DAA0D;IAC1D,QAAQ,EAAE,QAAQ,CAAA;IAClB,kEAAkE;IAClE,iBAAiB,EAAE,MAAM,OAAO,CAAA;IAChC,oFAAoF;IACpF,iBAAiB,CAAC,EAAE,MAAM,CAAA;CAC3B;AAED,8BAA8B;AAC9B,eAAO,MAAM,gBAAgB;;CAEnB,CAAA;AAOV;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,QAAQ,EAAE,CA6InE"}
@@ -77,10 +77,23 @@ const MOBILE_API_METHOD_PREFIX = `${MOBILE_API_PREFIX}/`;
77
77
  export function makeMobileApiRoutes(deps) {
78
78
  const { service, apiProxy, mobileEnterToSend } = deps;
79
79
  const eventsHeartbeatMs = deps.eventsHeartbeatMs ?? DEFAULT_EVENTS_HEARTBEAT_MS;
80
+ /**
81
+ * Refresh the paired device's presence and report whether it is live.
82
+ * The mobile surface (unlike the desktop Web UI) has no `/api/pair/heartbeat`
83
+ * sender, so any activity on the mobile channel — a gated RPC, or the live
84
+ * SSE stream staying open — must count as presence. Without this, an
85
+ * idle-but-connected phone ages past `offlineAfterMs` and the desktop panel
86
+ * wrongly reports it as disconnected.
87
+ */
88
+ const touchDeviceFor = (req) => {
89
+ const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
90
+ if (deviceId === undefined)
91
+ return false;
92
+ return service.touchDevice(deviceId);
93
+ };
80
94
  /** The phone gate: a live paired-device cookie, or nothing else proceeds. */
81
95
  const gateOk = (req) => {
82
- const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
83
- return deviceId !== undefined && service.hasDevice(deviceId);
96
+ return touchDeviceFor(req);
84
97
  };
85
98
  const writeJson = (res, status, body) => {
86
99
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
@@ -164,6 +177,10 @@ export function makeMobileApiRoutes(deps) {
164
177
  const heartbeat = setInterval(() => {
165
178
  if (closed)
166
179
  return;
180
+ // An open SSE stream proves the phone is still live even while the agent
181
+ // idles (no RPC traffic), so refresh presence alongside the transport
182
+ // keepalive — otherwise an idle phone drifts to "disconnected".
183
+ touchDeviceFor(req);
167
184
  try {
168
185
  res.write(': ping\n\n');
169
186
  }
@@ -1 +1 @@
1
- {"version":3,"file":"mobile-routes.d.ts","sourceRoot":"","sources":["../../src/mobile-routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAMH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAqC/D;;;GAGG;AACH,wBAAgB,gBAAgB,IAAI,QAAQ,EAAE,CAqB7C"}
1
+ {"version":3,"file":"mobile-routes.d.ts","sourceRoot":"","sources":["../../src/mobile-routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAMH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAwD/D;;;GAGG;AACH,wBAAgB,gBAAgB,IAAI,QAAQ,EAAE,CAmC7C"}
@@ -14,6 +14,10 @@ import { fileURLToPath } from 'node:url';
14
14
  function mobileBundlePath() {
15
15
  return fileURLToPath(new URL('../lib/mobile.js', import.meta.url));
16
16
  }
17
+ /** The apple-touch-icon shipped with the package (iOS home-screen icon, 180x180 PNG). */
18
+ function appleTouchIconPath() {
19
+ return fileURLToPath(new URL('../assets/apple-touch-icon.png', import.meta.url));
20
+ }
17
21
  /** The mobile page shell: minimal, offline-safe, no external assets. */
18
22
  function pageHtml(bundleUrl) {
19
23
  return [
@@ -24,6 +28,10 @@ function pageHtml(bundleUrl) {
24
28
  '<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">',
25
29
  '<meta name="theme-color" content="#f3f5f9">',
26
30
  '<meta name="referrer" content="no-referrer">',
31
+ // iOS "Add to Home Screen" uses apple-touch-icon for the tile icon;
32
+ // the PNG ships with the package (opaque background — iOS composites
33
+ // transparency onto black, so the icon avoids transparent regions).
34
+ '<link rel="apple-touch-icon" href="/m/apple-touch-icon.png">',
27
35
  '<title>移动端远程控制</title>',
28
36
  '</head>',
29
37
  '<body>',
@@ -42,6 +50,15 @@ function writeStatic(res, status, type, body) {
42
50
  });
43
51
  res.end(body);
44
52
  }
53
+ /** Send the PNG icon as a binary body (unlike text assets, the PNG must not be decoded to utf-8). */
54
+ function writePng(res, status, body) {
55
+ res.writeHead(status, {
56
+ 'content-type': 'image/png',
57
+ 'cache-control': 'public, max-age=31536000, immutable',
58
+ 'referrer-policy': 'no-referrer',
59
+ });
60
+ res.end(body);
61
+ }
45
62
  /**
46
63
  * Build the mobile page routes.
47
64
  * @returns the two exact routes to register on webServer.
@@ -64,8 +81,23 @@ export function makeMobileRoutes() {
64
81
  writeStatic(res, 500, 'text/plain', 'failed to read the mobile bundle');
65
82
  }
66
83
  };
84
+ const handleIcon = async (_req, res) => {
85
+ const path = appleTouchIconPath();
86
+ if (!existsSync(path)) {
87
+ writeStatic(res, 404, 'text/plain', 'apple-touch-icon not found');
88
+ return;
89
+ }
90
+ try {
91
+ const body = await readFile(path);
92
+ writePng(res, 200, body);
93
+ }
94
+ catch {
95
+ writeStatic(res, 500, 'text/plain', 'failed to read the apple-touch-icon');
96
+ }
97
+ };
67
98
  return [
68
99
  { kind: 'exact', path: '/m', handler: handlePage },
69
100
  { kind: 'exact', path: '/m/mobile.js', handler: handleBundle },
101
+ { kind: 'exact', path: '/m/apple-touch-icon.png', handler: handleIcon },
70
102
  ];
71
103
  }
@@ -206,9 +206,11 @@ export interface UpdateRunVerifiedDeps {
206
206
  }
207
207
  /**
208
208
  * Run the update, then verify the installed versions actually moved. pnpm
209
- * exits 0 even when it silently kept the installed versions — the pnpm 11
210
- * `minimumReleaseAge` gate refuses same-day releases by default — so a green
211
- * exit alone would report a misleading "update complete". Re-read the
209
+ * exits 0 even when it silently kept the installed versions — runUpdate
210
+ * overrides pnpm 11's `minimumReleaseAge` gate (default 24 h) with
211
+ * `--config.minimumReleaseAge=0`, but an older pnpm without the override or
212
+ * another silent no-op can still keep versions in place — so a green exit
213
+ * alone must not report a misleading "update complete". Re-read the
212
214
  * installed versions afterwards and surface a `stale` failure (with the
213
215
  * captured pnpm output) when nothing moved, so the panel can tell the user
214
216
  * how to unblock the gate instead of claiming success.
@@ -1 +1 @@
1
- {"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../src/update.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../src/update.ts"],"names":[],"mappings":"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"}
@@ -302,6 +302,16 @@ const OUTPUT_CAP = 16 * 1024;
302
302
  * detects this message instead of the spawn error event.
303
303
  */
304
304
  const WIN_CMD_MISSING_RE = /not recognized as an internal or external command/i;
305
+ /**
306
+ * Bypass for pnpm 11's supply-chain gate: `minimumReleaseAge` (default 24 h)
307
+ * silently skips same-day releases — `pnpm update --latest` then exits 0
308
+ * without moving anything and the post-run verification would misreport a
309
+ * stale no-op. The update here is explicitly user-initiated and the panel
310
+ * shows exactly which versions are being installed, so the gate only adds a
311
+ * confusing silent no-op; override it per-invocation instead of asking every
312
+ * user to edit the profile's pnpm-workspace.yaml.
313
+ */
314
+ const MIN_RELEASE_AGE_OVERRIDE = '--config.minimumReleaseAge=0';
305
315
  /**
306
316
  * Run the update inside the profile directory. Tries pnpm first, falls back
307
317
  * to corepack and then npx when the previous command is missing (ENOENT);
@@ -328,10 +338,14 @@ export function runUpdate(deps) {
328
338
  // "0.1.12" without a range), and plain `pnpm update` treats an exact spec
329
339
  // as pinned — it prints "Already up to date" and exits 0 without moving
330
340
  // the installed version, so the panel would report a false success.
341
+ // `MIN_RELEASE_AGE_OVERRIDE` rides along every candidate: without it the
342
+ // pnpm 11 minimumReleaseAge gate (default 24 h) silently keeps same-day
343
+ // releases in place, which the post-run verification would then have to
344
+ // surface as `stale` even though the update is legitimate.
331
345
  const candidates = [
332
- { command: 'pnpm', args: ['update', '--latest', ...packages] },
333
- { command: 'corepack', args: ['pnpm', 'update', '--latest', ...packages] },
334
- { command: 'npx', args: ['--yes', 'pnpm', 'update', '--latest', ...packages] },
346
+ { command: 'pnpm', args: ['update', '--latest', MIN_RELEASE_AGE_OVERRIDE, ...packages] },
347
+ { command: 'corepack', args: ['pnpm', 'update', '--latest', MIN_RELEASE_AGE_OVERRIDE, ...packages] },
348
+ { command: 'npx', args: ['--yes', 'pnpm', 'update', '--latest', MIN_RELEASE_AGE_OVERRIDE, ...packages] },
335
349
  ];
336
350
  // `output` accumulates across candidates for UI display; `currentOutput`
337
351
  // is reset per candidate and carries only that candidate's own diagnostics
@@ -444,9 +458,11 @@ export function runUpdate(deps) {
444
458
  }
445
459
  /**
446
460
  * Run the update, then verify the installed versions actually moved. pnpm
447
- * exits 0 even when it silently kept the installed versions — the pnpm 11
448
- * `minimumReleaseAge` gate refuses same-day releases by default — so a green
449
- * exit alone would report a misleading "update complete". Re-read the
461
+ * exits 0 even when it silently kept the installed versions — runUpdate
462
+ * overrides pnpm 11's `minimumReleaseAge` gate (default 24 h) with
463
+ * `--config.minimumReleaseAge=0`, but an older pnpm without the override or
464
+ * another silent no-op can still keep versions in place — so a green exit
465
+ * alone must not report a misleading "update complete". Re-read the
450
466
  * installed versions afterwards and surface a `stale` failure (with the
451
467
  * captured pnpm output) when nothing moved, so the panel can tell the user
452
468
  * how to unblock the gate instead of claiming success.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-remote-web-ui",
3
3
  "description": "Mobile remote control for the dsh web GUI: scan-to-pair QR entry beside the settings button, one-time pairing tokens, live device status, and revocable mobile sessions",
4
- "version": "0.1.16",
4
+ "version": "0.1.18",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -30,6 +30,7 @@
30
30
  "lib/**/*.d.ts",
31
31
  "lib/**/*.d.ts.map",
32
32
  "src",
33
+ "assets",
33
34
  "cordis.patch.yml"
34
35
  ],
35
36
  "license": "Apache-2.0",
@@ -45,6 +45,17 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
45
45
  onSave: () => void
46
46
  /** Drop every staged edit. */
47
47
  onDiscard: () => void
48
+ /**
49
+ * Render the controls expanded on arrival (still collapsible). Defaults to
50
+ * true: promoted first-level sections show their settings immediately.
51
+ */
52
+ defaultOpen?: boolean
53
+ /**
54
+ * Render without the collapse affordance: a static header and an always
55
+ * visible body. Used by first-level settings sections whose nav entry
56
+ * already provides the selection.
57
+ */
58
+ alwaysOpen?: boolean
48
59
  /** The plugin's controls. */
49
60
  children: ReactNode
50
61
  }
@@ -55,57 +66,27 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
55
66
  * @returns the card, or nothing while the namespace is still loading.
56
67
  */
57
68
  export function PluginSettingsCard<TKey extends string = string>(props: PluginSettingsCardProps<TKey>) {
58
- const [open, setOpen] = useState(false)
59
- const { state } = props
69
+ const [open, setOpen] = useState(props.defaultOpen ?? true)
70
+ const { state, alwaysOpen } = props
60
71
  if (!state.available) return null
61
72
  const title = props.t(props.titleKey)
62
73
  const description = props.t(props.descriptionKey)
63
74
  const blocked = !state.dirty || state.invalid || state.saving
64
- const cardClass = open ? `${css.cardOpen} ${css.card}` : css.card
65
- // The namespace exists but the Host does not serve it to this client (the
66
- // official settings allowlist omits third-party namespaces): show a card
67
- // that explains the gap instead of vanishing, so a missing card never
68
- // reads as a missing plugin.
69
- if (!state.exposed) {
70
- return (
71
- <li className={cardClass}>
72
- <button
73
- type="button"
74
- className={css.header}
75
- aria-expanded={open}
76
- aria-label={`${props.t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`}
77
- onClick={() => { setOpen(!open) }}
78
- >
79
- <span className={css.headText}>
80
- <span className={css.name} title={title}>{title}</span>
81
- <span className={css.description} title={description}>{description}</span>
82
- </span>
83
- <svg
84
- width="14"
85
- height="14"
86
- viewBox="0 0 14 14"
87
- fill="none"
88
- xmlns="http://www.w3.org/2000/svg"
89
- className={open ? `${css.chevron} ${css.chevronOpen}` : css.chevron}
90
- >
91
- <path
92
- d="M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z"
93
- fill="currentColor"
94
- />
95
- </svg>
96
- </button>
97
- {open
98
- ? (
99
- <div className={css.body}>
100
- <p className={css.notExposed} role="status">{props.t('settings.notExposed')}</p>
101
- </div>
102
- )
103
- : null}
104
- </li>
75
+ const expanded = alwaysOpen === true || open
76
+ const cardClass = expanded ? `${css.cardOpen} ${css.card}` : css.card
77
+ // With alwaysOpen the nav entry already provides the selection, so the
78
+ // header is a static title row instead of a disclosure button.
79
+ const header = alwaysOpen === true
80
+ ? (
81
+ <div className={css.headerStatic}>
82
+ <span className={css.headText}>
83
+ <span className={css.name} title={title}>{title}</span>
84
+ <span className={css.description} title={description}>{description}</span>
85
+ </span>
86
+ {state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
87
+ </div>
105
88
  )
106
- }
107
- return (
108
- <li className={cardClass}>
89
+ : (
109
90
  <button
110
91
  type="button"
111
92
  className={css.header}
@@ -132,7 +113,29 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
132
113
  />
133
114
  </svg>
134
115
  </button>
135
- {open
116
+ )
117
+ // The namespace exists but the Host does not serve it to this client (the
118
+ // official settings allowlist omits third-party namespaces): show a card
119
+ // that explains the gap instead of vanishing, so a missing card never
120
+ // reads as a missing plugin.
121
+ if (!state.exposed) {
122
+ return (
123
+ <li className={cardClass}>
124
+ {header}
125
+ {expanded
126
+ ? (
127
+ <div className={css.body}>
128
+ <p className={css.notExposed} role="status">{props.t('settings.notExposed')}</p>
129
+ </div>
130
+ )
131
+ : null}
132
+ </li>
133
+ )
134
+ }
135
+ return (
136
+ <li className={cardClass}>
137
+ {header}
138
+ {expanded
136
139
  ? (
137
140
  <div className={css.body}>
138
141
  {!state.writable ? <p className={css.readOnly} role="status">{props.t('settings.readOnly')}</p> : null}
@@ -42,6 +42,15 @@
42
42
  outline-offset: -2px;
43
43
  }
44
44
 
45
+ .headerStatic {
46
+ width: 100%;
47
+ border-radius: 12px;
48
+ align-items: center;
49
+ gap: 12px;
50
+ padding: 14px 16px;
51
+ display: flex;
52
+ }
53
+
45
54
  .headText {
46
55
  flex-direction: column;
47
56
  flex: 1;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Mobile markdown renderer tests: GFM subset coverage plus the security
3
+ * contract — raw HTML escapes, dangerous link/image protocols are
4
+ * stripped, and code fences never parse their content.
5
+ */
6
+ import { describe, expect, it } from 'vitest'
7
+ import { escapeHtml, renderInline, renderMarkdown, safeUrl } from './markdown.ts'
8
+
9
+ describe('safeUrl', () => {
10
+ it('allows http/https/mailto/fragments and scheme-less targets', () => {
11
+ expect(safeUrl('https://example.com/a')).toBe('https://example.com/a')
12
+ expect(safeUrl('http://example.com')).toBe('http://example.com')
13
+ expect(safeUrl('mailto:a@b.c')).toBe('mailto:a@b.c')
14
+ expect(safeUrl('#top')).toBe('#top')
15
+ expect(safeUrl('./rel/path')).toBe('./rel/path')
16
+ })
17
+
18
+ it('rejects javascript:/data:/vbscript: and blanks', () => {
19
+ expect(safeUrl('javascript:alert(1)')).toBeNull()
20
+ expect(safeUrl('data:text/html,x')).toBeNull()
21
+ expect(safeUrl('vbscript:x')).toBeNull()
22
+ expect(safeUrl('')).toBeNull()
23
+ expect(safeUrl(' ')).toBeNull()
24
+ })
25
+ })
26
+
27
+ describe('renderInline', () => {
28
+ it('renders code, bold, italic, strikethrough and links', () => {
29
+ expect(renderInline('`a<b`')).toBe('<code>a&lt;b</code>')
30
+ expect(renderInline('**b**')).toBe('<strong>b</strong>')
31
+ expect(renderInline('*i*')).toBe('<em>i</em>')
32
+ expect(renderInline('~~d~~')).toBe('<del>d</del>')
33
+ expect(renderInline('[x](https://e.com)')).toBe('<a href="https://e.com" target="_blank" rel="noopener noreferrer">x</a>')
34
+ })
35
+
36
+ it('strips unsafe link targets and renders the label as plain text', () => {
37
+ expect(renderInline('[x](javascript:alert(1))')).toBe('x')
38
+ expect(renderInline('![a](data:text/html,x)')).toBe('a')
39
+ })
40
+
41
+ it('escapes raw HTML instead of passing it through', () => {
42
+ expect(renderInline('<script>alert(1)</script>')).toBe('&lt;script&gt;alert(1)&lt;/script&gt;')
43
+ })
44
+ })
45
+
46
+ describe('renderMarkdown', () => {
47
+ it('renders headings, hr, paragraphs', () => {
48
+ const html = renderMarkdown('# T\n\npara\n\n---')
49
+ expect(html).toContain('<h1>T</h1>')
50
+ expect(html).toContain('<p>para</p>')
51
+ expect(html).toContain('<hr />')
52
+ })
53
+
54
+ it('renders fenced code without parsing its content', () => {
55
+ const html = renderMarkdown('```ts\nconst a = **bold** <x>\n```')
56
+ expect(html).toBe('<pre class="language-ts"><code>const a = **bold** &lt;x&gt;</code></pre>')
57
+ })
58
+
59
+ it('renders lists, blockquotes and tables', () => {
60
+ const html = renderMarkdown('- a\n- b\n\n1. x\n2. y\n\n> quote\n\n| h1 | h2 |\n| --- | --- |\n| c1 | c2 |')
61
+ expect(html).toContain('<ul><li>a</li><li>b</li></ul>')
62
+ expect(html).toContain('<ol><li>x</li><li>y</li></ol>')
63
+ expect(html).toContain('<blockquote><p>quote</p></blockquote>')
64
+ expect(html).toContain('<thead><tr><th>h1</th><th>h2</th></tr></thead>')
65
+ expect(html).toContain('<td>c1</td><td>c2</td>')
66
+ })
67
+
68
+ it('normalizes CRLF and keeps user-visible text intact', () => {
69
+ expect(renderMarkdown('a\r\nb')).toBe('<p>a\nb</p>')
70
+ })
71
+ })
72
+
73
+ describe('escapeHtml', () => {
74
+ it('escapes all five HTML-significant characters', () => {
75
+ expect(escapeHtml('<&>"\'')).toBe('&lt;&amp;&gt;&quot;&#39;')
76
+ })
77
+ })