dsh-multi-chat 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,108 +1,139 @@
1
- {
2
- "name": "dsh-multi-chat",
3
- "version": "1.0.2",
4
- "description": "DSH 多窗口墙:在官方 DeepSeek Harness Web 界面里以多窗口网格并排运行、监控多个对话实例,一屏盯住所有 Agent 的实时进度;内置带口令认证的局域网网关,手机/平板也能看。",
5
- "license": "MIT",
6
- "type": "module",
7
- "main": "./lib/index.js",
8
- "types": "./lib/types/index.d.ts",
9
- "exports": {
10
- ".": {
11
- "types": "./lib/types/index.d.ts",
12
- "default": "./lib/index.js"
13
- },
14
- "./client": {
15
- "types": "./lib/types/client/index.d.ts",
16
- "default": "./lib/client.js"
17
- },
18
- "./invariant": {
19
- "types": "./lib/types/invariant.d.ts",
20
- "default": "./lib/invariant.js"
21
- },
22
- "./package.json": "./package.json"
23
- },
24
- "dsh": {
25
- "bundle": {
26
- "patch": "./cordis.patch.yml"
27
- },
28
- "client": {
29
- "platform": "web",
30
- "inject": [
31
- "@deepseek-ai/dsh-client-runtime",
32
- "@deepseek-ai/dsh-client-locale",
33
- "@deepseek-ai/dsh-client-ui-layout",
34
- "@deepseek-ai/dsh-client-ui-sidebar",
35
- "@deepseek-ai/dsh-client-ui-primitives"
36
- ]
37
- },
38
- "marketplace": {
39
- "profiles": [
40
- "web"
41
- ],
42
- "requiresRestart": true,
43
- "manualSteps": false,
44
- "requiresBuildApproval": false
45
- }
46
- },
47
- "bin": {
48
- "dsh-multi-chat": "bin/dsh-multi-chat.mjs"
49
- },
50
- "repository": {
51
- "type": "git",
52
- "url": "git+https://github.com/daetz-coder/dsh-multi-chat.git"
53
- },
54
- "homepage": "https://github.com/daetz-coder/dsh-multi-chat#readme",
55
- "bugs": {
56
- "url": "https://github.com/daetz-coder/dsh-multi-chat/issues"
57
- },
58
- "keywords": [
59
- "dsh",
60
- "deepseek-harness",
61
- "deepseek",
62
- "multi-chat",
63
- "multi-conversation",
64
- "multi-window",
65
- "multi-agent",
66
- "client-plugin",
67
- "gateway",
68
- "phone-access"
69
- ],
70
- "files": [
71
- "bin/",
72
- "scripts/",
73
- "patches/",
74
- "assets/",
75
- "lib/",
76
- "src/",
77
- "cordis.patch.yml",
78
- "README.md",
79
- "README.zh.md",
80
- "LICENSE"
81
- ],
82
- "scripts": {
83
- "build": "tsc -b tsconfig.json && tsdown",
84
- "clean": "rm -rf lib",
85
- "test": "vitest run"
86
- },
87
- "devDependencies": {
88
- "@testing-library/dom": "^10.4.1",
89
- "@testing-library/react": "^16.3.2",
90
- "@types/node": "^26.2.0",
91
- "@types/react": "^18.3.31",
92
- "@types/react-dom": "^18.3.7",
93
- "jsdom": "^29.1.1",
94
- "lightningcss": "^1.33.0",
95
- "react": "^18.3.1",
96
- "react-dom": "^18.3.1",
97
- "tsdown": "^0.11.13",
98
- "typescript": "^5.9.3",
99
- "vite-tsconfig-paths": "^6.1.1",
100
- "vitest": "^2.1.9"
101
- },
102
- "engines": {
103
- "node": ">=20"
104
- },
105
- "dependencies": {
106
- "clsx": "^2.1.1"
107
- }
108
- }
1
+ {
2
+ "name": "dsh-multi-chat",
3
+ "version": "1.0.4",
4
+ "description": "DSH 多窗口墙:在官方 DeepSeek Harness Web 界面里以多窗口网格并排运行、监控多个对话实例,一屏盯住所有 Agent 的实时进度;内置带口令认证的局域网网关,手机/平板也能看。",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./lib/index.js",
8
+ "types": "./lib/types/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./lib/types/index.d.ts",
12
+ "default": "./lib/index.js"
13
+ },
14
+ "./client": {
15
+ "types": "./lib/types/client/index.d.ts",
16
+ "default": "./lib/client.js"
17
+ },
18
+ "./invariant": {
19
+ "types": "./lib/types/invariant.d.ts",
20
+ "default": "./lib/invariant.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "dsh": {
25
+ "bundle": {
26
+ "patch": "./cordis.patch.yml"
27
+ },
28
+ "client": {
29
+ "platform": "web",
30
+ "inject": [
31
+ "@deepseek-ai/dsh-client-ui-renderer",
32
+ "@deepseek-ai/dsh-client-locale",
33
+ "@deepseek-ai/dsh-client-ui-layout",
34
+ "@deepseek-ai/dsh-client-ui-sidebar"
35
+ ]
36
+ },
37
+ "marketplace": {
38
+ "profiles": [
39
+ "web"
40
+ ],
41
+ "requiresRestart": true,
42
+ "manualSteps": false,
43
+ "requiresBuildApproval": false
44
+ }
45
+ },
46
+ "bin": {
47
+ "dsh-multi-chat": "bin/dsh-multi-chat.mjs"
48
+ },
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/daetz-coder/dsh-multi-chat.git"
52
+ },
53
+ "homepage": "https://github.com/daetz-coder/dsh-multi-chat#readme",
54
+ "bugs": {
55
+ "url": "https://github.com/daetz-coder/dsh-multi-chat/issues"
56
+ },
57
+ "keywords": [
58
+ "dsh",
59
+ "deepseek-harness",
60
+ "deepseek",
61
+ "multi-chat",
62
+ "multi-conversation",
63
+ "multi-window",
64
+ "multi-agent",
65
+ "client-plugin",
66
+ "gateway",
67
+ "phone-access"
68
+ ],
69
+ "files": [
70
+ "bin/",
71
+ "scripts/",
72
+ "patches/",
73
+ "assets/",
74
+ "lib/",
75
+ "src/",
76
+ "cordis.patch.yml",
77
+ "README.md",
78
+ "README.zh.md",
79
+ "LICENSE"
80
+ ],
81
+ "scripts": {
82
+ "build": "tsc -b tsconfig.json && tsdown && npm run check:platform",
83
+ "clean": "rm -rf lib",
84
+ "test": "npm run build && vitest run",
85
+ "check:platform": "node scripts/check-platform-contract.mjs",
86
+ "check:platform:against": "node scripts/check-platform-contract.mjs --against",
87
+ "sync:platform": "node scripts/check-platform-contract.mjs --sync"
88
+ },
89
+ "devDependencies": {
90
+ "@deepseek-ai/cordis": "4.0.4",
91
+ "@deepseek-ai/dsh-client-locale": "0.1.7-rc.1",
92
+ "@deepseek-ai/dsh-client-store": "0.1.7-rc.1",
93
+ "@deepseek-ai/dsh-client-ui-conversation": "0.1.7-rc.1",
94
+ "@deepseek-ai/dsh-client-ui-layout": "0.1.7-rc.1",
95
+ "@deepseek-ai/dsh-client-ui-primitives": "0.1.7-rc.1",
96
+ "@deepseek-ai/dsh-client-ui-renderer": "0.1.7-rc.1",
97
+ "@deepseek-ai/dsh-client-ui-sidebar": "0.1.7-rc.1",
98
+ "@deepseek-ai/dsh-client-ui-slots": "0.1.7-rc.1",
99
+ "@deepseek-ai/dsh-host-webserver": "0.1.7-rc.1",
100
+ "@deepseek-ai/dsh-invariants": "0.1.7-rc.1",
101
+ "@deepseek-ai/dsh-util-workspace-path": "0.1.7-rc.1",
102
+ "@deepseek-ai/schemastery": "3.18.4",
103
+ "@shikijs/langs": "^4.4.3",
104
+ "@testing-library/dom": "^10.4.1",
105
+ "@testing-library/react": "^16.3.2",
106
+ "@types/node": "^26.2.0",
107
+ "@types/react": "^18.3.31",
108
+ "@types/react-dom": "^18.3.7",
109
+ "anser": "^2.3.5",
110
+ "clsx": "^2.1.1",
111
+ "diff": "^9.0.0",
112
+ "immer": "^10.2.0",
113
+ "jsdom": "^29.1.1",
114
+ "katex": "^0.18.9",
115
+ "lightningcss": "^1.33.0",
116
+ "mdast-util-from-markdown": "^2.0.3",
117
+ "mdast-util-gfm": "^3.1.0",
118
+ "mdast-util-math": "^3.0.0",
119
+ "micromark-core-commonmark": "^2.0.4",
120
+ "micromark-extension-gfm": "^3.0.0",
121
+ "micromark-extension-math": "^3.1.0",
122
+ "micromark-factory-space": "^2.1.0",
123
+ "micromark-util-character": "^2.1.1",
124
+ "micromark-util-classify-character": "^2.0.1",
125
+ "micromark-util-sanitize-uri": "^2.0.1",
126
+ "micromark-util-symbol": "^2.0.1",
127
+ "react": "^18.3.1",
128
+ "react-dom": "^18.3.1",
129
+ "shiki": "^4.4.3",
130
+ "simple-icons": "^16.33.0",
131
+ "tsdown": "^0.11.13",
132
+ "typescript": "^5.9.3",
133
+ "vitest": "^2.1.9",
134
+ "zustand": "^4.4.7"
135
+ },
136
+ "engines": {
137
+ "node": ">=20"
138
+ }
139
+ }
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Platform-contract guard for the plugin's browser half.
4
+ *
5
+ * A client plugin bundle is NOT loaded by Node or Vite — it is a factory handed
6
+ * to `window.__ModuleLoader__`, and every `require(spec)` inside it is resolved
7
+ * against the shell's module table. That table has exactly two sources:
8
+ *
9
+ * 1. the platform SEED TABLE — a fixed set of specifiers the web shell
10
+ * hardcodes into its own Vite bundle, and
11
+ * 2. boot-graph package rows — other plugins' bundles.
12
+ *
13
+ * A plugin can only rely on (1): it cannot conjure another package's row. So a
14
+ * require() of anything outside the seed table throws at materialization time
15
+ * with "missed the module table", and the boot is fail-closed — the whole web
16
+ * UI stops on its "Failed to load plugins" card.
17
+ *
18
+ * That is exactly how v1.0.3 broke: it required
19
+ * `@deepseek-ai/dsh-client-runtime/client`, a package upstream has since
20
+ * deleted. Nothing in the build noticed, because the type came from a local
21
+ * unversioned checkout of the harness monorepo.
22
+ *
23
+ * This script closes that hole: it reads the seed table from
24
+ * platform-seeds.json (the same file tsdown.config.ts builds its externals
25
+ * list from) and fails if the built bundle requires anything else.
26
+ *
27
+ * Usage:
28
+ * node scripts/check-platform-contract.mjs # check lib/client.js
29
+ * node scripts/check-platform-contract.mjs --against <dsh> # + diff vs an installed DSH
30
+ * node scripts/check-platform-contract.mjs --sync <dsh> # rewrite platform-seeds.json from an installed DSH
31
+ */
32
+ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs'
33
+ import { join, dirname } from 'node:path'
34
+ import { fileURLToPath } from 'node:url'
35
+
36
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
37
+ const SEEDS_FILE = join(ROOT, 'platform-seeds.json')
38
+ const BUNDLE = join(ROOT, 'lib', 'client.js')
39
+
40
+ /** Every module specifier a built bundle asks the loader for, in source order. */
41
+ function requiredSpecifiers(code) {
42
+ const found = []
43
+ for (const m of code.matchAll(/\brequire(?:\.async)?\(\s*["']([^"']+)["']\s*\)/g)) {
44
+ const spec = m[1]
45
+ // Relative chunk requests are package-local code the loader serves itself.
46
+ if (!spec.startsWith('./') && !spec.startsWith('../')) found.push(spec)
47
+ }
48
+ return [...new Set(found)]
49
+ }
50
+
51
+ /** Locate the web shell bundle inside an installed DSH package directory. */
52
+ function findShellBundle(dshRoot) {
53
+ const hits = []
54
+ const walk = (dir, depth) => {
55
+ if (depth > 6 || hits.length > 0) return
56
+ let entries
57
+ try { entries = readdirSync(dir) } catch { return }
58
+ for (const name of entries) {
59
+ const full = join(dir, name)
60
+ let st
61
+ try { st = statSync(full) } catch { continue }
62
+ if (st.isDirectory()) walk(full, depth + 1)
63
+ else if (/^index-[\w-]+\.js$/.test(name) && full.includes('dsh-web-frontend')) hits.push(full)
64
+ }
65
+ }
66
+ walk(dshRoot, 0)
67
+ if (hits.length === 0) throw new Error(`no dsh-web-frontend shell bundle found under ${dshRoot}`)
68
+ return hits[0]
69
+ }
70
+
71
+ /**
72
+ * Read the seed table out of the minified shell.
73
+ * The shell writes it as `staticModules: <fn>()`, where `<fn>` returns the table
74
+ * object literal. Both shapes seen in the wild: `function iE(){return{...}}` on
75
+ * 0.1.7-rc.1 and `function by(){return{...}}` on 0.1.5-rc.2.
76
+ */
77
+ function extractSeeds(shellCode) {
78
+ const call = /staticModules\s*:\s*([A-Za-z_$][\w$]*)\s*\(\s*\)/.exec(shellCode)
79
+ if (call === null) throw new Error('shell has no `staticModules: <fn>()` — the boot protocol changed; update this extractor')
80
+ const fn = call[1]
81
+ const body = new RegExp(`function\\s+${fn}\\s*\\(\\)\\s*\\{\\s*return\\s*\\{([^}]*)\\}`).exec(shellCode)
82
+ if (body === null) throw new Error(`could not read the body of seed-table function "${fn}"`)
83
+ const seeds = []
84
+ for (const m of body[1].matchAll(/(?:"((?:[^"\\]|\\.)+)"|([A-Za-z_$][\w$]*))\s*:/g)) {
85
+ seeds.push(m[1] !== undefined ? JSON.parse(`"${m[1]}"`) : m[2])
86
+ }
87
+ return seeds
88
+ }
89
+
90
+ /** Bare (non-`node:`) specifiers the node half imports; those resolve by normal Node rules. */
91
+ function nodeHalfSpecifiers() {
92
+ const file = join(ROOT, 'lib', 'index.js')
93
+ if (!existsSync(file)) return []
94
+ const specs = []
95
+ // Anchored to the start of a line so a `from "..."` inside a string literal
96
+ // (error messages, URLs) is not mistaken for an import statement.
97
+ for (const m of readFileSync(file, 'utf8').matchAll(/^import\s+(?:[^"'\n]*?from\s+)?["']([^"']+)["']/gm)) {
98
+ if (!m[1].startsWith('.') && !m[1].startsWith('node:')) specs.push(m[1])
99
+ }
100
+ return [...new Set(specs)]
101
+ }
102
+
103
+ const die = (msg) => { console.error(`\n✗ platform-contract: ${msg}\n`); process.exit(1) }
104
+
105
+ const argv = process.argv.slice(2)
106
+ const flag = (name) => {
107
+ const i = argv.indexOf(name)
108
+ if (i === -1) return undefined
109
+ const value = argv[i + 1]
110
+ if (value === undefined || value.startsWith('--')) die(`${name} needs a path argument`)
111
+ return value
112
+ }
113
+ const syncTo = flag('--sync')
114
+ const against = flag('--against')
115
+
116
+ // --sync: adopt the seed table of an installed DSH as this repo's contract.
117
+ if (syncTo !== undefined) {
118
+ if (!existsSync(syncTo)) die(`--sync path does not exist: ${syncTo}`)
119
+ const seeds = extractSeeds(readFileSync(findShellBundle(syncTo), 'utf8'))
120
+ const prev = JSON.parse(readFileSync(SEEDS_FILE, 'utf8'))
121
+ const version = (() => {
122
+ try { return JSON.parse(readFileSync(join(syncTo, 'package.json'), 'utf8')).version } catch { return undefined }
123
+ })()
124
+ const verified = [...new Set([...(prev.verifiedAgainst ?? []), ...(version === undefined ? [] : [version])])]
125
+ writeFileSync(SEEDS_FILE, `${JSON.stringify({ ...prev, verifiedAgainst: verified, seeds }, null, 2)}\n`)
126
+ console.log(`✓ platform-seeds.json updated from ${syncTo}${version === undefined ? '' : ` (dsh ${version})`}`)
127
+ console.log(` seeds: ${seeds.join(', ')}`)
128
+ process.exit(0)
129
+ }
130
+
131
+ const { seeds, verifiedAgainst } = JSON.parse(readFileSync(SEEDS_FILE, 'utf8'))
132
+ const seedSet = new Set(seeds)
133
+
134
+ // --against: report drift between the pinned contract and a real installation.
135
+ if (against !== undefined) {
136
+ if (!existsSync(against)) die(`--against path does not exist: ${against}`)
137
+ const live = extractSeeds(readFileSync(findShellBundle(against), 'utf8'))
138
+ const liveSet = new Set(live)
139
+ const missing = seeds.filter((s) => !liveSet.has(s))
140
+ const added = live.filter((s) => !seedSet.has(s))
141
+ if (missing.length > 0 || added.length > 0) {
142
+ console.error(`\n✗ platform-contract drift against ${against}`)
143
+ if (missing.length > 0) console.error(` required but absent upstream: ${missing.join(', ')}`)
144
+ if (added.length > 0) console.error(` new upstream seeds (run --sync to adopt): ${added.join(', ')}`)
145
+ console.error()
146
+ process.exit(1)
147
+ }
148
+ console.log(`✓ seed table matches ${against}`)
149
+ for (const spec of nodeHalfSpecifiers()) {
150
+ const ok = existsSync(join(against, 'node_modules', ...spec.split('/')))
151
+ if (!ok) die(`node half imports "${spec}" but it is not installed under ${against}`)
152
+ }
153
+ }
154
+
155
+ if (!existsSync(BUNDLE)) die(`built bundle missing: ${BUNDLE} (run the build first)`)
156
+ const required = requiredSpecifiers(readFileSync(BUNDLE, 'utf8'))
157
+ const offenders = required.filter((spec) => !seedSet.has(spec))
158
+
159
+ console.log(`platform-contract: lib/client.js requires ${required.length} external module(s)`)
160
+ console.log(` pinned seed table (verified against dsh ${verifiedAgainst.join(', ')}): ${seeds.length} entries`)
161
+ for (const spec of required) console.log(` ${seedSet.has(spec) ? '✓' : '✗'} ${spec}`)
162
+
163
+ if (offenders.length > 0) {
164
+ die(
165
+ `lib/client.js requires module(s) the platform does not seed: ${offenders.join(', ')}\n` +
166
+ ` A plugin bundle cannot reach these — the boot will fail with "missed the module table"\n` +
167
+ ` and take the whole web UI down. Either import a seed-table package instead, or,\n` +
168
+ ` if upstream genuinely added one, adopt it: npm run sync:platform -- <path-to-installed-dsh>`,
169
+ )
170
+ }
171
+ console.log('✓ every required module is a platform seed word')
@@ -1,52 +1,52 @@
1
- /**
2
- * WallToggle: the sidebar-foot shortcut. Clicking it opens the wall view —
3
- * the `conversation.view` ring switches to the 'multi-wall' entry, which the
4
- * header renders as the "多窗口" tab. The click is a plain user-equivalent
5
- * activation: it finds the header's view-ring tab for this plugin's label and
6
- * clicks it, so the official view-ring state machine (the chat store's active
7
- * view field) performs the switch. No store is declared: the ring decides
8
- * what renders, so this row owns no state.
9
- *
10
- * Session-scoped by design: the view ring (and its header tabs) only renders
11
- * with an active session, so the shortcut is inert on the empty-hero screen —
12
- * the user first opens or creates a session (the official flow), after which
13
- * the tab is present and the click lands.
14
- */
15
- import { IconFullscreenOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
16
- import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
17
- import css from './WallToggle.module.css'
18
-
19
- /** Composed props: sidebar column state + locale. */
20
- export type WallToggleProps =
21
- & PropsRuntime<'sidebar.footer.action'>
22
- & PropsLocale<'multiWall'>
23
-
24
- /**
25
- * Render the wall shortcut row (icon; label only in the wide column).
26
- * @param props - composed slot props.
27
- * @returns the shortcut row.
28
- */
29
- export function WallToggle({ wide, t }: WallToggleProps) {
30
- return (
31
- <button
32
- type="button"
33
- className={css.row}
34
- aria-label={t('toggle.aria')}
35
- title={t('toggle')}
36
- onClick={() => {
37
- // The official view ring renders the active entry through
38
- // `only: <active id>`; the only sanctioned way to move it is the
39
- // header tab's click handler (actions.setView). Trigger that same
40
- // element instead of reaching into the chat store from the root
41
- // scope. No tab (no active session) is a no-op.
42
- const label = t('view.multiWall')
43
- const tab = Array.from(document.querySelectorAll<HTMLButtonElement>('[role="tab"]'))
44
- .find(el => el.textContent?.trim() === label)
45
- tab?.click()
46
- }}
47
- >
48
- <IconFullscreenOutline16 size={wide ? 16 : 18} />
49
- {wide && <span className={css.label}>{t('toggle')}</span>}
50
- </button>
51
- )
52
- }
1
+ /**
2
+ * WallToggle: the sidebar-foot shortcut. Clicking it opens the wall view —
3
+ * the `conversation.view` ring switches to the 'multi-wall' entry, which the
4
+ * header renders as the "多窗口" tab. The click is a plain user-equivalent
5
+ * activation: it finds the header's view-ring tab for this plugin's label and
6
+ * clicks it, so the official view-ring state machine (the chat store's active
7
+ * view field) performs the switch. No store is declared: the ring decides
8
+ * what renders, so this row owns no state.
9
+ *
10
+ * Session-scoped by design: the view ring (and its header tabs) only renders
11
+ * with an active session, so the shortcut is inert on the empty-hero screen —
12
+ * the user first opens or creates a session (the official flow), after which
13
+ * the tab is present and the click lands.
14
+ */
15
+ import { IconFullscreenOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
16
+ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
17
+ import css from './WallToggle.module.css'
18
+
19
+ /** Composed props: sidebar column state + locale. */
20
+ export type WallToggleProps =
21
+ & PropsRuntime<'sidebar.footer.action'>
22
+ & PropsLocale<'multiWall'>
23
+
24
+ /**
25
+ * Render the wall shortcut row (icon; label only in the wide column).
26
+ * @param props - composed slot props.
27
+ * @returns the shortcut row.
28
+ */
29
+ export function WallToggle({ wide, t }: WallToggleProps) {
30
+ return (
31
+ <button
32
+ type="button"
33
+ className={css.row}
34
+ aria-label={t('toggle.aria')}
35
+ title={t('toggle')}
36
+ onClick={() => {
37
+ // The official view ring renders the active entry through
38
+ // `only: <active id>`; the only sanctioned way to move it is the
39
+ // header tab's click handler (actions.setView). Trigger that same
40
+ // element instead of reaching into the chat store from the root
41
+ // scope. No tab (no active session) is a no-op.
42
+ const label = t('view.multiWall')
43
+ const tab = Array.from(document.querySelectorAll<HTMLButtonElement>('[role="tab"]'))
44
+ .find(el => el.textContent?.trim() === label)
45
+ tab?.click()
46
+ }}
47
+ >
48
+ <IconFullscreenOutlineRegular size={wide ? 16 : 18} />
49
+ {wide && <span className={css.label}>{t('toggle')}</span>}
50
+ </button>
51
+ )
52
+ }