dsh-zen-remote 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +51 -0
- package/LICENSE +38 -0
- package/README.md +237 -0
- package/assets/home.png +0 -0
- package/assets/info.png +0 -0
- package/assets/pairing.png +0 -0
- package/assets/session.png +0 -0
- package/assets/sheet.png +0 -0
- package/cordis.patch.yml +17 -0
- package/cordis.patch.yml.example +16 -0
- package/docs/interface.md +200 -0
- package/docs/remote-access.en.md +335 -0
- package/docs/remote-access.md +422 -0
- package/dsh-push.mjs +213 -0
- package/lan-gate.mjs +78 -0
- package/lib/client.js +6615 -0
- package/lib/index.js +310 -0
- package/lib/index.js.map +1 -0
- package/lib/lan-gate-server.cjs +626 -0
- package/lib/types/client/MobileAttachButton.d.ts +25 -0
- package/lib/types/client/MobileAttachButton.d.ts.map +1 -0
- package/lib/types/client/MobileAttachChips.d.ts +32 -0
- package/lib/types/client/MobileAttachChips.d.ts.map +1 -0
- package/lib/types/client/MobileDrawerFooter.d.ts +21 -0
- package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -0
- package/lib/types/client/MobileHome.d.ts +24 -0
- package/lib/types/client/MobileHome.d.ts.map +1 -0
- package/lib/types/client/MobileHomeChips.d.ts +76 -0
- package/lib/types/client/MobileHomeChips.d.ts.map +1 -0
- package/lib/types/client/MobileNavOverlay.d.ts +15 -0
- package/lib/types/client/MobileNavOverlay.d.ts.map +1 -0
- package/lib/types/client/MobileNavToggle.d.ts +18 -0
- package/lib/types/client/MobileNavToggle.d.ts.map +1 -0
- package/lib/types/client/MobileSessionHeader.d.ts +58 -0
- package/lib/types/client/MobileSessionHeader.d.ts.map +1 -0
- package/lib/types/client/MobileSessionInfo.d.ts +41 -0
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -0
- package/lib/types/client/attach-upload.d.ts +78 -0
- package/lib/types/client/attach-upload.d.ts.map +1 -0
- package/lib/types/client/chips-store.d.ts +13 -0
- package/lib/types/client/chips-store.d.ts.map +1 -0
- package/lib/types/client/debug.d.ts +9 -0
- package/lib/types/client/debug.d.ts.map +1 -0
- package/lib/types/client/effects/aionui-compat.d.ts +4 -0
- package/lib/types/client/effects/aionui-compat.d.ts.map +1 -0
- package/lib/types/client/effects/gestures.d.ts +7 -0
- package/lib/types/client/effects/gestures.d.ts.map +1 -0
- package/lib/types/client/effects/header-status.d.ts +15 -0
- package/lib/types/client/effects/header-status.d.ts.map +1 -0
- package/lib/types/client/effects/phone-chrome.d.ts +106 -0
- package/lib/types/client/effects/phone-chrome.d.ts.map +1 -0
- package/lib/types/client/effects/turn-fold.d.ts +25 -0
- package/lib/types/client/effects/turn-fold.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +18 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +58 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/nav-store.d.ts +73 -0
- package/lib/types/client/nav-store.d.ts.map +1 -0
- package/lib/types/client/session-dot.d.ts +12 -0
- package/lib/types/client/session-dot.d.ts.map +1 -0
- package/lib/types/client/styles/base.css.d.ts +2 -0
- package/lib/types/client/styles/base.css.d.ts.map +1 -0
- package/lib/types/client/styles/chips.css.d.ts +2 -0
- package/lib/types/client/styles/chips.css.d.ts.map +1 -0
- package/lib/types/client/styles/compat.css.d.ts +2 -0
- package/lib/types/client/styles/compat.css.d.ts.map +1 -0
- package/lib/types/client/styles/composer.css.d.ts +2 -0
- package/lib/types/client/styles/composer.css.d.ts.map +1 -0
- package/lib/types/client/styles/header.css.d.ts +2 -0
- package/lib/types/client/styles/header.css.d.ts.map +1 -0
- package/lib/types/client/styles/home.css.d.ts +2 -0
- package/lib/types/client/styles/home.css.d.ts.map +1 -0
- package/lib/types/client/styles/index.d.ts +15 -0
- package/lib/types/client/styles/index.d.ts.map +1 -0
- package/lib/types/client/styles/info.css.d.ts +2 -0
- package/lib/types/client/styles/info.css.d.ts.map +1 -0
- package/lib/types/client/styles/layout.css.d.ts +2 -0
- package/lib/types/client/styles/layout.css.d.ts.map +1 -0
- package/lib/types/client/styles/misc.css.d.ts +2 -0
- package/lib/types/client/styles/misc.css.d.ts.map +1 -0
- package/lib/types/client/styles/turn-fold.css.d.ts +2 -0
- package/lib/types/client/styles/turn-fold.css.d.ts.map +1 -0
- package/lib/types/index.d.ts +83 -0
- package/lib/types/index.d.ts.map +1 -0
- package/llms.txt +20 -0
- package/package.json +128 -0
- package/pwa/app.css +95 -0
- package/pwa/icons/icon-192.png +0 -0
- package/pwa/icons/icon-512.png +0 -0
- package/pwa/icons/icon-maskable-512.png +0 -0
- package/pwa/icons/icon-maskable.svg +1 -0
- package/pwa/icons/icon.svg +1 -0
- package/pwa/inject.js +190 -0
- package/pwa/manifest.json +29 -0
- package/pwa/offline.html +38 -0
- package/pwa/sw.js +176 -0
- package/pwa/touch-gestures.js +94 -0
- package/scripts/build-client.mjs +101 -0
- package/scripts/cdp-probe.mjs +64 -0
- package/scripts/check-attach-upload.mjs +99 -0
- package/scripts/check-sunk-viewport.mjs +33 -0
- package/scripts/check-upload-endpoint.mjs +168 -0
- package/src/client/MobileAttachButton.tsx +125 -0
- package/src/client/MobileAttachChips.tsx +85 -0
- package/src/client/MobileDrawerFooter.tsx +56 -0
- package/src/client/MobileHome.tsx +348 -0
- package/src/client/MobileHomeChips.tsx +426 -0
- package/src/client/MobileNavOverlay.tsx +279 -0
- package/src/client/MobileNavToggle.tsx +52 -0
- package/src/client/MobileSessionHeader.tsx +242 -0
- package/src/client/MobileSessionInfo.tsx +303 -0
- package/src/client/attach-upload.ts +140 -0
- package/src/client/chips-store.ts +41 -0
- package/src/client/debug.ts +253 -0
- package/src/client/effects/aionui-compat.ts +183 -0
- package/src/client/effects/gestures.ts +265 -0
- package/src/client/effects/header-status.ts +30 -0
- package/src/client/effects/phone-chrome.ts +265 -0
- package/src/client/effects/turn-fold.ts +258 -0
- package/src/client/index.tsx +227 -0
- package/src/client/locales.ts +109 -0
- package/src/client/nav-store.ts +78 -0
- package/src/client/session-dot.ts +17 -0
- package/src/client/styles/base.css.ts +167 -0
- package/src/client/styles/chips.css.ts +224 -0
- package/src/client/styles/compat.css.ts +681 -0
- package/src/client/styles/composer.css.ts +504 -0
- package/src/client/styles/header.css.ts +368 -0
- package/src/client/styles/home.css.ts +477 -0
- package/src/client/styles/index.ts +25 -0
- package/src/client/styles/info.css.ts +238 -0
- package/src/client/styles/layout.css.ts +501 -0
- package/src/client/styles/misc.css.ts +128 -0
- package/src/client/styles/turn-fold.css.ts +106 -0
- package/src/index.ts +343 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
const CHROME = '/data/data/com.termux/files/usr/lib/chromium/chrome';
|
|
4
|
+
const SESSION = 'session-cd1f3fcc-ec1a-4b51-b7c1-840d9359bc18';
|
|
5
|
+
const PROFILE = '/data/data/com.termux/files/home/.cache/cdp-probe-' + Date.now();
|
|
6
|
+
const port = await new Promise((resolve, reject) => {
|
|
7
|
+
const srv = net.createServer();
|
|
8
|
+
srv.listen(0, '127.0.0.1', () => { const p = srv.address().port; srv.close(() => resolve(p)); });
|
|
9
|
+
srv.on('error', reject);
|
|
10
|
+
});
|
|
11
|
+
const chrome = spawn(CHROME, ['--headless=new', '--no-sandbox', '--disable-dev-shm-usage', '--remote-debugging-port=' + port, '--user-data-dir=' + PROFILE, '--window-size=390,844', 'about:blank'], { stdio: 'ignore' });
|
|
12
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
13
|
+
async function getJson(path) { const res = await fetch('http://127.0.0.1:' + port + path); return res.json(); }
|
|
14
|
+
async function waitForTarget() { for (let i = 0; i < 60; i++) { try { const l = await getJson('/json'); if (l.length) return l[0]; } catch {} await sleep(300); } throw new Error('no target'); }
|
|
15
|
+
let msgId = 0; const pending = new Map(); let ws;
|
|
16
|
+
function send(method, params = {}) { return new Promise((resolve, reject) => { const id = ++msgId; pending.set(id, { resolve, reject }); ws.send(JSON.stringify({ id, method, params })); }); }
|
|
17
|
+
async function evaluate(expression) {
|
|
18
|
+
const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
|
|
19
|
+
if (r.exceptionDetails) return { __evalError: String(r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 200) };
|
|
20
|
+
return r.result.value;
|
|
21
|
+
}
|
|
22
|
+
async function main() {
|
|
23
|
+
const target = await waitForTarget();
|
|
24
|
+
ws = new WebSocket(target.webSocketDebuggerUrl);
|
|
25
|
+
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
|
|
26
|
+
ws.onmessage = (ev) => { const msg = JSON.parse(ev.data); if (msg.id && pending.has(msg.id)) { const { resolve, reject } = pending.get(msg.id); pending.delete(msg.id); msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result); } };
|
|
27
|
+
await send('Page.enable'); await send('Runtime.enable');
|
|
28
|
+
await send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 2, mobile: true });
|
|
29
|
+
await send('Page.navigate', { url: 'http://127.0.0.1:3080/' });
|
|
30
|
+
await sleep(8000);
|
|
31
|
+
await evaluate(`localStorage.setItem('dsh.sessions.current', JSON.stringify({ sessionId: '${SESSION}' }));`);
|
|
32
|
+
await send('Page.reload');
|
|
33
|
+
let ready = false;
|
|
34
|
+
for (let i = 0; i < 25; i++) {
|
|
35
|
+
await sleep(2000);
|
|
36
|
+
ready = await evaluate(`!!document.querySelector('[data-gitgraph-chip-anchor] [data-gitgraph-chip]')`);
|
|
37
|
+
if (ready) break;
|
|
38
|
+
}
|
|
39
|
+
if (!ready) { console.log('chip not found'); process.exit(0); }
|
|
40
|
+
// 1. computed styles of the enlarged chip
|
|
41
|
+
const styles = await evaluate(`(() => {
|
|
42
|
+
const btn = document.querySelector('[data-gitgraph-chip-anchor] [data-gitgraph-chip]');
|
|
43
|
+
const cs = getComputedStyle(btn);
|
|
44
|
+
const r = btn.getBoundingClientRect();
|
|
45
|
+
return { minHeight: cs.minHeight, touchAction: cs.touchAction, fontSize: cs.fontSize, rect: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) } };
|
|
46
|
+
})()`);
|
|
47
|
+
console.log('STYLES ' + JSON.stringify(styles));
|
|
48
|
+
// 2. pressed feedback via real mouse events
|
|
49
|
+
await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: 60, y: 657, button: 'left', clickCount: 1 });
|
|
50
|
+
await sleep(120);
|
|
51
|
+
const pressed = await evaluate(`(() => { const btn = document.querySelector('[data-gitgraph-chip-anchor] [data-gitgraph-chip]'); const cs = getComputedStyle(btn); return { transform: cs.transform, active: btn.matches(':active') }; })()`);
|
|
52
|
+
console.log('PRESSED ' + JSON.stringify(pressed));
|
|
53
|
+
await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 60, y: 657, button: 'left', clickCount: 1 });
|
|
54
|
+
await sleep(2500);
|
|
55
|
+
const after = await evaluate(`(() => {
|
|
56
|
+
const p = document.querySelector('[data-gitgraph-popover]');
|
|
57
|
+
if (!p) return { popover: null };
|
|
58
|
+
const r = p.getBoundingClientRect();
|
|
59
|
+
return { popover: { y: Math.round(r.y), h: Math.round(r.height), inViewport: r.y >= 0 && r.bottom <= innerHeight }, options: document.querySelectorAll('[data-gitgraph-popover] [role="option"]').length };
|
|
60
|
+
})()`);
|
|
61
|
+
console.log('AFTER ' + JSON.stringify(after));
|
|
62
|
+
process.exit(0);
|
|
63
|
+
}
|
|
64
|
+
main().catch(e => { console.error('FATAL', e); process.exit(1); });
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Self-check for the S7 attachment plumbing.
|
|
2
|
+
//
|
|
3
|
+
// The interesting logic is all string math now: the composer draft is the
|
|
4
|
+
// feature's only state, so "which chips are showing" is entirely decided by
|
|
5
|
+
// what mentionsIn() reads out of the draft text, and the × button is entirely
|
|
6
|
+
// draftWithoutMention(). Both are exercised here, including the round trip
|
|
7
|
+
// that the UI depends on (add N, remove them one by one, land back on '').
|
|
8
|
+
//
|
|
9
|
+
// S7.1 deleted the MIME-sorting assertions that used to live here: there is no
|
|
10
|
+
// image/non-image split any more — every attachment takes the upload route.
|
|
11
|
+
//
|
|
12
|
+
// Run: node scripts/check-attach-upload.mjs (needs Node >= 23.6 type stripping)
|
|
13
|
+
import assert from 'node:assert/strict'
|
|
14
|
+
import {
|
|
15
|
+
UPLOAD_DIR,
|
|
16
|
+
UPLOAD_ROUTE,
|
|
17
|
+
draftWithMention,
|
|
18
|
+
draftWithoutMention,
|
|
19
|
+
leafOf,
|
|
20
|
+
mentionsIn,
|
|
21
|
+
middleEllipsis,
|
|
22
|
+
uploadUrl,
|
|
23
|
+
} from '../src/client/attach-upload.ts'
|
|
24
|
+
|
|
25
|
+
// ---- the upload URL --------------------------------------------------------
|
|
26
|
+
// Root-relative (so the gateway's pairing cookie rides along) and fully
|
|
27
|
+
// escaped: a filename with a space, an ampersand, or CJK must not be able to
|
|
28
|
+
// forge a second query parameter.
|
|
29
|
+
assert.equal(uploadUrl('session-1', 'a.txt'), `${UPLOAD_ROUTE}?session=session-1&name=a.txt`)
|
|
30
|
+
assert.equal(
|
|
31
|
+
uploadUrl('session-2', 'my notes&session=evil.txt'),
|
|
32
|
+
`${UPLOAD_ROUTE}?session=session-2&name=my+notes%26session%3Devil.txt`,
|
|
33
|
+
)
|
|
34
|
+
assert.equal(new URL(uploadUrl('s', '../../etc/passwd'), 'http://x').searchParams.get('name'), '../../etc/passwd')
|
|
35
|
+
assert.equal(new URL(uploadUrl('s', '照片.jpg'), 'http://x').searchParams.get('name'), '照片.jpg')
|
|
36
|
+
|
|
37
|
+
// ---- appending @mentions to the draft --------------------------------------
|
|
38
|
+
const a = `${UPLOAD_DIR}/a.pdf`
|
|
39
|
+
const b = `${UPLOAD_DIR}/b.zip`
|
|
40
|
+
|
|
41
|
+
assert.equal(draftWithMention('', a), '@.dsh-uploads/a.pdf ')
|
|
42
|
+
// A word already in the draft gets a separating space...
|
|
43
|
+
assert.equal(draftWithMention('read', a), 'read @.dsh-uploads/a.pdf ')
|
|
44
|
+
// ...but an existing trailing space or newline is not doubled.
|
|
45
|
+
assert.equal(draftWithMention('read ', a), 'read @.dsh-uploads/a.pdf ')
|
|
46
|
+
assert.equal(draftWithMention('read\n', a), 'read\n@.dsh-uploads/a.pdf ')
|
|
47
|
+
// Two files in one pick stay in order and stay separated.
|
|
48
|
+
assert.equal(draftWithMention(draftWithMention('', a), b), '@.dsh-uploads/a.pdf @.dsh-uploads/b.zip ')
|
|
49
|
+
|
|
50
|
+
// ---- reading the chips back out of the draft -------------------------------
|
|
51
|
+
assert.deepEqual(mentionsIn(''), [])
|
|
52
|
+
assert.deepEqual(mentionsIn('just some prose'), [])
|
|
53
|
+
assert.deepEqual(mentionsIn('@.dsh-uploads/a.pdf '), [a])
|
|
54
|
+
// Reading order, with the user's own words interleaved.
|
|
55
|
+
assert.deepEqual(mentionsIn('look at @.dsh-uploads/a.pdf and @.dsh-uploads/b.zip please'), [a, b])
|
|
56
|
+
// A pasted duplicate must not produce two chips with the same React key.
|
|
57
|
+
assert.deepEqual(mentionsIn('@.dsh-uploads/a.pdf @.dsh-uploads/a.pdf'), [a])
|
|
58
|
+
// Other @-mentions (dsh-at-file's workspace references) are not ours.
|
|
59
|
+
assert.deepEqual(mentionsIn('@src/index.ts @.dsh-uploads/a.pdf'), [a])
|
|
60
|
+
// The directory name is matched literally — the `.` is not a wildcard.
|
|
61
|
+
assert.deepEqual(mentionsIn('@Xdsh-uploads/a.pdf'), [])
|
|
62
|
+
|
|
63
|
+
// ---- the × button ----------------------------------------------------------
|
|
64
|
+
// Removing the only attachment leaves an empty draft, so the chip row and the
|
|
65
|
+
// composer both go clean.
|
|
66
|
+
assert.equal(draftWithoutMention('@.dsh-uploads/a.pdf ', a), '')
|
|
67
|
+
// Removing one of two leaves the other, with no double space where it was.
|
|
68
|
+
assert.equal(draftWithoutMention('@.dsh-uploads/a.pdf @.dsh-uploads/b.zip ', a), '@.dsh-uploads/b.zip ')
|
|
69
|
+
assert.equal(draftWithoutMention('read @.dsh-uploads/a.pdf please', a), 'read please')
|
|
70
|
+
// Prose survives even when it is the only thing left.
|
|
71
|
+
assert.equal(draftWithoutMention('read @.dsh-uploads/a.pdf ', a), 'read ')
|
|
72
|
+
// Removing something that is not there is a no-op.
|
|
73
|
+
assert.equal(draftWithoutMention('read @.dsh-uploads/a.pdf ', b), 'read @.dsh-uploads/a.pdf ')
|
|
74
|
+
|
|
75
|
+
// Full round trip: what the user sees after adding two and dismissing both.
|
|
76
|
+
let draft = draftWithMention(draftWithMention('photos:', a), b)
|
|
77
|
+
assert.deepEqual(mentionsIn(draft), [a, b])
|
|
78
|
+
draft = draftWithoutMention(draft, a)
|
|
79
|
+
assert.deepEqual(mentionsIn(draft), [b])
|
|
80
|
+
draft = draftWithoutMention(draft, b)
|
|
81
|
+
assert.deepEqual(mentionsIn(draft), [])
|
|
82
|
+
// The separator space added before the first mention stays: the user is still
|
|
83
|
+
// typing into this draft, and silently trimming under the caret is worse than
|
|
84
|
+
// one trailing space.
|
|
85
|
+
assert.equal(draft, 'photos: ')
|
|
86
|
+
|
|
87
|
+
// ---- chip labels -----------------------------------------------------------
|
|
88
|
+
assert.equal(leafOf(`${UPLOAD_DIR}/report.pdf`), 'report.pdf')
|
|
89
|
+
assert.equal(leafOf('report.pdf'), 'report.pdf')
|
|
90
|
+
// Short names are untouched; long ones lose their MIDDLE so the extension —
|
|
91
|
+
// the part that says what the file IS — always survives.
|
|
92
|
+
assert.equal(middleEllipsis('a.pdf'), 'a.pdf')
|
|
93
|
+
const long = middleEllipsis('quarterly-revenue-breakdown-2026.pdf')
|
|
94
|
+
assert.equal(long.length, 18)
|
|
95
|
+
assert.ok(long.startsWith('quarterly'), long)
|
|
96
|
+
assert.ok(long.endsWith('.pdf'), long)
|
|
97
|
+
assert.ok(long.includes('…'))
|
|
98
|
+
|
|
99
|
+
console.log('ATTACH UPLOAD CHECK OK')
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Self-check for the iOS sunk-viewport detection (S1.2). No desktop browser
|
|
2
|
+
// can enter that mode, so the boundary cases are asserted numerically instead.
|
|
3
|
+
// Run: node scripts/check-sunk-viewport.mjs (needs Node >= 23.6 type stripping)
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import { isViewportSunkBelowStatusBar as sunk } from '../src/client/effects/phone-chrome.ts'
|
|
6
|
+
|
|
7
|
+
// The reported device: iPhone 393x852, standalone PWA, viewport already below
|
|
8
|
+
// the status bar (852 - 793 = 59) while env still claims the full 59.
|
|
9
|
+
assert.equal(sunk({ standalone: true, screenHeight: 852, innerHeight: 793, envTop: 59 }), true)
|
|
10
|
+
|
|
11
|
+
// Same phone in a Safari tab -> never touch anything.
|
|
12
|
+
assert.equal(sunk({ standalone: false, screenHeight: 852, innerHeight: 793, envTop: 59 }), false)
|
|
13
|
+
|
|
14
|
+
// Standalone with black-translucent actually in effect: content is full-height,
|
|
15
|
+
// so the inset is a real, un-paid-for offset and must stay.
|
|
16
|
+
assert.equal(sunk({ standalone: true, screenHeight: 852, innerHeight: 852, envTop: 59 }), false)
|
|
17
|
+
|
|
18
|
+
// Landscape iPhone: the notch moves to the sides, top inset is 0. The height
|
|
19
|
+
// difference is large and meaningless -> the envTop guard rejects it.
|
|
20
|
+
assert.equal(sunk({ standalone: true, screenHeight: 852, innerHeight: 350, envTop: 0 }), false)
|
|
21
|
+
|
|
22
|
+
// Android standalone: loses status-bar height off innerHeight but reports no
|
|
23
|
+
// top inset -> same guard, no change.
|
|
24
|
+
assert.equal(sunk({ standalone: true, screenHeight: 2400, innerHeight: 2316, envTop: 0 }), false)
|
|
25
|
+
|
|
26
|
+
// Notchless / desktop: nothing to compensate either way.
|
|
27
|
+
assert.equal(sunk({ standalone: true, screenHeight: 800, innerHeight: 800, envTop: 0 }), false)
|
|
28
|
+
|
|
29
|
+
// Partial sink (system ate less than the inset claims) is not this bug —
|
|
30
|
+
// zeroing there would under-pad.
|
|
31
|
+
assert.equal(sunk({ standalone: true, screenHeight: 852, innerHeight: 832, envTop: 59 }), false)
|
|
32
|
+
|
|
33
|
+
console.log('SUNK VIEWPORT CHECK OK')
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Integration check for the S7 upload route (src/index.ts handleUpload).
|
|
2
|
+
//
|
|
3
|
+
// Drives the real handler over a real node:http socket with a fake sessions
|
|
4
|
+
// service and a throwaway workspace, so the accept/reject decisions and the
|
|
5
|
+
// bytes that reach disk are the ones the phone would produce. No harness, no
|
|
6
|
+
// session, no message: writing a file into a workspace is not a prompt.
|
|
7
|
+
//
|
|
8
|
+
// Run: node scripts/check-upload-endpoint.mjs (needs Node >= 23.6 type stripping)
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
import { createServer } from 'node:http'
|
|
11
|
+
import { mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import { UPLOAD_DIR, handleUpload, safeUploadName, sameOriginPost } from '../src/index.ts'
|
|
15
|
+
|
|
16
|
+
const MAX_BYTES = 4096
|
|
17
|
+
|
|
18
|
+
const workspace = await mkdtemp(join(tmpdir(), 'mnav-upload-'))
|
|
19
|
+
const outside = await mkdtemp(join(tmpdir(), 'mnav-outside-'))
|
|
20
|
+
let logged = 0
|
|
21
|
+
|
|
22
|
+
/** Minimal stand-in for the host Context the handler actually touches. */
|
|
23
|
+
const ctx = {
|
|
24
|
+
sessions: {
|
|
25
|
+
get: (id) => (id === 'session-live' ? { header: { cwd: workspace } } : undefined),
|
|
26
|
+
},
|
|
27
|
+
logger: { warn: () => { logged += 1 } },
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const server = createServer((req, res) => { void handleUpload(ctx, MAX_BYTES, req, res) })
|
|
31
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
|
32
|
+
const base = `http://127.0.0.1:${server.address().port}`
|
|
33
|
+
|
|
34
|
+
/** POST one body the way the browser does: raw bytes, same-origin headers. */
|
|
35
|
+
async function post(query, body, init = {}) {
|
|
36
|
+
const response = await fetch(`${base}/_dsh/mobile-nav/upload${query}`, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: { origin: base, 'content-type': 'application/octet-stream', ...(init.headers ?? {}) },
|
|
39
|
+
body,
|
|
40
|
+
duplex: 'half',
|
|
41
|
+
...init,
|
|
42
|
+
})
|
|
43
|
+
return { status: response.status, body: await response.json() }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
// ---- pure helpers, before anything touches the disk ----------------------
|
|
48
|
+
// Path separators cannot survive; only the basename does.
|
|
49
|
+
assert.equal(safeUploadName('../../etc/passwd'), 'passwd')
|
|
50
|
+
assert.equal(safeUploadName('..\\..\\windows\\system32\\evil.dll'), 'evil.dll')
|
|
51
|
+
assert.equal(safeUploadName('/etc/shadow'), 'shadow')
|
|
52
|
+
// Dots and hyphens are ordinary filename characters and must be preserved
|
|
53
|
+
// (a character-class typo here silently mangles every extension).
|
|
54
|
+
assert.equal(safeUploadName('my-photo.2026.jpg'), 'my-photo.2026.jpg')
|
|
55
|
+
// Whitespace folds to _ so the client's @path mention stays one token.
|
|
56
|
+
assert.equal(safeUploadName('照片 (1).png'), '照片_(1).png')
|
|
57
|
+
assert.equal(safeUploadName('my notes\tv2.txt'), 'my_notes_v2.txt')
|
|
58
|
+
assert.ok(!/\s/u.test(safeUploadName('a b c d.txt')))
|
|
59
|
+
// Dotfiles, empty labels, and Windows device names.
|
|
60
|
+
assert.equal(safeUploadName('...'), 'upload.bin')
|
|
61
|
+
assert.equal(safeUploadName(''), 'upload.bin')
|
|
62
|
+
assert.equal(safeUploadName('.bashrc'), 'bashrc')
|
|
63
|
+
assert.equal(safeUploadName('CON.txt'), '_CON.txt')
|
|
64
|
+
// Long names are capped in bytes, keeping the extension.
|
|
65
|
+
const long = safeUploadName(`${'あ'.repeat(200)}.jpg`)
|
|
66
|
+
assert.ok(Buffer.byteLength(long) <= 180, `long name still ${Buffer.byteLength(long)} bytes`)
|
|
67
|
+
assert.ok(long.endsWith('.jpg'))
|
|
68
|
+
|
|
69
|
+
// The gateway rewrites Origin/Host to the upstream origin, so a phone
|
|
70
|
+
// request presents as same-origin; a real cross-site POST does not.
|
|
71
|
+
assert.equal(sameOriginPost({ headers: { origin: 'http://127.0.0.1:3080', host: '127.0.0.1:3080' } }), true)
|
|
72
|
+
assert.equal(sameOriginPost({ headers: { origin: 'https://evil.example', host: '127.0.0.1:3080' } }), false)
|
|
73
|
+
assert.equal(sameOriginPost({ headers: { 'sec-fetch-site': 'cross-site' } }), false)
|
|
74
|
+
assert.equal(sameOriginPost({ headers: { 'sec-fetch-site': 'same-origin' } }), true)
|
|
75
|
+
|
|
76
|
+
// ---- happy path ----------------------------------------------------------
|
|
77
|
+
const payload = 'hello attachment\n'
|
|
78
|
+
const ok = await post('?session=session-live&name=notes.txt', payload)
|
|
79
|
+
assert.equal(ok.status, 201)
|
|
80
|
+
assert.equal(ok.body.ok, true)
|
|
81
|
+
assert.equal(ok.body.relPath, `${UPLOAD_DIR}/notes.txt`)
|
|
82
|
+
assert.equal(ok.body.bytes, Buffer.byteLength(payload))
|
|
83
|
+
assert.equal(await readFile(join(workspace, UPLOAD_DIR, 'notes.txt'), 'utf8'), payload)
|
|
84
|
+
|
|
85
|
+
// ---- same name twice does not clobber -----------------------------------
|
|
86
|
+
const again = await post('?session=session-live&name=notes.txt', 'second')
|
|
87
|
+
assert.equal(again.status, 201)
|
|
88
|
+
assert.equal(again.body.relPath, `${UPLOAD_DIR}/notes-1.txt`)
|
|
89
|
+
assert.equal(await readFile(join(workspace, UPLOAD_DIR, 'notes.txt'), 'utf8'), payload)
|
|
90
|
+
|
|
91
|
+
// ---- binary round-trip ---------------------------------------------------
|
|
92
|
+
const bytes = new Uint8Array(1024)
|
|
93
|
+
for (let i = 0; i < bytes.length; i += 1) bytes[i] = (i * 7) % 256
|
|
94
|
+
const bin = await post('?session=session-live&name=blob.bin', bytes)
|
|
95
|
+
assert.equal(bin.status, 201)
|
|
96
|
+
assert.deepEqual(new Uint8Array(await readFile(join(workspace, UPLOAD_DIR, 'blob.bin'))), bytes)
|
|
97
|
+
|
|
98
|
+
// ---- oversize, declared --------------------------------------------------
|
|
99
|
+
const tooBig = 'x'.repeat(MAX_BYTES + 1)
|
|
100
|
+
const declared = await post('?session=session-live&name=big.txt', tooBig)
|
|
101
|
+
assert.equal(declared.status, 413)
|
|
102
|
+
assert.equal(declared.body.error.code, 'too-large')
|
|
103
|
+
|
|
104
|
+
// ---- oversize, chunked (no Content-Length to pre-check) ------------------
|
|
105
|
+
const chunked = await post('?session=session-live&name=stream.txt', new ReadableStream({
|
|
106
|
+
start(controller) {
|
|
107
|
+
for (let i = 0; i < 5; i += 1) controller.enqueue(new TextEncoder().encode('y'.repeat(2048)))
|
|
108
|
+
controller.close()
|
|
109
|
+
},
|
|
110
|
+
}))
|
|
111
|
+
assert.equal(chunked.status, 413)
|
|
112
|
+
assert.equal(chunked.body.error.code, 'too-large')
|
|
113
|
+
|
|
114
|
+
// ---- path traversal ------------------------------------------------------
|
|
115
|
+
const escape = await post('?session=session-live&name=..%2F..%2Fevil.txt', 'nope')
|
|
116
|
+
assert.equal(escape.status, 201, 'the name is sanitized, not rejected')
|
|
117
|
+
assert.equal(escape.body.relPath, `${UPLOAD_DIR}/evil.txt`)
|
|
118
|
+
assert.equal(await readFile(join(workspace, UPLOAD_DIR, 'evil.txt'), 'utf8'), 'nope')
|
|
119
|
+
// Nothing at all escaped into the parent of the workspace.
|
|
120
|
+
assert.deepEqual(await readdir(outside), [])
|
|
121
|
+
|
|
122
|
+
// ---- unknown session -----------------------------------------------------
|
|
123
|
+
const ghost = await post('?session=session-gone&name=a.txt', 'x')
|
|
124
|
+
assert.equal(ghost.status, 404)
|
|
125
|
+
assert.equal(ghost.body.error.code, 'session-not-found')
|
|
126
|
+
|
|
127
|
+
// ---- malformed query -----------------------------------------------------
|
|
128
|
+
assert.equal((await post('?name=a.txt', 'x')).status, 400)
|
|
129
|
+
assert.equal((await post('?session=session-live', 'x')).status, 400)
|
|
130
|
+
assert.equal((await post('?session=a&session=b&name=x.txt', 'x')).status, 400)
|
|
131
|
+
|
|
132
|
+
// ---- method and origin ---------------------------------------------------
|
|
133
|
+
const wrongMethod = await fetch(`${base}/_dsh/mobile-nav/upload?session=session-live&name=a.txt`)
|
|
134
|
+
assert.equal(wrongMethod.status, 405)
|
|
135
|
+
const crossSite = await post('?session=session-live&name=a.txt', 'x', {
|
|
136
|
+
headers: { origin: 'https://evil.example' },
|
|
137
|
+
})
|
|
138
|
+
assert.equal(crossSite.status, 403)
|
|
139
|
+
assert.equal(crossSite.body.error.code, 'origin-rejected')
|
|
140
|
+
|
|
141
|
+
// ---- a symlinked upload directory is refused, not followed ---------------
|
|
142
|
+
const trapWorkspace = await mkdtemp(join(tmpdir(), 'mnav-trap-'))
|
|
143
|
+
await mkdir(join(outside, 'loot'), { recursive: true })
|
|
144
|
+
await symlink(join(outside, 'loot'), join(trapWorkspace, UPLOAD_DIR))
|
|
145
|
+
ctx.sessions.get = (id) => (id === 'session-trap' ? { header: { cwd: trapWorkspace } } : undefined)
|
|
146
|
+
const trapped = await post('?session=session-trap&name=a.txt', 'x')
|
|
147
|
+
assert.equal(trapped.status, 400)
|
|
148
|
+
assert.equal(trapped.body.error.code, 'path-escape')
|
|
149
|
+
assert.deepEqual(await readdir(join(outside, 'loot')), [])
|
|
150
|
+
await rm(trapWorkspace, { recursive: true, force: true })
|
|
151
|
+
|
|
152
|
+
// Every rejection that reached the body is logged once, so a failing phone
|
|
153
|
+
// leaves a trail: 2x413, 404, 3x400 query, 1x400 symlink. The 405 and 403
|
|
154
|
+
// short-circuit before the try block and deliberately log nothing — they are
|
|
155
|
+
// the two an unpaired scanner can trigger at will.
|
|
156
|
+
assert.equal(logged, 7, `expected the rejections to be logged, saw ${logged}`)
|
|
157
|
+
|
|
158
|
+
// ---- the workspace holds exactly what was uploaded, nothing partial ------
|
|
159
|
+
assert.deepEqual((await readdir(join(workspace, UPLOAD_DIR))).sort(), [
|
|
160
|
+
'blob.bin', 'evil.txt', 'notes-1.txt', 'notes.txt',
|
|
161
|
+
])
|
|
162
|
+
|
|
163
|
+
console.log('UPLOAD ENDPOINT CHECK OK')
|
|
164
|
+
} finally {
|
|
165
|
+
await new Promise((resolve) => server.close(resolve))
|
|
166
|
+
await rm(workspace, { recursive: true, force: true })
|
|
167
|
+
await rm(outside, { recursive: true, force: true })
|
|
168
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { useRef, useState } from 'react'
|
|
2
|
+
import type { ChangeEvent } from 'react'
|
|
3
|
+
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
4
|
+
import { IconPaperclipOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
5
|
+
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
|
6
|
+
import { NS } from './locales.ts'
|
|
7
|
+
import { draftWithMention, rememberThumbnail, uploadUrl } from './attach-upload.ts'
|
|
8
|
+
|
|
9
|
+
/** Full props for the composer attachment seat. */
|
|
10
|
+
export type MobileAttachButtonProps =
|
|
11
|
+
& PropsRuntime<'conversation.input.left'>
|
|
12
|
+
& PropsLocale<typeof NS>
|
|
13
|
+
|
|
14
|
+
/** Shape of the node half's response body (src/index.ts handleUpload). */
|
|
15
|
+
type UploadResult = { ok: true; relPath: string } | { ok: false; error: { code: string; message: string } }
|
|
16
|
+
|
|
17
|
+
/** Post one file to the host route and return the workspace-relative path it landed on. */
|
|
18
|
+
async function uploadFile(sessionId: SessionId, file: File): Promise<string> {
|
|
19
|
+
const response = await fetch(uploadUrl(sessionId, file.name), {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
// Same-origin so the gateway half of this plugin's pairing cookie rides along;
|
|
22
|
+
// the body is the raw bytes, no multipart wrapper for the host to parse.
|
|
23
|
+
credentials: 'same-origin',
|
|
24
|
+
headers: { 'Content-Type': file.type === '' ? 'application/octet-stream' : file.type },
|
|
25
|
+
body: file,
|
|
26
|
+
})
|
|
27
|
+
const body = (await response.json()) as UploadResult
|
|
28
|
+
if (!response.ok || !body.ok) {
|
|
29
|
+
throw new Error(body.ok === false ? body.error.message : `HTTP ${response.status}`)
|
|
30
|
+
}
|
|
31
|
+
return body.relPath
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Composer attachment button (S7).
|
|
36
|
+
*
|
|
37
|
+
* Renders into `conversation.input.left`, which CSS orders to the leftmost
|
|
38
|
+
* seat of the phone composer's bottom row (and hides at >= 768px, where the
|
|
39
|
+
* official picker on the host machine is the right answer). Tapping it opens
|
|
40
|
+
* the CLIENT's picker — no `accept` attribute on purpose, so iOS offers the
|
|
41
|
+
* full 相册 / 拍照 / 选取文件 sheet rather than one of them.
|
|
42
|
+
*
|
|
43
|
+
* Every picked file takes the SAME path: upload to the node half, then append
|
|
44
|
+
* `@.dsh-uploads/name` to the draft through `inputActions.setDraft` (the
|
|
45
|
+
* official write path — no DOM value poking). S7.1 removed the split that used
|
|
46
|
+
* to send inlineable images straight into the session with `session.prompt`:
|
|
47
|
+
* one behaviour for every attachment, and the user always presses send.
|
|
48
|
+
* MobileAttachChips renders the preview row off those same draft tokens.
|
|
49
|
+
*
|
|
50
|
+
* Files upload one at a time: a phone uplink gains nothing from parallelism
|
|
51
|
+
* and a serial loop keeps the mentions in pick order.
|
|
52
|
+
*/
|
|
53
|
+
export function MobileAttachButton({ t, sessionId, useInput, inputActions }: MobileAttachButtonProps) {
|
|
54
|
+
const picker = useRef<HTMLInputElement>(null)
|
|
55
|
+
const [busy, setBusy] = useState(false)
|
|
56
|
+
const [error, setError] = useState<string | null>(null)
|
|
57
|
+
const draft = useInput((state) => state.draft)
|
|
58
|
+
// Uploads are awaited, so the `draft` captured at pick time goes stale — the
|
|
59
|
+
// user can keep typing while a file is in flight. The ref is refreshed on
|
|
60
|
+
// every render (useInput re-renders us on each draft change), so the loop
|
|
61
|
+
// below can rebase onto whatever the composer holds right now.
|
|
62
|
+
const liveDraft = useRef(draft)
|
|
63
|
+
liveDraft.current = draft
|
|
64
|
+
|
|
65
|
+
const pick = async (event: ChangeEvent<HTMLInputElement>) => {
|
|
66
|
+
const files = Array.from(event.target.files ?? [])
|
|
67
|
+
// Reset first: picking the same file twice in a row fires no change event
|
|
68
|
+
// otherwise, which reads to the user as "the button stopped working".
|
|
69
|
+
event.target.value = ''
|
|
70
|
+
if (files.length === 0) return
|
|
71
|
+
|
|
72
|
+
setBusy(true)
|
|
73
|
+
setError(null)
|
|
74
|
+
// setDraft takes the WHOLE next draft, so each mention is appended to the
|
|
75
|
+
// value we last wrote — unless the ref shows the user has typed since, in
|
|
76
|
+
// which case theirs wins and we append to that instead. (Rebasing on the
|
|
77
|
+
// ref unconditionally would lose a mention whenever React had not yet
|
|
78
|
+
// re-rendered us between two files of the same pick.)
|
|
79
|
+
let written = liveDraft.current
|
|
80
|
+
try {
|
|
81
|
+
for (const file of files) {
|
|
82
|
+
const relPath = await uploadFile(sessionId, file)
|
|
83
|
+
// Local preview for the chip row; a no-op for non-images.
|
|
84
|
+
rememberThumbnail(relPath, file)
|
|
85
|
+
written = draftWithMention(liveDraft.current === written ? written : liveDraft.current, relPath)
|
|
86
|
+
inputActions.setDraft(written)
|
|
87
|
+
}
|
|
88
|
+
} catch (cause) {
|
|
89
|
+
setError(cause instanceof Error ? cause.message : String(cause))
|
|
90
|
+
} finally {
|
|
91
|
+
setBusy(false)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<>
|
|
97
|
+
<input
|
|
98
|
+
ref={picker}
|
|
99
|
+
type="file"
|
|
100
|
+
multiple
|
|
101
|
+
data-mobile-nav="attach-picker"
|
|
102
|
+
onChange={(event) => { void pick(event) }}
|
|
103
|
+
/>
|
|
104
|
+
<button
|
|
105
|
+
type="button"
|
|
106
|
+
data-mobile-nav="attach"
|
|
107
|
+
data-busy={busy ? '' : undefined}
|
|
108
|
+
aria-label={t('attach')}
|
|
109
|
+
aria-busy={busy}
|
|
110
|
+
title={error ?? (busy ? t('attachBusy') : t('attach'))}
|
|
111
|
+
onClick={() => {
|
|
112
|
+
setError(null)
|
|
113
|
+
picker.current?.click()
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
<IconPaperclipOutline16 size={16} />
|
|
117
|
+
{error === null ? null : (
|
|
118
|
+
<span data-mobile-nav="attach-error" role="status" onClick={(e) => { e.stopPropagation(); setError(null) }}>
|
|
119
|
+
{t('attachFailed')}
|
|
120
|
+
</span>
|
|
121
|
+
)}
|
|
122
|
+
</button>
|
|
123
|
+
</>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
3
|
+
import { IconCloseFill14, IconPaperclipOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
4
|
+
import { NS } from './locales.ts'
|
|
5
|
+
import {
|
|
6
|
+
draftWithoutMention,
|
|
7
|
+
leafOf,
|
|
8
|
+
mentionsIn,
|
|
9
|
+
middleEllipsis,
|
|
10
|
+
releaseThumbnailsExcept,
|
|
11
|
+
thumbnailFor,
|
|
12
|
+
} from './attach-upload.ts'
|
|
13
|
+
|
|
14
|
+
/** Full props for the attachment preview row. */
|
|
15
|
+
export type MobileAttachChipsProps =
|
|
16
|
+
& PropsRuntime<'conversation.input.dock'>
|
|
17
|
+
& PropsLocale<typeof NS>
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Attachment preview row (S7.1), in `conversation.input.dock` — the official
|
|
21
|
+
* full-width row above the composer card, which composer.css.ts already lays
|
|
22
|
+
* out as one horizontally scrollable chip line shared with whatever else docks
|
|
23
|
+
* there (the git branch chip, a todo strip). No DOM injection into the
|
|
24
|
+
* official card.
|
|
25
|
+
*
|
|
26
|
+
* **This component owns no state.** It renders whatever
|
|
27
|
+
* `@.dsh-uploads/...` tokens the draft currently holds, so:
|
|
28
|
+
*
|
|
29
|
+
* - uploading appends a token -> a chip appears;
|
|
30
|
+
* - the × removes the token -> the chip disappears;
|
|
31
|
+
* - the user hand-deleting the text -> the chip disappears;
|
|
32
|
+
* - the official send clearing the draft -> every chip disappears.
|
|
33
|
+
*
|
|
34
|
+
* There is no list to keep in sync and nothing to reconcile on session switch.
|
|
35
|
+
* The only side state is the preview-URL map in attach-upload.ts, and the
|
|
36
|
+
* effect below is what keeps it honest: anything no longer mentioned gets its
|
|
37
|
+
* object URL revoked.
|
|
38
|
+
*
|
|
39
|
+
* An image chip is a 48px tile; anything else (and any image whose preview did
|
|
40
|
+
* not survive, see below) is a name pill. The `<img>` is stacked ON TOP of the
|
|
41
|
+
* paperclip rather than swapped for it, so a format the engine cannot decode —
|
|
42
|
+
* HEIC everywhere except WebKit — just fails to paint and reveals the icon,
|
|
43
|
+
* with no error state to track.
|
|
44
|
+
*/
|
|
45
|
+
export function MobileAttachChips({ t, useInput, inputActions }: MobileAttachChipsProps) {
|
|
46
|
+
const draft = useInput((state) => state.draft)
|
|
47
|
+
const attachments = mentionsIn(draft)
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
releaseThumbnailsExcept(mentionsIn(draft))
|
|
51
|
+
}, [draft])
|
|
52
|
+
|
|
53
|
+
if (attachments.length === 0) return null
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div data-mobile-nav="attach-chips">
|
|
57
|
+
{attachments.map((relPath) => {
|
|
58
|
+
const name = leafOf(relPath)
|
|
59
|
+
const preview = thumbnailFor(relPath)
|
|
60
|
+
return (
|
|
61
|
+
<span
|
|
62
|
+
key={relPath}
|
|
63
|
+
data-mobile-nav="attach-chip"
|
|
64
|
+
data-kind={preview === undefined ? 'file' : 'image'}
|
|
65
|
+
title={name}
|
|
66
|
+
>
|
|
67
|
+
<span data-mobile-nav="attach-chip-art">
|
|
68
|
+
<IconPaperclipOutline16 size={16} />
|
|
69
|
+
{preview === undefined ? null : <img src={preview} alt="" />}
|
|
70
|
+
</span>
|
|
71
|
+
{preview === undefined ? <span data-mobile-nav="attach-chip-name">{middleEllipsis(name)}</span> : null}
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
data-mobile-nav="attach-chip-remove"
|
|
75
|
+
aria-label={t('attachRemove', { name })}
|
|
76
|
+
onClick={() => inputActions.setDraft(draftWithoutMention(draft, relPath))}
|
|
77
|
+
>
|
|
78
|
+
<IconCloseFill14 size={14} />
|
|
79
|
+
</button>
|
|
80
|
+
</span>
|
|
81
|
+
)
|
|
82
|
+
})}
|
|
83
|
+
</div>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
2
|
+
import { IconDownloadOutline16, IconPanelLeftOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
3
|
+
import { NS } from './locales.ts'
|
|
4
|
+
|
|
5
|
+
/** Full props for the sidebar footer action entry. */
|
|
6
|
+
export interface MobileDrawerFooterProps extends PropsRuntime<'sidebar.footer.action'>, PropsLocale<typeof NS> {
|
|
7
|
+
/** Bound ctx.sessionLogDownload.download() for the current session. */
|
|
8
|
+
downloadSessionLog: (sessionId: string) => void
|
|
9
|
+
/** Bound ctx.layout.toggleSidebar(): the Files sheet closes the drawer. */
|
|
10
|
+
toggleSidebar: () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Mobile-only drawer footer actions, relocated from the session header to the
|
|
15
|
+
* drawer footer (beside Settings):
|
|
16
|
+
* - Files: opens the dsh-web-ui aionui explorer as a floating bottom sheet
|
|
17
|
+
* (the explorer column is hidden on mobile until this marker is set, so
|
|
18
|
+
* the suite's own persisted-expanded state can never cover the UI on load).
|
|
19
|
+
* - Session log: the official session-log-export controller, so the
|
|
20
|
+
* progress/result dialog is shared with the desktop flow.
|
|
21
|
+
* Hidden entirely on wide screens (CSS media query).
|
|
22
|
+
*/
|
|
23
|
+
export function MobileDrawerFooter({ useSessions, downloadSessionLog, toggleSidebar, t }: MobileDrawerFooterProps) {
|
|
24
|
+
const sessionId = useSessions((state) => state.current)
|
|
25
|
+
const openExplorer = (): void => {
|
|
26
|
+
document.querySelector('[data-mobile-nav="frame"]')?.setAttribute('data-aionui-explorer-open', '')
|
|
27
|
+
toggleSidebar()
|
|
28
|
+
}
|
|
29
|
+
return (
|
|
30
|
+
<div data-mobile-nav="drawer-actions">
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
data-mobile-nav="explorer"
|
|
34
|
+
aria-label={t('files')}
|
|
35
|
+
title={t('files')}
|
|
36
|
+
onClick={openExplorer}
|
|
37
|
+
>
|
|
38
|
+
<IconPanelLeftOutline16 size={14} />
|
|
39
|
+
<span>{t('files')}</span>
|
|
40
|
+
</button>
|
|
41
|
+
<button
|
|
42
|
+
type="button"
|
|
43
|
+
data-mobile-nav="session-log"
|
|
44
|
+
aria-label={t('sessionLog')}
|
|
45
|
+
title={t('sessionLog')}
|
|
46
|
+
disabled={sessionId === undefined}
|
|
47
|
+
onClick={() => {
|
|
48
|
+
if (sessionId !== undefined) downloadSessionLog(sessionId)
|
|
49
|
+
}}
|
|
50
|
+
>
|
|
51
|
+
<IconDownloadOutline16 size={14} />
|
|
52
|
+
<span>{t('sessionLog')}</span>
|
|
53
|
+
</button>
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
56
|
+
}
|