@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.
Files changed (78) hide show
  1. package/README.md +8 -4
  2. package/build/client.mjs +7 -1
  3. package/build/dist/client.js +1364 -64
  4. package/build/dist/client.json +225 -21
  5. package/build/ensure.mjs +11 -2
  6. package/cordis.patch.yml +9 -0
  7. package/entries/events.mjs +7 -0
  8. package/entries/load.mjs +11 -0
  9. package/entries/telemetry.mjs +7 -0
  10. package/entries/turns.mjs +7 -0
  11. package/package.json +32 -19
  12. package/src/client/RootLayout.tsx +332 -0
  13. package/src/client/agent-requests.ts +64 -0
  14. package/src/client/assistant-setup.tsx +185 -0
  15. package/src/client/author-settings.ts +57 -0
  16. package/src/client/brand.tsx +21 -14
  17. package/src/client/contract.ts +48 -1
  18. package/src/client/document-context.ts +39 -0
  19. package/src/client/dsh.d.ts +27 -1
  20. package/src/client/fallback.tsx +23 -19
  21. package/src/client/frame.css +280 -120
  22. package/src/client/index.tsx +72 -69
  23. package/src/client/launch-target.ts +89 -0
  24. package/src/client/layout.ts +90 -77
  25. package/src/client/mascot.tsx +37 -0
  26. package/src/client/messages.ts +142 -49
  27. package/src/client/{chrome.tsx → navigation-shell.tsx} +20 -25
  28. package/src/client/observable.ts +16 -0
  29. package/src/client/paths.ts +17 -0
  30. package/src/client/root-reload.ts +163 -0
  31. package/src/client/runtime-diagnostics.tsx +228 -0
  32. package/src/client/settings-opener.ts +26 -0
  33. package/src/client/{source.tsx → source-links.tsx} +16 -26
  34. package/src/client/speech-settings.tsx +7 -0
  35. package/src/client/styles.css +42 -0
  36. package/src/client/theme.ts +4 -2
  37. package/src/client/workbench-keys.ts +36 -0
  38. package/src/client/workspace-events.ts +114 -0
  39. package/src/node/bindings.ts +345 -0
  40. package/src/node/context.ts +131 -20
  41. package/src/node/{dsh.ts → dsh-types.ts} +33 -2
  42. package/src/node/fake-collector.ts +46 -0
  43. package/src/node/fake-model.ts +80 -0
  44. package/src/node/launch.ts +127 -10
  45. package/src/node/policy.ts +20 -23
  46. package/src/node/routes.ts +21 -11
  47. package/src/node/server-routes.ts +3 -0
  48. package/src/node/service.ts +58 -207
  49. package/src/node/skills.ts +9 -31
  50. package/src/node/telemetry.ts +126 -0
  51. package/src/node/tools.ts +34 -11
  52. package/src/node/{turns/index.ts → turns.ts} +48 -37
  53. package/src/node/workbench.ts +51 -6
  54. package/src/node/workspace-events.ts +167 -0
  55. package/src/node/workspaces.ts +2 -2
  56. package/src/workspace-events.ts +31 -0
  57. package/web.patch.yml +27 -56
  58. package/rows/load.mjs +0 -8
  59. package/rows/turns.mjs +0 -7
  60. package/skills/presmium-author/SKILL.md +0 -95
  61. package/skills/presmium-author/references/assistance.md +0 -27
  62. package/skills/presmium-author/references/code.md +0 -52
  63. package/skills/presmium-author/references/directives.md +0 -592
  64. package/skills/presmium-author/references/publishing.md +0 -60
  65. package/skills/presmium-author/references/writing.md +0 -72
  66. package/skills/presmium-present/SKILL.md +0 -69
  67. package/skills/presmium-present/references/classroom.md +0 -46
  68. package/skills/presmium-present/references/delivery.md +0 -63
  69. package/skills/presmium-present/references/lecture.md +0 -58
  70. package/src/client/Frame.tsx +0 -314
  71. /package/{rows → entries}/context.mjs +0 -0
  72. /package/{rows → entries}/policy.mjs +0 -0
  73. /package/{rows → entries}/routes.mjs +0 -0
  74. /package/{rows → entries}/service.mjs +0 -0
  75. /package/{rows → entries}/skills.mjs +0 -0
  76. /package/{rows → entries}/tools.mjs +0 -0
  77. /package/{rows → entries}/workbench.mjs +0 -0
  78. /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
- // These formats keep Harness's rich previews. Text files share the workbench editor.
7
- const PREVIEW = /\.(?:png|jpe?g|gif|webp|avif|ico|bmp|svg|pdf|html?|csv|tsv|wasm|zip|gz|tgz|bz2|xz|7z|woff2?|ttf|otf|eot|mp3|mp4|m4a|wav|ogg|oga|webm|mov|avi|mkv|bin|so|dylib|dll|exe|pyc|xlsx?|docx?|pptx?|parquet|npy|npz|pkl|db|sqlite)$/i
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
- function normalized(path: string): string | undefined {
11
- const parts: string[] = []
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.workbench().then(answer => { if (active && answer.root) root = normalized(answer.root) }).catch(() => {})
28
- const locate = (address: string): { path: string, document: string } | undefined => {
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 || normalized(session.cwd) !== root || PREVIEW.test(file.path)) return
33
- const absolute = normalized(resolveWorkspacePath(session.cwd, file.path))
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 && documents.single) return
41
- return { path, document: 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.openSource(target.document, target.path, typeof line === 'number' && Number.isInteger(line) && line > 0 ? { line, mark: true } : undefined)
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 || normalized(absolutePath) !== normalized(resolveWorkspacePath(root, target.path))) return
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.openSource(target.document, target.path)
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: shared source editor'))
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
+ }
@@ -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
+ }
@@ -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
- export const TOKENS: Record<string, Pair> = {
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
- export function followScheme(snapshot: DshThemeSnapshot): boolean {
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
+ }