@lincyaw/dsh 0.1.0 → 0.2.0-rc.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/README.md +8 -4
- package/build/client.mjs +7 -1
- package/build/dist/client.js +1364 -64
- package/build/dist/client.json +225 -21
- package/build/ensure.mjs +11 -2
- package/cordis.patch.yml +9 -0
- package/entries/events.mjs +7 -0
- package/entries/load.mjs +11 -0
- package/entries/telemetry.mjs +7 -0
- package/entries/turns.mjs +7 -0
- package/package.json +32 -19
- package/src/client/RootLayout.tsx +332 -0
- package/src/client/agent-requests.ts +64 -0
- package/src/client/assistant-setup.tsx +185 -0
- package/src/client/author-settings.ts +57 -0
- package/src/client/brand.tsx +21 -14
- package/src/client/contract.ts +48 -1
- package/src/client/document-context.ts +39 -0
- package/src/client/dsh.d.ts +27 -1
- package/src/client/fallback.tsx +23 -19
- package/src/client/frame.css +280 -120
- package/src/client/index.tsx +72 -69
- package/src/client/launch-target.ts +89 -0
- package/src/client/layout.ts +90 -77
- package/src/client/mascot.tsx +37 -0
- package/src/client/messages.ts +142 -49
- package/src/client/{chrome.tsx → navigation-shell.tsx} +20 -25
- package/src/client/observable.ts +16 -0
- package/src/client/paths.ts +17 -0
- package/src/client/root-reload.ts +163 -0
- package/src/client/runtime-diagnostics.tsx +228 -0
- package/src/client/settings-opener.ts +26 -0
- package/src/client/{source.tsx → source-links.tsx} +16 -26
- package/src/client/speech-settings.tsx +7 -0
- package/src/client/styles.css +42 -0
- package/src/client/theme.ts +4 -2
- package/src/client/workbench-keys.ts +36 -0
- package/src/client/workspace-events.ts +114 -0
- package/src/node/bindings.ts +345 -0
- package/src/node/context.ts +131 -20
- package/src/node/{dsh.ts → dsh-types.ts} +33 -2
- package/src/node/fake-collector.ts +46 -0
- package/src/node/fake-model.ts +80 -0
- package/src/node/launch.ts +127 -10
- package/src/node/policy.ts +20 -23
- package/src/node/routes.ts +21 -11
- package/src/node/server-routes.ts +3 -0
- package/src/node/service.ts +58 -207
- package/src/node/skills.ts +9 -31
- package/src/node/telemetry.ts +126 -0
- package/src/node/tools.ts +34 -11
- package/src/node/{turns/index.ts → turns.ts} +48 -37
- package/src/node/workbench.ts +51 -6
- package/src/node/workspace-events.ts +167 -0
- package/src/node/workspaces.ts +2 -2
- package/src/workspace-events.ts +31 -0
- package/web.patch.yml +27 -56
- package/rows/load.mjs +0 -8
- package/rows/turns.mjs +0 -7
- package/skills/presmium-author/SKILL.md +0 -95
- package/skills/presmium-author/references/assistance.md +0 -27
- package/skills/presmium-author/references/code.md +0 -52
- package/skills/presmium-author/references/directives.md +0 -592
- package/skills/presmium-author/references/publishing.md +0 -60
- package/skills/presmium-author/references/writing.md +0 -72
- package/skills/presmium-present/SKILL.md +0 -69
- package/skills/presmium-present/references/classroom.md +0 -46
- package/skills/presmium-present/references/delivery.md +0 -63
- package/skills/presmium-present/references/lecture.md +0 -58
- package/src/client/Frame.tsx +0 -314
- /package/{rows → entries}/context.mjs +0 -0
- /package/{rows → entries}/policy.mjs +0 -0
- /package/{rows → entries}/routes.mjs +0 -0
- /package/{rows → entries}/service.mjs +0 -0
- /package/{rows → entries}/skills.mjs +0 -0
- /package/{rows → entries}/tools.mjs +0 -0
- /package/{rows → entries}/workbench.mjs +0 -0
- /package/{rows → entries}/workspaces.mjs +0 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { Component, useSyncExternalStore } from 'react'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { isObservedWorkspaceFailure, PmButton, PmNotice } from '@lincyaw/workbench'
|
|
4
|
+
import type { Workbench, WorkspaceRequestEvent } from '@lincyaw/workbench'
|
|
5
|
+
import type { AuthoringDiagnostics, CanonicalSource } from '@lincyaw/kernel'
|
|
6
|
+
import { createObservable } from './observable.ts'
|
|
7
|
+
import { useClientMessages } from './messages.ts'
|
|
8
|
+
|
|
9
|
+
interface RuntimeIssue {
|
|
10
|
+
key: string
|
|
11
|
+
document?: string
|
|
12
|
+
checkId: string
|
|
13
|
+
message: string
|
|
14
|
+
feedbackOwner?: WorkspaceRequestEvent['feedbackOwner']
|
|
15
|
+
authoringSource?: CanonicalSource
|
|
16
|
+
}
|
|
17
|
+
interface RuntimeSnapshot { issues: readonly RuntimeIssue[], reportFailed: boolean }
|
|
18
|
+
const instances = new WeakMap<Workbench, WorkbenchRuntimeDiagnostics>()
|
|
19
|
+
const LIMIT = 20
|
|
20
|
+
|
|
21
|
+
/** Diagnostics never include request payloads, stack traces or URL credentials. */
|
|
22
|
+
export function runtimeMessage(value: unknown): string {
|
|
23
|
+
const text = value instanceof Error ? value.message : typeof value === 'string' ? value : ''
|
|
24
|
+
return (text || 'An unexpected workbench error occurred.')
|
|
25
|
+
.replace(/[a-z][a-z\d+.-]*:\/\/[^\s"'<>]+/gi, value => {
|
|
26
|
+
try { const url = new URL(value); return `${url.origin === 'null' ? `${url.protocol}//${url.host}` : url.origin}${url.pathname}` } catch { return '[URL]' }
|
|
27
|
+
})
|
|
28
|
+
.replace(/["']?authorization["']?\s*[:=]\s*(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')/gi, '[credential redacted]')
|
|
29
|
+
.replace(/\bauthorization\s*[:=]\s*[^\r\n;,]+/gi, '[credential redacted]')
|
|
30
|
+
.replace(/\b(?:bearer|basic)\s+[^\s"',;&]+/gi, '[credential redacted]')
|
|
31
|
+
.replace(/["']?(?:access[_-]?token|refresh[_-]?token|client[_-]?secret|api[_-]?key|token|password|secret)["']?\s*[:=]\s*(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')/gi, '[credential redacted]')
|
|
32
|
+
.replace(/\b(?:access[_-]?token|refresh[_-]?token|client[_-]?secret|api[_-]?key|token|password|secret)\b\s*[:=]\s*[^\s"',;&]+/gi, '[credential redacted]')
|
|
33
|
+
.replace(/\bsk-[A-Za-z0-9_-]{12,}\b/g, '[credential redacted]')
|
|
34
|
+
.slice(0, 600)
|
|
35
|
+
}
|
|
36
|
+
const aborted = (value: unknown) => value instanceof Error && value.name === 'AbortError'
|
|
37
|
+
|
|
38
|
+
/** Root-window failures stay separate from the authored iframe's diagnostic contribution. */
|
|
39
|
+
export class WorkbenchRuntimeDiagnostics {
|
|
40
|
+
readonly state = createObservable<RuntimeSnapshot>({ issues: [], reportFailed: false })
|
|
41
|
+
readonly #workbench: Workbench
|
|
42
|
+
readonly #instance = `workbench:${globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}`
|
|
43
|
+
readonly #seen = new WeakSet<object>()
|
|
44
|
+
readonly #attempted = new Map<string, string>()
|
|
45
|
+
readonly #requests = new Map<number, { document?: string, key: string, feedbackOwner?: WorkspaceRequestEvent['feedbackOwner'], authoringSource?: CanonicalSource }>()
|
|
46
|
+
readonly #latestRequests = new Map<string, number>()
|
|
47
|
+
#reports = Promise.resolve()
|
|
48
|
+
#timer?: ReturnType<typeof setTimeout>
|
|
49
|
+
#stop?: () => void
|
|
50
|
+
#active = false
|
|
51
|
+
#document?: string
|
|
52
|
+
|
|
53
|
+
constructor(workbench: Workbench) { this.#workbench = workbench }
|
|
54
|
+
|
|
55
|
+
start(): () => void {
|
|
56
|
+
if (this.#stop) return this.#stop
|
|
57
|
+
this.#active = true
|
|
58
|
+
const error = (event: ErrorEvent | Event) => {
|
|
59
|
+
if (event instanceof ErrorEvent) this.capture(event.error ?? event.message)
|
|
60
|
+
else if (event.target instanceof HTMLScriptElement || event.target instanceof HTMLLinkElement) {
|
|
61
|
+
const source = event.target instanceof HTMLScriptElement ? event.target.src : event.target.href
|
|
62
|
+
this.capture(`A workbench resource could not be loaded: ${source}`)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const rejection = (event: PromiseRejectionEvent) => this.capture(event.reason)
|
|
66
|
+
window.addEventListener('error', error, true)
|
|
67
|
+
window.addEventListener('unhandledrejection', rejection)
|
|
68
|
+
const unwatchRequests = this.#workbench.client.subscribeRequests?.(event => this.#request(event))
|
|
69
|
+
const unwatchWorkbench = this.#workbench.subscribe(() => this.#schedule())
|
|
70
|
+
this.#schedule()
|
|
71
|
+
this.#stop = () => {
|
|
72
|
+
this.#active = false
|
|
73
|
+
clearTimeout(this.#timer)
|
|
74
|
+
this.#timer = undefined
|
|
75
|
+
window.removeEventListener('error', error, true)
|
|
76
|
+
window.removeEventListener('unhandledrejection', rejection)
|
|
77
|
+
unwatchRequests?.()
|
|
78
|
+
unwatchWorkbench()
|
|
79
|
+
this.#requests.clear()
|
|
80
|
+
this.#latestRequests.clear()
|
|
81
|
+
if (this.#document !== undefined) this.#send(this.#document, [])
|
|
82
|
+
this.#stop = undefined
|
|
83
|
+
}
|
|
84
|
+
return this.#stop
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
capture(error: unknown): void {
|
|
88
|
+
if (!this.#active || aborted(error) || isObservedWorkspaceFailure(error)) return
|
|
89
|
+
if (typeof error === 'object' && error !== null) {
|
|
90
|
+
if (this.#seen.has(error)) return
|
|
91
|
+
this.#seen.add(error)
|
|
92
|
+
}
|
|
93
|
+
const document = this.#workbench.state.document
|
|
94
|
+
const message = runtimeMessage(error)
|
|
95
|
+
this.#add({ key: JSON.stringify(['error', document, message]), document, checkId: 'presmium.workbench.error', message, authoringSource: this.#source(document) })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#source(document: string | undefined): CanonicalSource | undefined {
|
|
99
|
+
const value = this.#workbench.state.authoring
|
|
100
|
+
if (document === undefined || value?.directory !== document || !value.content || !value.version || !value.fingerprint) return
|
|
101
|
+
return { documentId: value.content.id, version: value.version, fingerprint: value.fingerprint }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#request(event: WorkspaceRequestEvent): void {
|
|
105
|
+
if (event.kind === 'start') {
|
|
106
|
+
const document = event.document === '' ? this.#workbench.state.document : event.document
|
|
107
|
+
const key = JSON.stringify(['request', document, event.method, event.path, event.feedbackOwner])
|
|
108
|
+
this.#requests.set(event.requestId, { document, key, feedbackOwner: event.feedbackOwner, authoringSource: this.#source(document) })
|
|
109
|
+
this.#latestRequests.delete(key)
|
|
110
|
+
this.#latestRequests.set(key, event.requestId)
|
|
111
|
+
while (this.#requests.size > LIMIT * 8) this.#requests.delete(this.#requests.keys().next().value!)
|
|
112
|
+
while (this.#latestRequests.size > LIMIT * 4) this.#latestRequests.delete(this.#latestRequests.keys().next().value!)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
const request = this.#requests.get(event.requestId)
|
|
116
|
+
this.#requests.delete(event.requestId)
|
|
117
|
+
if (!request || this.#latestRequests.get(request.key) !== event.requestId || event.kind === 'cancel') return
|
|
118
|
+
const { document, key, feedbackOwner, authoringSource } = request
|
|
119
|
+
if (event.kind === 'recovery') {
|
|
120
|
+
const issues = this.state.getSnapshot().issues
|
|
121
|
+
if (issues.some(issue => issue.key === key)) this.#replace(issues.filter(issue => issue.key !== key))
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
const message = `${event.method} /api/presmium/${event.path}: ${event.status === undefined ? 'the workspace service could not be reached' : `HTTP ${event.status}`}`
|
|
125
|
+
this.#add({ key, document, checkId: 'presmium.workbench.request', message, authoringSource, ...event.status !== undefined && feedbackOwner ? { feedbackOwner } : {} })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#add(issue: RuntimeIssue): void {
|
|
129
|
+
const issues = this.state.getSnapshot().issues
|
|
130
|
+
const known = issues.find(known => known.key === issue.key)
|
|
131
|
+
if (known) {
|
|
132
|
+
if (known.message !== issue.message || known.feedbackOwner !== issue.feedbackOwner || JSON.stringify(known.authoringSource) !== JSON.stringify(issue.authoringSource)) this.#replace(issues.map(value => value === known ? issue : value))
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
this.#replace([...issues, issue].slice(-LIMIT))
|
|
136
|
+
}
|
|
137
|
+
#replace(issues: readonly RuntimeIssue[]): void {
|
|
138
|
+
this.state.set({ ...this.state.getSnapshot(), issues })
|
|
139
|
+
this.#schedule()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
visible(): readonly RuntimeIssue[] {
|
|
143
|
+
const { document } = this.#workbench.state
|
|
144
|
+
return this.state.getSnapshot().issues.filter(issue => (issue.document === undefined || issue.document === document) && issue.feedbackOwner !== 'inline-preview')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
dismiss(): void {
|
|
148
|
+
const document = this.#workbench.state.document
|
|
149
|
+
this.#replace(this.state.getSnapshot().issues.filter(issue => issue.document !== undefined && issue.document !== document))
|
|
150
|
+
}
|
|
151
|
+
retry(): void {
|
|
152
|
+
const document = this.#workbench.state.document
|
|
153
|
+
if (document !== undefined) this.#attempted.delete(document)
|
|
154
|
+
this.#schedule()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#schedule(): void {
|
|
158
|
+
if (!this.#active || this.#timer !== undefined) return
|
|
159
|
+
this.#timer = setTimeout(() => {
|
|
160
|
+
this.#timer = undefined
|
|
161
|
+
const { document } = this.#workbench.state
|
|
162
|
+
if (this.#document !== undefined && this.#document !== document) this.#send(this.#document, [])
|
|
163
|
+
this.#document = document
|
|
164
|
+
if (document === undefined) return
|
|
165
|
+
const findings: AuthoringDiagnostics['findings'] = this.state.getSnapshot().issues.filter(issue => issue.document === document).map(({ checkId, message, authoringSource }) => ({ checkId, message, severity: 'error', ...authoringSource ? { authoringSource } : {} }))
|
|
166
|
+
this.#send(document, findings)
|
|
167
|
+
}, 100)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
#send(document: string, findings: AuthoringDiagnostics['findings']): void {
|
|
171
|
+
const signature = JSON.stringify(findings)
|
|
172
|
+
if (this.#attempted.get(document) === signature) return
|
|
173
|
+
this.#attempted.delete(document)
|
|
174
|
+
this.#attempted.set(document, signature)
|
|
175
|
+
while (this.#attempted.size > LIMIT * 2) this.#attempted.delete(this.#attempted.keys().next().value!)
|
|
176
|
+
const report: AuthoringDiagnostics = { document, instance: this.#instance, findings }
|
|
177
|
+
const current = () => this.#active && this.#workbench.state.document === document && this.#attempted.get(document) === signature
|
|
178
|
+
this.#reports = this.#reports.then(async () => {
|
|
179
|
+
// A queued failure may belong to a document that was closed before delivery.
|
|
180
|
+
// Empty reports still clear this instance's old contribution in queue order.
|
|
181
|
+
if (findings.length && !current()) {
|
|
182
|
+
if (this.#attempted.get(document) === signature) this.#attempted.delete(document)
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
const result = await this.#workbench.client.reportAuthoringDiagnostics(report) as { accepted?: boolean }
|
|
187
|
+
const failed = result?.accepted !== true
|
|
188
|
+
if (current()) this.state.set({ ...this.state.getSnapshot(), reportFailed: failed })
|
|
189
|
+
} catch {
|
|
190
|
+
if (current()) this.state.set({ ...this.state.getSnapshot(), reportFailed: true })
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function runtimeDiagnostics(workbench: Workbench): WorkbenchRuntimeDiagnostics {
|
|
197
|
+
let diagnostics = instances.get(workbench)
|
|
198
|
+
if (!diagnostics) { diagnostics = new WorkbenchRuntimeDiagnostics(workbench); instances.set(workbench, diagnostics) }
|
|
199
|
+
return diagnostics
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function RuntimeDiagnosticsNotice({ workbench, fatal = false }: { workbench: Workbench, fatal?: boolean }) {
|
|
203
|
+
const diagnostics = runtimeDiagnostics(workbench)
|
|
204
|
+
const snapshot = useSyncExternalStore(diagnostics.state.subscribe, diagnostics.state.getSnapshot)
|
|
205
|
+
useSyncExternalStore(workbench.subscribe, workbench.getState)
|
|
206
|
+
const t = useClientMessages(workbench)
|
|
207
|
+
const issues = diagnostics.visible()
|
|
208
|
+
if (!fatal && !issues.length) return null
|
|
209
|
+
return <div className="pmw-runtime-feedback"><PmNotice tone="danger">
|
|
210
|
+
<p>{fatal ? t.runtimeStopped : t.runtimeProblems}</p>
|
|
211
|
+
{issues.length > 0 && <details><summary>{t.runtimeDetails}</summary><ul>{issues.map(issue => <li key={issue.key}>{issue.message}</li>)}</ul></details>}
|
|
212
|
+
{snapshot.reportFailed && <p>{t.runtimeReportFailed}</p>}
|
|
213
|
+
<div className="pmw-runtime-feedback__actions">
|
|
214
|
+
{snapshot.reportFailed && <PmButton small onClick={() => diagnostics.retry()}>{t.runtimeRetry}</PmButton>}
|
|
215
|
+
{!fatal && <PmButton small onClick={() => diagnostics.dismiss()}>{t.runtimeDismiss}</PmButton>}
|
|
216
|
+
<PmButton small icon="refresh" onClick={() => location.reload()}>{t.runtimeReload}</PmButton>
|
|
217
|
+
</div>
|
|
218
|
+
</PmNotice></div>
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export class WorkbenchRuntimeBoundary extends Component<{ workbench: Workbench, children: ReactNode }, { failed: boolean }> {
|
|
222
|
+
state = { failed: false }
|
|
223
|
+
static getDerivedStateFromError() { return { failed: true } }
|
|
224
|
+
componentDidCatch(error: Error): void { runtimeDiagnostics(this.props.workbench).capture(error) }
|
|
225
|
+
render() {
|
|
226
|
+
return this.state.failed ? <main className="pmw-runtime-fallback pm-ui"><RuntimeDiagnosticsNotice workbench={this.props.workbench} fatal /></main> : this.props.children
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { MODEL_SETTINGS } from './contract.ts'
|
|
2
|
+
|
|
3
|
+
type OpenSection = (id: string) => void
|
|
4
|
+
|
|
5
|
+
let remembered: OpenSection | undefined
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Keeps the settings shell's `openSection`, which it hands only to the onboarding steps it mounts (Presmium's welcome
|
|
9
|
+
* notice and the step in place of the DeepSeek sign-in, both at every page load), so other places can open a section.
|
|
10
|
+
*/
|
|
11
|
+
export function rememberOpenSection(openSection: OpenSection | undefined): void {
|
|
12
|
+
if (openSection) remembered = openSection
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Opens Settings at Models, as the welcome notice's "Set up a model" does; false before the shell has offered a way. */
|
|
16
|
+
export function openModelSettings(): boolean {
|
|
17
|
+
if (!remembered) return false
|
|
18
|
+
remembered(MODEL_SETTINGS.section)
|
|
19
|
+
return true
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function openSpeechSettings(): boolean {
|
|
23
|
+
if (!remembered) return false
|
|
24
|
+
remembered('presmium-speech')
|
|
25
|
+
return true
|
|
26
|
+
}
|
|
@@ -1,44 +1,34 @@
|
|
|
1
1
|
import { isAbsoluteWorkspacePath, parseFileAddress, resolveWorkspacePath, workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path'
|
|
2
2
|
import type { Workbench } from '@lincyaw/workbench'
|
|
3
3
|
import { useLayoutEffect, useRef, useSyncExternalStore } from 'react'
|
|
4
|
+
import { normalizePath } from './paths.ts'
|
|
4
5
|
|
|
5
6
|
const ID = '@lincyaw/dsh/source'
|
|
6
|
-
//
|
|
7
|
-
const
|
|
7
|
+
// A document's Markdown is shown as the document; other files keep Harness's own previews.
|
|
8
|
+
const DOCUMENT_SOURCE = /\.(?:md|markdown)$/i
|
|
8
9
|
const RESERVED = new Set(['node_modules', 'dist', '.git', '.presmium'])
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
for (const part of path.replace(/\\/g, '/').split('/')) {
|
|
13
|
-
if (!part || part === '.') continue
|
|
14
|
-
if (part === '..') {
|
|
15
|
-
if (!parts.length || parts.at(-1)?.endsWith(':')) return
|
|
16
|
-
parts.pop()
|
|
17
|
-
} else parts.push(part)
|
|
18
|
-
}
|
|
19
|
-
return `${path.startsWith('/') ? '/' : ''}${parts.join('/')}`
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Use the Harness resolver for file links and line references, then open the canonical editor. */
|
|
23
|
-
export function sourceLinks(ctx: DshClientContext, workbench: Workbench, showEditor: (layout: DshClientContext['layout']) => void): void {
|
|
11
|
+
/** Routes Harness's links to a document's Markdown (with a line) to that place in the open document. */
|
|
12
|
+
export function registerSourceLinks(ctx: DshClientContext, workbench: Workbench, showEditor: (layout: DshClientContext['layout']) => void): void {
|
|
24
13
|
ctx.inject(['sidebarRightTabs', 'sidebarRight', 'sessions', 'layout'], inner => inner.effect(() => {
|
|
25
14
|
let root: string | undefined
|
|
26
15
|
let active = true
|
|
27
|
-
void workbench.client.
|
|
28
|
-
|
|
16
|
+
void workbench.client.readWorkbenchInfo().then(answer => { if (active && answer.root) root = normalizePath(answer.root) }).catch(() => {})
|
|
17
|
+
/** The file a link names, relative to the workspace root, and the document it is in (none: the open one stays). */
|
|
18
|
+
const locate = (address: string): { path: string, document?: string } | undefined => {
|
|
29
19
|
const file = parseFileAddress(address)
|
|
30
20
|
if (!root || file?.scope !== 'session') return
|
|
31
21
|
const session = inner.sessions.list.getSnapshot().byId[file.sessionId]
|
|
32
|
-
if (!session?.cwd ||
|
|
33
|
-
const absolute =
|
|
22
|
+
if (!session?.cwd || normalizePath(session.cwd) !== root || !DOCUMENT_SOURCE.test(file.path)) return
|
|
23
|
+
const absolute = normalizePath(resolveWorkspacePath(session.cwd, file.path))
|
|
34
24
|
if (!absolute || !isAbsoluteWorkspacePath(absolute) || !absolute.startsWith(`${root}/`)) return
|
|
35
25
|
const path = absolute.slice(root.length + 1)
|
|
36
26
|
if (path.split('/').some(part => RESERVED.has(part))) return
|
|
37
27
|
const documents = workbench.state.documents
|
|
38
28
|
if (!documents) return
|
|
39
29
|
const document = [...documents.documents].sort((a, b) => b.dir.length - a.dir.length).find(doc => !doc.dir || path.startsWith(`${doc.dir}/`))?.dir
|
|
40
|
-
if (document === undefined
|
|
41
|
-
return { path, document
|
|
30
|
+
if (document === undefined) return
|
|
31
|
+
return { path, document }
|
|
42
32
|
}
|
|
43
33
|
function SourceLink({ useTabInfo }: DshSourceTabProps) {
|
|
44
34
|
const { tab } = useTabInfo()
|
|
@@ -52,7 +42,7 @@ export function sourceLinks(ctx: DshClientContext, workbench: Workbench, showEdi
|
|
|
52
42
|
tab.actions.close()
|
|
53
43
|
if (inner.sidebarRight.isExpanded()) inner.sidebarRight.toggleExpanded()
|
|
54
44
|
showEditor(inner.layout)
|
|
55
|
-
void workbench.
|
|
45
|
+
void workbench.revealSource(target.path, typeof line === 'number' && Number.isInteger(line) && line > 0 ? line : 1)
|
|
56
46
|
}, [tab.visible, tab.contentId, tab.navigation.revision])
|
|
57
47
|
return <span data-pmw-source-route="" hidden />
|
|
58
48
|
}
|
|
@@ -63,11 +53,11 @@ export function sourceLinks(ctx: DshClientContext, workbench: Workbench, showEdi
|
|
|
63
53
|
const tab = inner.sidebarRight.active()
|
|
64
54
|
if (tab?.kind !== 'text') return
|
|
65
55
|
const target = locate(tab.contentId)
|
|
66
|
-
if (!target ||
|
|
56
|
+
if (!target || normalizePath(absolutePath) !== normalizePath(resolveWorkspacePath(root, target.path))) return
|
|
67
57
|
inner.sidebarRight.close(tab.id)
|
|
68
58
|
if (inner.sidebarRight.isExpanded()) inner.sidebarRight.toggleExpanded()
|
|
69
59
|
showEditor(inner.layout)
|
|
70
|
-
void workbench.
|
|
60
|
+
void workbench.revealSource(target.path)
|
|
71
61
|
}, [absolutePath, documents])
|
|
72
62
|
return null
|
|
73
63
|
}
|
|
@@ -77,5 +67,5 @@ export function sourceLinks(ctx: DshClientContext, workbench: Workbench, showEdi
|
|
|
77
67
|
let offRestored: (() => void) | undefined
|
|
78
68
|
inner.slots.inject('sidebar.right.tab.document.actions', () => { offRestored = inner.slots.register({ name: 'sidebar.right.tab.document.actions', id: ID }, RestoredSource) })
|
|
79
69
|
return () => { active = false; offRestored?.(); offBody?.(); offType() }
|
|
80
|
-
}, 'presmium:
|
|
70
|
+
}, 'presmium: links to a document\'s source show the document'))
|
|
81
71
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { SpeechSettingsPanel, SPEECH_SETTINGS_MESSAGES, WorkbenchProvider } from '@lincyaw/workbench'
|
|
2
|
+
import type { Workbench } from '@lincyaw/workbench'
|
|
3
|
+
|
|
4
|
+
/** The framework owns Settings navigation; Presmium owns its operator service page. */
|
|
5
|
+
export function registerSpeechSettings(ctx: DshClientContext, workbench: Workbench): void {
|
|
6
|
+
ctx.effect(() => ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'presmium-speech', order: 30, label: () => SPEECH_SETTINGS_MESSAGES[workbench.state.locale].title }, () => <WorkbenchProvider workbench={workbench}><SpeechSettingsPanel /></WorkbenchProvider>)), 'presmium: voice service settings')
|
|
7
|
+
}
|
package/src/client/styles.css
CHANGED
|
@@ -1,3 +1,45 @@
|
|
|
1
1
|
@import '@lincyaw/design/design.css';
|
|
2
2
|
@import '@lincyaw/workbench/styles.css';
|
|
3
3
|
@import './frame.css';
|
|
4
|
+
|
|
5
|
+
/* The kitten among the conversation header's actions. */
|
|
6
|
+
.pmw-conversation-mascot {
|
|
7
|
+
display: inline-flex;
|
|
8
|
+
align-items: center;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/* The kitten's row in Settings → General, laid out as Harness's own rows: its name and note, then its choice. */
|
|
12
|
+
.pmw-mascot-setting {
|
|
13
|
+
display: flex;
|
|
14
|
+
align-items: center;
|
|
15
|
+
justify-content: space-between;
|
|
16
|
+
gap: var(--pm-space-4);
|
|
17
|
+
padding-block: var(--pm-space-4);
|
|
18
|
+
border-top: var(--pm-size-hairline) solid var(--pm-color-line);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.pmw-mascot-setting__copy {
|
|
22
|
+
display: grid;
|
|
23
|
+
gap: var(--pm-space-1);
|
|
24
|
+
min-width: 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.pmw-mascot-setting__title {
|
|
28
|
+
margin: 0;
|
|
29
|
+
color: var(--pm-color-ink);
|
|
30
|
+
font-size: var(--pm-text-sm);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.pmw-mascot-setting__hint {
|
|
34
|
+
margin: 0;
|
|
35
|
+
color: var(--pm-color-muted);
|
|
36
|
+
font-size: var(--pm-text-xs);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.pmw-mascot-setting .pm-ui-segmented {
|
|
40
|
+
flex: none;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@media (max-width: 767px) {
|
|
44
|
+
.pmw-mascot-setting { flex-wrap: wrap; }
|
|
45
|
+
}
|
package/src/client/theme.ts
CHANGED
|
@@ -6,7 +6,8 @@ const same = (value: string): Pair => ({ light: value, dark: value })
|
|
|
6
6
|
const pm = (name: ColorName): Pair => same(color(name))
|
|
7
7
|
const mix = (name: ColorName, percent: number, other = 'transparent'): Pair => same(`color-mix(in srgb, ${color(name)} ${percent}%, ${other})`)
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
/** DeepSeek Harness's theme tokens (`--dsw-*`) mapped onto Presmium's design tokens. */
|
|
10
|
+
export const DSH_THEME_TOKENS: Record<string, Pair> = {
|
|
10
11
|
'--dsw-alias-bg-base': pm('bg'),
|
|
11
12
|
'--dsw-alias-bg-document-preview': pm('surface'),
|
|
12
13
|
'--dsw-alias-bg-layer-1': pm('surface'),
|
|
@@ -142,7 +143,8 @@ export class ThemePresenter {
|
|
|
142
143
|
}
|
|
143
144
|
}
|
|
144
145
|
|
|
145
|
-
|
|
146
|
+
/** Sets the `dark` class of the page to DeepSeek Harness's colour scheme; returns whether it is dark. */
|
|
147
|
+
export function syncDarkClass(snapshot: DshThemeSnapshot): boolean {
|
|
146
148
|
const dark = snapshot.active.colorScheme === 'dark'
|
|
147
149
|
document.documentElement.classList.toggle('dark', dark)
|
|
148
150
|
return dark
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Workbench } from '@lincyaw/workbench'
|
|
2
|
+
import { CLIENT_MESSAGES } from './messages.ts'
|
|
3
|
+
import type { ClientMessages } from './messages.ts'
|
|
4
|
+
|
|
5
|
+
const apple = /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent)
|
|
6
|
+
const mod = apple ? '⌘' : 'Ctrl', shift = apple ? '⇧' : 'Shift'
|
|
7
|
+
const key = (code: string, ...modifiers: string[]) => ({ code, modifiers })
|
|
8
|
+
|
|
9
|
+
// The reference lists an extension's rows in the order of their ids, so the ids carry the order.
|
|
10
|
+
const ROWS: { id: string, label: keyof ClientMessages['keys'], keys: string[], bindings: [DshShortcutBinding, ...DshShortcutBinding[]] }[] = [
|
|
11
|
+
{ id: 'presmium.keys-1-save', label: 'save', keys: [mod, 'S'], bindings: [key('KeyS', 'primary')] },
|
|
12
|
+
{ id: 'presmium.keys-2-undo', label: 'undo', keys: [mod, 'Z'], bindings: [key('KeyZ', 'primary')] },
|
|
13
|
+
{ id: 'presmium.keys-3-redo', label: 'redo', keys: apple ? [mod, shift, 'Z'] : [mod, 'Y'], bindings: apple ? [key('KeyZ', 'primary', 'shift')] : [key('KeyY', 'primary'), key('KeyZ', 'primary', 'shift')] },
|
|
14
|
+
{ id: 'presmium.keys-4-play', label: 'play', keys: ['Space'], bindings: [key('Space')] },
|
|
15
|
+
{ id: 'presmium.keys-5-step', label: 'step', keys: ['←', '→'], bindings: [key('ArrowLeft'), key('ArrowRight')] },
|
|
16
|
+
{ id: 'presmium.keys-6-overview', label: 'overview', keys: ['O'], bindings: [key('KeyO')] },
|
|
17
|
+
{ id: 'presmium.keys-7-fullscreen', label: 'fullscreen', keys: [shift, 'F'], bindings: [key('KeyF', 'shift')] },
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Lists the workbench's own keys in Harness's shortcut reference (Settings → General → Edit Shortcuts, or Mod+/). They are
|
|
22
|
+
* fixed rows: the workbench and its preview handle the keys themselves, and the rows keep them from editable commands.
|
|
23
|
+
*/
|
|
24
|
+
export function registerWorkbenchKeys(ctx: DshClientContext, workbench: Workbench): void {
|
|
25
|
+
ctx.effect(() => {
|
|
26
|
+
const disposers = ROWS.flatMap(({ label, ...row }) => {
|
|
27
|
+
try {
|
|
28
|
+
return [ctx.shortcuts.registerFixed({ ...row, label: () => CLIENT_MESSAGES[workbench.state.locale].keys[label], group: 'application' })]
|
|
29
|
+
} catch (error) {
|
|
30
|
+
console.warn(`[presmium] the shortcut reference did not take ${row.id}`, error)
|
|
31
|
+
return []
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
return () => { for (const dispose of disposers) dispose() }
|
|
35
|
+
}, 'presmium: the workbench keys in the shortcut reference')
|
|
36
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { WebSocket } from 'partysocket'
|
|
2
|
+
import type { WorkspaceSubscription } from '@lincyaw/workbench'
|
|
3
|
+
import type { AuthoringClientAcknowledgement, AuthoringClientEvent, AuthoringClientReport } from '@lincyaw/kernel'
|
|
4
|
+
import { AUTHORING_CLIENT_REQUEST_TIMEOUT_MS } from '@lincyaw/kernel'
|
|
5
|
+
import { WORKSPACE_EVENTS_PATH, workspaceEventMessage } from '../workspace-events.ts'
|
|
6
|
+
import type { WorkspaceEventTopic, WorkspaceEventCommand } from '../workspace-events.ts'
|
|
7
|
+
|
|
8
|
+
const TOPICS: Readonly<Record<string, WorkspaceEventTopic>> = {
|
|
9
|
+
document: 'document', diagnostics: 'diagnostics', review: 'review', revision: 'ir', job: 'jobs', 'viewer-site': 'viewer-site', 'viewer-control': 'viewer-control', approvals: 'approvals',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Each live reader owns one logical subscription; the library owns physical reconnect timing. */
|
|
13
|
+
export function workspaceSubscriptions(ctx: Pick<DshClientContext, 'effect'>): WorkspaceSubscription {
|
|
14
|
+
type Subscription = { id?: string, request: Extract<WorkspaceEventCommand, { type: 'subscribe' }>, received(data: string, event?: string): void, failed?(): void, ready?(): void, pending?: Map<number, { resolve(): void, reject(error: Error): void, timer: ReturnType<typeof setTimeout> }> }
|
|
15
|
+
const readers = new Set<Subscription>()
|
|
16
|
+
const ids = new Map<string, Subscription>()
|
|
17
|
+
const url = new URL(WORKSPACE_EVENTS_PATH.slice(1), document.baseURI)
|
|
18
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
19
|
+
const socket = new WebSocket(url.href, [], { startClosed: true, maxEnqueuedMessages: 0 })
|
|
20
|
+
let closed = false
|
|
21
|
+
let serial = 0
|
|
22
|
+
const subscribe = (reader: Subscription) => {
|
|
23
|
+
const id = String(++serial)
|
|
24
|
+
reader.id = id
|
|
25
|
+
ids.set(id, reader)
|
|
26
|
+
socket.send(JSON.stringify({ ...reader.request, id }))
|
|
27
|
+
}
|
|
28
|
+
const opened = () => {
|
|
29
|
+
if (closed) return
|
|
30
|
+
ids.clear()
|
|
31
|
+
for (const reader of readers) subscribe(reader)
|
|
32
|
+
}
|
|
33
|
+
const disconnected = () => {
|
|
34
|
+
ids.clear()
|
|
35
|
+
for (const reader of readers) { reader.id = undefined; rejectPublications(reader); reader.failed?.() }
|
|
36
|
+
}
|
|
37
|
+
const rejectPublications = (reader: Subscription) => { for (const pending of reader.pending?.values() ?? []) { clearTimeout(pending.timer); pending.reject(new Error('client-transport-disconnected')) }; reader.pending?.clear() }
|
|
38
|
+
const message = (event: MessageEvent) => {
|
|
39
|
+
let raw: unknown
|
|
40
|
+
try { raw = JSON.parse(event.data) } catch { return }
|
|
41
|
+
const parsed = workspaceEventMessage.safeParse(raw)
|
|
42
|
+
if (!parsed.success) return
|
|
43
|
+
const value = parsed.data
|
|
44
|
+
const reader = ids.get(value.id)
|
|
45
|
+
if (!reader || !readers.has(reader)) return
|
|
46
|
+
if (value.type === 'published') {
|
|
47
|
+
const pending = reader.pending?.get(value.sequence)
|
|
48
|
+
reader.pending?.delete(value.sequence)
|
|
49
|
+
if (pending) clearTimeout(pending.timer)
|
|
50
|
+
if (value.ok) pending?.resolve(); else pending?.reject(new Error('client-publication-refused'))
|
|
51
|
+
} else if (value.type === 'ready') reader.ready?.()
|
|
52
|
+
else if (value.type === 'error') { rejectPublications(reader); reader.failed?.() }
|
|
53
|
+
else {
|
|
54
|
+
const topic = reader.request.topic
|
|
55
|
+
const named = topic === 'viewer-site' ? ['ir', 'reload'] : topic === 'viewer-control' ? ['ready', 'approval', 'settled', 'go', 'session'] : topic === 'approvals' ? ['approval', 'settled'] : undefined
|
|
56
|
+
if (named && (!value.event || !named.includes(value.event))) return
|
|
57
|
+
reader.received(value.data, value.event)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
socket.addEventListener('open', opened)
|
|
61
|
+
socket.addEventListener('close', disconnected)
|
|
62
|
+
socket.addEventListener('message', message)
|
|
63
|
+
ctx.effect(() => () => {
|
|
64
|
+
closed = true
|
|
65
|
+
for (const reader of readers) rejectPublications(reader)
|
|
66
|
+
readers.clear(); ids.clear()
|
|
67
|
+
socket.removeEventListener('open', opened)
|
|
68
|
+
socket.removeEventListener('close', disconnected)
|
|
69
|
+
socket.removeEventListener('message', message)
|
|
70
|
+
socket.close()
|
|
71
|
+
}, 'presmium: workspace subscriptions')
|
|
72
|
+
const add = (reader: Subscription) => {
|
|
73
|
+
if (!closed) {
|
|
74
|
+
readers.add(reader)
|
|
75
|
+
if (socket.readyState === WebSocket.OPEN) subscribe(reader)
|
|
76
|
+
else if (readers.size === 1) socket.reconnect()
|
|
77
|
+
}
|
|
78
|
+
return () => {
|
|
79
|
+
if (!readers.delete(reader)) return
|
|
80
|
+
rejectPublications(reader)
|
|
81
|
+
if (reader.id) {
|
|
82
|
+
ids.delete(reader.id)
|
|
83
|
+
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'unsubscribe', id: reader.id }))
|
|
84
|
+
}
|
|
85
|
+
if (readers.size === 0) socket.close()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const subscription: WorkspaceSubscription = (address, name, received, failed, ready) => {
|
|
89
|
+
const topic = TOPICS[name]
|
|
90
|
+
const query = new URL(address).searchParams
|
|
91
|
+
const document = query.get('document')
|
|
92
|
+
if (!topic || topic === 'authoring-client' || document === null) throw new Error('Invalid workspace event subscription.')
|
|
93
|
+
const request: Subscription['request'] = topic === 'viewer-site'
|
|
94
|
+
? { type: 'subscribe', id: '', topic, document, app: query.get('app') ?? '', lang: query.get('lang') ?? '', form: query.get('form') === 'slides' ? 'slides' : 'web' }
|
|
95
|
+
: topic === 'viewer-control' ? { type: 'subscribe', id: '', topic, document, window: query.get('window') ?? '' }
|
|
96
|
+
: { type: 'subscribe', id: '', topic, document }
|
|
97
|
+
const reader: Subscription = { request, received, failed, ready }
|
|
98
|
+
return add(reader)
|
|
99
|
+
}
|
|
100
|
+
subscription.openAuthoringClient = (document, open, received, failed) => {
|
|
101
|
+
const reader: Subscription = { request: { type: 'subscribe', id: '', topic: 'authoring-client', document, ...open }, pending: new Map(), failed,
|
|
102
|
+
received(raw) { let event: AuthoringClientEvent; try { event = JSON.parse(raw) } catch { return }; if (event.type === 'connected') rejectPublications(reader); received(event) } }
|
|
103
|
+
const close = add(reader)
|
|
104
|
+
const publish = (kind: 'state' | 'ack', connectionId: string, report: AuthoringClientReport | AuthoringClientAcknowledgement) => new Promise<void>((resolve, reject) => {
|
|
105
|
+
if (closed || !readers.has(reader) || !reader.id || socket.readyState !== WebSocket.OPEN || reader.pending!.has(report.sequence)) { reject(new Error('client-transport-disconnected')); return }
|
|
106
|
+
// A lost publication receipt must release the queue without authorizing a repeated action.
|
|
107
|
+
const timer = setTimeout(() => { reader.pending!.delete(report.sequence); reject(new Error('client-publication-timeout')) }, AUTHORING_CLIENT_REQUEST_TIMEOUT_MS / 2)
|
|
108
|
+
reader.pending!.set(report.sequence, { resolve, reject, timer })
|
|
109
|
+
socket.send(JSON.stringify({ type: 'authoring-client-publish', id: reader.id, connectionId, sequence: report.sequence, kind, value: report }))
|
|
110
|
+
})
|
|
111
|
+
return { close, publish: (connectionId, report) => publish('state', connectionId, report), acknowledge: (connectionId, report) => publish('ack', connectionId, report) }
|
|
112
|
+
}
|
|
113
|
+
return subscription
|
|
114
|
+
}
|