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 +7 -4
- package/docs/release-notes/v0.1.1.md +45 -0
- package/lib/index.js +126 -7
- package/lib/page.html +36 -4
- package/package.json +1 -1
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
|
|
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
|
|
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/
|
|
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 #
|
|
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) {
|
|
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 = '
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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
|
-
|
|
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>'
|