dsh-mobi-web 0.1.0 → 0.1.1

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.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  A phone-first web client for **DeepSeek Harness**, served by a sidecar gateway that runs *inside* the harness process.
6
6
 
7
- > **Not `dsh-mobile-gateway`.** There is an unrelated npm package by that name ("scan a QR code + settings page + live sync"). This one is different: a **32 KB page** instead of a proxied UI, **approvals that stay on the phone when the phone started the turn**, and Mermaid rendered on the device. Package and repo are both `dsh-mobi-web`; the local checkout directory is still named `dsh-mobi-web-plugin`.
7
+ > **Not `dsh-mobile-gateway`.** There is an unrelated npm package by that name ("scan a QR code + settings page + live sync"). This one is different: a **32 KB page** instead of a proxied UI, **approvals that stay on the phone when the phone started the turn**, and Mermaid rendered on the device. Package, repo and checkout directory are all `dsh-mobi-web`.
8
8
 
9
9
  ```
10
10
  phone browser ──HTTPS──▶ Cloudflare tunnel / Tailscale ──▶ 127.0.0.1:3083 (this plugin) ──▶ harness host services
@@ -60,6 +60,7 @@ The official web UI ships ~4.6 MB of JavaScript. Over a relayed mobile link (0.4
60
60
  **Approve**
61
61
  - Approval requests arrive as cards over SSE
62
62
  - **Origin affinity**: if the *phone* started the turn, the approval stays on the phone. It waits up to 15 minutes, then **cancels** — it never falls back to a desktop nobody is sitting at. Desktop-origin turns keep the old behaviour (20 s grace, then hand back).
63
+ - **Desktop-owned approvals are visible, not silent**: when the desktop is the one waiting, the phone shows a read-only “PC is waiting for approval · <tool>” banner (and a marker on the session row), so a phone that is not the owner no longer looks stuck. If the gateway's grace window hands a phone card back to the desktop, the stale card is dismissed and the banner replaces it.
63
64
 
64
65
  **Stay up**
65
66
  - SSE with exponential-backoff reconnect (2 s → 30 s) and a 6 s poll as fallback
@@ -73,9 +74,11 @@ The plugin is a DSH **bundle**: it ships its own `cordis.patch.yml`, so no profi
73
74
 
74
75
  ```bash
75
76
  dsh plugin --profile web add dsh-mobi-web # from npm
76
- dsh plugin --profile web add link:/path/to/dsh-mobi-web-plugin # from a checkout
77
+ dsh plugin --profile web add link:/path/to/dsh-mobi-web # from a checkout
77
78
  ```
78
79
 
80
+ > **`link:` records an absolute path.** It is meant for a throwaway *development* profile, not for the profile your phone talks to: if you move the checkout, the link dangles, the bundle silently stops mounting, and `/m/` disappears with no error. Re-run the `add` after a move. For an unpublished build on your daily profile, `npm pack` and install the tarball — that is a copy, not a link.
81
+
79
82
  **The desktop profile** — `desktop` is reserved for the Electron-owned profile, and the *npm* CLI refuses to manage it. Install through the app's **plugin manager**, or with the Desktop-installed command (`dsh` inside the app bundle):
80
83
 
81
84
  ```bash
@@ -156,11 +159,11 @@ lib/index.js the gateway plugin (single file on purpose)
156
159
  lib/page.html the mobile page, served from disk on every request
157
160
  assets/ vendored mermaid (js + gz + br) and the app icon
158
161
  cordis.patch.yml bundle patch — the install declaration
159
- test/ 27 tests, fake host services, no network
162
+ test/ 34 tests, fake host services, no network
160
163
  ```
161
164
 
162
165
  ## Tests
163
166
 
164
167
  ```bash
165
- node --test test/*.test.mjs # 27 tests, no network, fake host services
168
+ node --test test/*.test.mjs # 34 tests, no network, fake host services
166
169
  ```
@@ -0,0 +1,45 @@
1
+ # v0.1.1 — desktop approval status
2
+
3
+ A small release about the one case where the phone used to look broken: the
4
+ desktop was waiting on an approval, the phone showed a running turn, and
5
+ nothing said why.
6
+
7
+ ## What changed
8
+
9
+ - **A read-only “PC is waiting for approval” banner on the phone.** When the
10
+ gateway delegates an approval to the desktop — or when a desktop-origin
11
+ approval arrives while the phone is not watching — the session view now
12
+ shows the tool name and a plain-language hint that the desktop has to
13
+ continue. It updates over SSE and is re-sent with the session payload, so
14
+ it survives a reconnect, a page refresh and a lock screen.
15
+ - **Session-list marker.** A session with a pending desktop approval carries a
16
+ `等待电脑审批` / waiting marker on its row, so the list no longer shows a
17
+ running session with no explanation.
18
+ - **The stale phone card is dismissed.** The desktop-origin grace window can
19
+ hand a request back after the phone has already rendered a card; that card
20
+ used to stay tappable and fail with “already handled”. It is now replaced by
21
+ the read-only banner.
22
+ - **No takeover.** The phone still does not answer desktop-owned approvals.
23
+ This release only observes them, which keeps the existing origin-affinity
24
+ decision intact.
25
+
26
+ ## Implementation notes
27
+
28
+ - The gateway listens to the durable `approval/asked` / `approval/decided`
29
+ session events and keeps a per-session pending map. The read path also
30
+ derives the same state from the session surface, so a gateway restart or a
31
+ reconnect does not lose the status.
32
+ - No new dependencies, routes, config keys or permissions. The mobile API adds
33
+ two response fields: `desktopApprovals` on `GET /m/api/session` and
34
+ `waiting` on each `GET /m/api/sessions` item.
35
+ - 34 tests, including seven for the pure surface scanner.
36
+
37
+ ## Upgrade
38
+
39
+ ```bash
40
+ dsh plugin --profile desktop add dsh-mobi-web@0.1.1
41
+ # restart the app
42
+ ```
43
+
44
+ The session cookie and token do not change; the phone does not need to log in
45
+ again.
package/lib/index.js CHANGED
@@ -849,6 +849,33 @@ export function resolveToken(configured) {
849
849
  return { token: minted, source: 'minted', file, saved }
850
850
  }
851
851
 
852
+ /**
853
+ * Pending approvals from a session surface that the gateway does not own.
854
+ * `approval/asked` without a matching `approval/decided`; owned asks are
855
+ * filtered because those already render as an actionable card. Pure so the
856
+ * read path stays testable without a cordis host.
857
+ */
858
+ export function pendingApprovalsFromEvents(events, isOwned = () => false) {
859
+ const pending = new Map()
860
+ for (const event of events ?? []) {
861
+ const id = String(event?.data?.id ?? '')
862
+ if (id === '') continue
863
+ if (event?.type === 'approval/asked') {
864
+ if (!isOwned(id) && !pending.has(id)) {
865
+ pending.set(id, {
866
+ id,
867
+ toolName: event.data?.toolName ?? 'tool',
868
+ reason: event.data?.reason,
869
+ at: typeof event.time === 'number' ? event.time : undefined,
870
+ })
871
+ }
872
+ } else if (event?.type === 'approval/decided') {
873
+ pending.delete(id)
874
+ }
875
+ }
876
+ return [...pending.values()]
877
+ }
878
+
852
879
  export function apply(ctx, rawConfig = {}) {
853
880
  const config = { ...DEFAULTS, ...rawConfig }
854
881
  const resolved = config.token === false
@@ -953,12 +980,36 @@ export function apply(ctx, rawConfig = {}) {
953
980
  // L1: push committed session events to the phone over SSE instead of polling.
954
981
  const streams = new Map()
955
982
  const runningSessions = new Set()
983
+ // Approvals the gateway owns (the phone may answer) and approvals the host has
984
+ // asked for that it does not own (the desktop is answering). The second map is
985
+ // what lets the phone show "PC is waiting" instead of a silent stuck turn.
986
+ const approvals = new Map()
987
+ const pendingApprovals = new Map()
988
+ const seenSessions = new Map()
989
+ const phoneTurns = new Set()
956
990
  const pushEvent = (sessionId, payload) => {
957
991
  const set = streams.get(sessionId)
958
992
  if (set === undefined || set.size === 0) return
959
993
  const line = `data: ${JSON.stringify(payload)}\n\n`
960
994
  for (const res of set) { try { res.write(line) } catch {} }
961
995
  }
996
+ const desktopApprovalsFor = (sessionId) => [...pendingApprovals.values()]
997
+ .filter((entry) => entry.sessionId === sessionId && !approvals.has(entry.id))
998
+ const approvalStatusPayload = (sessionId) => {
999
+ const list = desktopApprovalsFor(sessionId)
1000
+ const first = list[0]
1001
+ return {
1002
+ kind: 'approval-status',
1003
+ count: list.length,
1004
+ toolName: first?.toolName,
1005
+ reason: first?.reason,
1006
+ at: first?.at,
1007
+ }
1008
+ }
1009
+ const pushApprovalStatus = (sessionId) => {
1010
+ if (sessionId === undefined) return
1011
+ pushEvent(sessionId, approvalStatusPayload(sessionId))
1012
+ }
962
1013
  const openStream = (req, res, sessionId) => {
963
1014
  if (typeof sessionId !== 'string' || sessionId === '') {
964
1015
  res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' }).end('session required')
@@ -975,6 +1026,7 @@ export function apply(ctx, rawConfig = {}) {
975
1026
  if (set === undefined) { set = new Set(); streams.set(sessionId, set) }
976
1027
  set.add(res)
977
1028
  res.write(`data: ${JSON.stringify({ kind: 'state', running: runningSessions.has(sessionId) })}\n\n`)
1029
+ res.write(`data: ${JSON.stringify(approvalStatusPayload(sessionId))}\n\n`)
978
1030
  const ping = setInterval(() => { try { res.write(': ping\n\n') } catch {} }, 15000)
979
1031
  const close = () => {
980
1032
  clearInterval(ping)
@@ -1006,6 +1058,34 @@ export function apply(ctx, rawConfig = {}) {
1006
1058
  try {
1007
1059
  ctx.on('session/event', (session, event) => {
1008
1060
  const sessionId = session?.id
1061
+ if (sessionId !== undefined) {
1062
+ // Track every ask, including the ones the gateway deliberately delegates
1063
+ // to the desktop UI. `desktopApprovalsFor()` filters out the ones the
1064
+ // gateway owns, so the phone shows a read-only status only when the
1065
+ // desktop is the one being waited on.
1066
+ if (event?.type === 'approval/asked') {
1067
+ const id = String(event.data?.id ?? '')
1068
+ if (id !== '') {
1069
+ pendingApprovals.set(id, {
1070
+ id,
1071
+ sessionId,
1072
+ toolName: event.data?.toolName ?? 'tool',
1073
+ reason: event.data?.reason,
1074
+ callId: event.data?.callId,
1075
+ at: typeof event.time === 'number' ? event.time : Date.now(),
1076
+ })
1077
+ pushApprovalStatus(sessionId)
1078
+ }
1079
+ } else if (event?.type === 'approval/decided') {
1080
+ pendingApprovals.delete(String(event.data?.id ?? ''))
1081
+ pushApprovalStatus(sessionId)
1082
+ } else if (event?.type === 'turn/end') {
1083
+ for (const [pendingId, pending] of pendingApprovals) {
1084
+ if (pending.sessionId === sessionId) pendingApprovals.delete(pendingId)
1085
+ }
1086
+ pushApprovalStatus(sessionId)
1087
+ }
1088
+ }
1009
1089
  if (sessionId === undefined || !streams.has(sessionId)) {
1010
1090
  if (event?.type === 'turn/start') runningSessions.add(sessionId)
1011
1091
  if (event?.type === 'turn/end') runningSessions.delete(sessionId)
@@ -1027,9 +1107,6 @@ export function apply(ctx, rawConfig = {}) {
1027
1107
  say('warn', `session event stream unavailable: ${error.message}`)
1028
1108
  }
1029
1109
 
1030
- const approvals = new Map()
1031
- const seenSessions = new Map()
1032
- const phoneTurns = new Set()
1033
1110
  const approvalStats = { events: 0, taken: 0, delegated: 0, cancelled: 0, errors: 0, archived: 0, lastSession: null, lastSeenAgeMs: null, lastOrigin: null, lastError: null }
1034
1111
  const APPROVAL_WINDOW_MS = 30_000
1035
1112
  const APPROVAL_STALE_MS = 10_000
@@ -1056,9 +1133,18 @@ export function apply(ctx, rawConfig = {}) {
1056
1133
  // turns keep the old hand-back behaviour.
1057
1134
  const fromPhone = sessionId !== undefined && phoneTurns.has(sessionId)
1058
1135
  approvalStats.lastOrigin = fromPhone ? 'phone' : 'desktop'
1059
- if (!fromPhone && Date.now() - lastSeen > APPROVAL_WINDOW_MS) { approvalStats.delegated += 1; return next() }
1060
-
1061
1136
  const id = String(request?.id ?? request?.callId ?? crypto.randomUUID())
1137
+ if (!pendingApprovals.has(id)) {
1138
+ pendingApprovals.set(id, {
1139
+ id, sessionId, toolName: request?.toolName ?? 'tool', reason: request?.reason,
1140
+ callId: request?.callId, at: Date.now(),
1141
+ })
1142
+ }
1143
+ if (!fromPhone && Date.now() - lastSeen > APPROVAL_WINDOW_MS) {
1144
+ approvalStats.delegated += 1
1145
+ pushApprovalStatus(sessionId)
1146
+ return next()
1147
+ }
1062
1148
  let settle
1063
1149
  const decision = new Promise((resolve) => { settle = resolve })
1064
1150
  approvals.set(id, {
@@ -1092,6 +1178,8 @@ export function apply(ctx, rawConfig = {}) {
1092
1178
  return outcome
1093
1179
  } finally {
1094
1180
  approvals.delete(id)
1181
+ pendingApprovals.delete(id)
1182
+ pushApprovalStatus(sessionId)
1095
1183
  }
1096
1184
  }
1097
1185
 
@@ -1103,10 +1191,23 @@ export function apply(ctx, rawConfig = {}) {
1103
1191
  }
1104
1192
  approvalStats.lastHeldMs = Date.now() - startedAt
1105
1193
  try {
1106
- if (!settled || outcome === null) { approvalStats.delegated += 1; return next() }
1194
+ if (!settled || outcome === null) {
1195
+ approvalStats.delegated += 1
1196
+ // The card the phone was showing is now stale: the desktop owns the
1197
+ // request. Drop our ownership BEFORE publishing the status, otherwise
1198
+ // the status still counts this id as phone-owned and reports zero.
1199
+ approvals.delete(id)
1200
+ pushEvent(sessionId, { kind: 'approval-done', id })
1201
+ pushApprovalStatus(sessionId)
1202
+ return next()
1203
+ }
1107
1204
  return outcome
1108
1205
  } finally {
1109
1206
  approvals.delete(id)
1207
+ if (settled) {
1208
+ pendingApprovals.delete(id)
1209
+ pushApprovalStatus(sessionId)
1210
+ }
1110
1211
  }
1111
1212
  }, true)
1112
1213
  } catch (error) {
@@ -1156,6 +1257,7 @@ export function apply(ctx, rawConfig = {}) {
1156
1257
  cwd: summary?.cwd ?? record.header.cwd,
1157
1258
  updatedAt: summary?.updatedAt ?? record.header.createdAt,
1158
1259
  running: summary?.running === true,
1260
+ waiting: desktopApprovalsFor(id).length > 0,
1159
1261
  live: summary?.agentAvailable ?? record.live === true,
1160
1262
  blank: summary?.blank === true,
1161
1263
  })
@@ -1298,6 +1400,8 @@ export function apply(ctx, rawConfig = {}) {
1298
1400
  const outcome = decision === 'allow' ? 'allowed-once' : 'rejected'
1299
1401
  entry.settle(outcome)
1300
1402
  approvals.delete(id)
1403
+ pendingApprovals.delete(id)
1404
+ pushApprovalStatus(entry.sessionId)
1301
1405
  pushEvent(entry.sessionId, { kind: 'approval-done', id })
1302
1406
  return { ok: true, outcome }
1303
1407
  },
@@ -1332,6 +1436,21 @@ export function apply(ctx, rawConfig = {}) {
1332
1436
  const pending = [...approvals.values()]
1333
1437
  .filter((entry) => entry.sessionId === id)
1334
1438
  .map((entry) => ({ id: entry.id, toolName: entry.toolName, reason: entry.reason, at: entry.at }))
1439
+ // Approvals that belong to the desktop (or were delegated to it). The live
1440
+ // map is the fast path; the surface scan covers a gateway restart and a
1441
+ // reconnect where the ask event happened before this process started.
1442
+ const desktop = new Map()
1443
+ for (const entry of desktopApprovalsFor(id)) desktop.set(entry.id, entry)
1444
+ const decided = new Set()
1445
+ for (const event of snapshot?.events ?? []) {
1446
+ if (event?.type === 'approval/decided') decided.add(String(event.data?.id ?? ''))
1447
+ }
1448
+ for (const approvalId of decided) desktop.delete(approvalId)
1449
+ for (const entry of pendingApprovalsFromEvents(snapshot?.events, (approvalId) => approvals.has(approvalId))) {
1450
+ if (!desktop.has(entry.id)) desktop.set(entry.id, { ...entry, sessionId: id })
1451
+ }
1452
+ const desktopApprovals = [...desktop.values()]
1453
+ .map(({ id: approvalId, toolName, reason, at }) => ({ id: approvalId, toolName, reason, at }))
1335
1454
  // Newest first while budgeting, then restore chronological order.
1336
1455
  const budgeted = []
1337
1456
  let total = 0
@@ -1341,7 +1460,7 @@ export function apply(ctx, rawConfig = {}) {
1341
1460
  total += message.text.length
1342
1461
  budgeted.unshift(message)
1343
1462
  }
1344
- return { id, title, cwd: snapshot?.session?.cwd, messages: budgeted, approvals: pending }
1463
+ return { id, title, cwd: snapshot?.session?.cwd, messages: budgeted, approvals: pending, desktopApprovals }
1345
1464
  },
1346
1465
  }
1347
1466
 
package/lib/page.html CHANGED
@@ -35,6 +35,7 @@
35
35
  .dot.live{background:var(--accent);box-shadow:0 0 0 3px rgba(77,107,254,.16)}
36
36
  .t{font-size:14.5px;line-height:1.35;word-break:break-word;overflow-wrap:anywhere}
37
37
  .m{font-size:11.5px;color:var(--dim);margin-top:3px;display:flex;gap:8px;flex-wrap:wrap}
38
+ .m .pc{color:#c8a85a;border:1px solid #3a3320;border-radius:99px;padding:0 6px;font-size:10px}
38
39
  .msg{padding:10px 14px}
39
40
  .msg .who{font-size:10.5px;color:var(--dim);letter-spacing:.4px;margin-bottom:3px}
40
41
  .msg .body{word-break:break-word;overflow-wrap:anywhere;font-size:14px;max-width:100%}
@@ -113,6 +114,13 @@
113
114
  padding:6px 12px;font-size:12.5px;font-family:inherit;flex:none;white-space:nowrap}
114
115
  .waiting .ap.allow{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600}
115
116
  .waiting .ap:disabled{opacity:.4}
117
+ .pcwait{flex:none;display:flex;gap:8px;align-items:center;padding:9px 14px;font-size:12px;
118
+ background:#151a12;border-top:1px solid #2b3320;color:#c8d6a8}
119
+ .pcwait b{font-weight:600;white-space:nowrap}
120
+ .pcwait code{background:#0d0f0c;border:1px solid #2b3320;border-radius:6px;padding:1px 6px;
121
+ font-size:11.5px;white-space:nowrap;flex:none;max-width:30vw;overflow:hidden;text-overflow:ellipsis}
122
+ .pcwait span{margin-left:auto;color:#7d8a68;font-size:11px;white-space:nowrap;min-width:0;
123
+ overflow:hidden;text-overflow:ellipsis}
116
124
  .composer{flex:none;width:100%;display:flex;gap:8px;align-items:flex-end;padding:8px 12px calc(10px + env(safe-area-inset-bottom));
117
125
  border-top:1px solid var(--line);background:var(--bg)}
118
126
  /* iOS Safari auto-zooms the whole page when a focused field's font-size is
@@ -144,6 +152,7 @@
144
152
  <button class="btn" id="refresh">刷新</button>
145
153
  </header>
146
154
  <main id="main"><div class="skel"></div><div class="skel"></div><div class="skel"></div></main>
155
+ <div class="pcwait" id="pcwait" hidden></div>
147
156
  <div class="waiting" id="waiting" hidden></div>
148
157
  <button class="jump" id="jump" hidden>↓</button>
149
158
  <div class="lightbox" id="lightbox" hidden><button class="close" id="lightbox-close">×</button></div>
@@ -158,7 +167,7 @@
158
167
  <footer><span id="hint">只读</span><span id="stat"></span></footer>
159
168
 
160
169
  <script>
161
- const BUILD = 'v36'
170
+ const BUILD = 'v37'
162
171
  // Anything that kills the script before the first render used to leave the phone
163
172
  // on a black screen with no clue. Surface it instead.
164
173
  function showFatal (message) {
@@ -179,10 +188,12 @@ window.addEventListener('unhandledrejection', (event) => {
179
188
  const $ = (id) => document.getElementById(id)
180
189
  const main = $('main'), title = $('title'), back = $('back'), hint = $('hint'), stat = $('stat')
181
190
  const composer = $('composer'), input = $('input'), sendBtn = $('send'), waiting = $('waiting'), run = $('run')
191
+ const pcwait = $('pcwait')
182
192
  const jump = $('jump'), stop = $('stop'), chip = $('chip'), attach = $('attach'), fileInput = $('file'), conn = $('conn')
183
193
  const lightbox = $('lightbox')
184
194
  let pendingImages = []
185
195
  let running = false
196
+ let phoneApproval = false
186
197
  let view = { name: 'list', id: null }
187
198
 
188
199
  const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]))
@@ -207,7 +218,7 @@ function sessionRow (s) {
207
218
  return `<div class="row under" data-id="${esc(s.id)}">
208
219
  <div class="dot ${s.running ? 'live' : ''}"></div>
209
220
  <div style="flex:1;min-width:0"><div class="t">${esc(s.title || '(未命名)')}</div>
210
- <div class="m"><span>${ago(s.updatedAt ?? s.createdAt)}</span>${s.running ? '<span>运行中</span>' : ''}</div>
221
+ <div class="m"><span>${ago(s.updatedAt ?? s.createdAt)}</span>${s.running ? '<span>运行中</span>' : ''}${s.waiting ? '<span class="pc">等待电脑审批</span>' : ''}</div>
211
222
  </div></div>`
212
223
  }
213
224
 
@@ -225,6 +236,7 @@ function renderTree (data) {
225
236
  back.hidden = true
226
237
  composer.hidden = true
227
238
  waiting.hidden = true
239
+ pcwait.hidden = true
228
240
  jump.hidden = true
229
241
  if (!groups.length) { main.innerHTML = '<div class="empty">还没有工作区</div>'; return }
230
242
 
@@ -300,8 +312,20 @@ function renderTree (data) {
300
312
  }
301
313
  }
302
314
 
315
+ function renderDesktopWaiting (status) {
316
+ const count = Number(status && status.count) || 0
317
+ if (count === 0 || phoneApproval) { pcwait.hidden = true; pcwait.innerHTML = ''; return }
318
+ pcwait.hidden = false
319
+ const label = count > 1 ? `电脑上有 ${count} 个审批等待处理` : '电脑上等待审批'
320
+ pcwait.innerHTML = `<b>${label}</b>` +
321
+ (status.toolName ? `<code>${esc(status.toolName)}</code>` : '') +
322
+ `<span>在电脑上处理后会自动继续</span>`
323
+ }
324
+
303
325
  function renderApproval (pending) {
326
+ phoneApproval = pending !== null && pending !== undefined
304
327
  if (!pending) { waiting.hidden = true; waiting.innerHTML = ''; return }
328
+ pcwait.hidden = true
305
329
  waiting.hidden = false
306
330
  waiting.innerHTML = `<b>等待批准</b><code>${esc(pending.toolName)}</code>` +
307
331
  (pending.reason ? `<span style="margin-left:0;color:#8a7a55">${esc(String(pending.reason).slice(0, 40))}</span>` : '') +
@@ -422,7 +446,10 @@ function renderSession (data) {
422
446
  hint.textContent = base(data.cwd) || ''
423
447
  back.hidden = false
424
448
  composer.hidden = false
425
- renderApproval((data.approvals || [])[0] || null)
449
+ const phone = (data.approvals || [])[0] || null
450
+ const desktop = data.desktopApprovals || []
451
+ renderApproval(phone)
452
+ renderDesktopWaiting({ count: desktop.length, toolName: desktop[0] && desktop[0].toolName })
426
453
  if (!data.messages.length) { main.innerHTML = '<div class="empty">这个会话还没有消息</div>'; return }
427
454
  main.innerHTML = data.messages.map((m) => messageHTML(m.role, m.text)).join('')
428
455
  main.scrollTop = main.scrollHeight
@@ -594,8 +621,9 @@ function connectStream (id) {
594
621
  if (msg.kind === 'aend') { if (liveEl) liveEl = null; return }
595
622
  if (msg.kind === 'message') { appendMessage(msg.role, msg.text); return }
596
623
  if (msg.kind === 'tool') { run.hidden = false; appendMessage('tool', '工具调用'); return }
624
+ if (msg.kind === 'approval-status') { renderDesktopWaiting(msg); return }
597
625
  if (msg.kind === 'approval') { renderApproval(msg); return }
598
- if (msg.kind === 'approval-done') { refreshOnce(); return }
626
+ if (msg.kind === 'approval-done') { phoneApproval = false; pcwait.hidden = true; refreshOnce(); return }
599
627
  }
600
628
  es.onerror = () => {
601
629
  setRunning(false)
@@ -623,6 +651,8 @@ async function loadList (resetScroll = true) {
623
651
  liveEl = null
624
652
  closeStream()
625
653
  run.hidden = true
654
+ pcwait.hidden = true
655
+ phoneApproval = false
626
656
  view = { name: 'list', id: null }
627
657
  history.replaceState(null, '', '#')
628
658
  try {
@@ -639,6 +669,8 @@ async function loadList (resetScroll = true) {
639
669
  }
640
670
 
641
671
  async function open (id) {
672
+ pcwait.hidden = true
673
+ phoneApproval = false
642
674
  view = { name: 'session', id }
643
675
  history.replaceState(null, '', '#' + id)
644
676
  main.innerHTML = '<div class="skel"></div><div class="skel"></div>'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobi-web",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "手机优先的 DeepSeek Harness 客户端:32KB 页面、SSE 实时输出、审批留在手机、Mermaid 图表、图片发送。无需改宿主,装成 bundle 即可用 Tailscale / Cloudflare 访问。",
5
5
  "keywords": [
6
6
  "dsh",