@lincyaw/dsh 0.0.0-stage → 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/LICENSE +202 -0
- package/README.md +26 -2
- package/build/client.mjs +72 -0
- package/build/dist/client.js +1390 -0
- package/build/dist/client.json +274 -0
- package/build/ensure.mjs +39 -0
- package/cordis.patch.yml +26 -0
- package/entries/context.mjs +7 -0
- package/entries/events.mjs +7 -0
- package/entries/load.mjs +11 -0
- package/entries/policy.mjs +7 -0
- package/entries/routes.mjs +7 -0
- package/entries/service.mjs +7 -0
- package/entries/skills.mjs +7 -0
- package/entries/telemetry.mjs +7 -0
- package/entries/tools.mjs +7 -0
- package/entries/turns.mjs +7 -0
- package/entries/workbench.mjs +9 -0
- package/entries/workspaces.mjs +7 -0
- package/package.json +108 -3
- package/profile.patch.yml +25 -0
- 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 +97 -0
- package/src/client/contract.ts +82 -0
- package/src/client/document-context.ts +39 -0
- package/src/client/dsh.d.ts +155 -0
- package/src/client/fallback.tsx +53 -0
- package/src/client/frame.css +505 -0
- package/src/client/index.tsx +157 -0
- package/src/client/launch-target.ts +89 -0
- package/src/client/layout.ts +230 -0
- package/src/client/mascot.tsx +37 -0
- package/src/client/messages.ts +171 -0
- package/src/client/navigation-shell.tsx +74 -0
- 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-links.tsx +71 -0
- package/src/client/speech-settings.tsx +7 -0
- package/src/client/styles.css +45 -0
- package/src/client/theme.ts +151 -0
- 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 +166 -0
- package/src/node/dsh-types.ts +124 -0
- package/src/node/fake-collector.ts +46 -0
- package/src/node/fake-model.ts +80 -0
- package/src/node/launch.ts +176 -0
- package/src/node/policy.ts +56 -0
- package/src/node/routes.ts +56 -0
- package/src/node/server-routes.ts +3 -0
- package/src/node/service.ts +131 -0
- package/src/node/skills.ts +24 -0
- package/src/node/telemetry.ts +126 -0
- package/src/node/tools.ts +66 -0
- package/src/node/turns.ts +176 -0
- package/src/node/watchdog.mjs +36 -0
- package/src/node/workbench.ts +88 -0
- package/src/node/workspace-events.ts +167 -0
- package/src/node/workspaces.ts +10 -0
- package/src/workspace-events.ts +31 -0
- package/web.patch.yml +161 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { Workbench } from '@lincyaw/workbench'
|
|
2
|
+
|
|
3
|
+
const KEY = 'presmium.root-reload.v1'
|
|
4
|
+
const DOCUMENT_KEY = 'presmium.open-document.v1'
|
|
5
|
+
const CODE = Symbol('Presmium root client code')
|
|
6
|
+
|
|
7
|
+
interface ReturnPoint {
|
|
8
|
+
workspace: string
|
|
9
|
+
directory: string
|
|
10
|
+
documentId: string
|
|
11
|
+
sectionId?: string
|
|
12
|
+
stepId?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Checkpoint { version: 1, armed: boolean, point?: ReturnPoint }
|
|
16
|
+
|
|
17
|
+
function isReturnPoint(point: unknown): point is ReturnPoint {
|
|
18
|
+
return Boolean(point && typeof point === 'object' && 'workspace' in point && typeof point.workspace === 'string'
|
|
19
|
+
&& 'directory' in point && typeof point.directory === 'string' && 'documentId' in point && typeof point.documentId === 'string'
|
|
20
|
+
&& (!('sectionId' in point) || typeof point.sectionId === 'string') && (!('stepId' in point) || typeof point.stepId === 'string'))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readOpenDocument(): ReturnPoint | undefined {
|
|
24
|
+
try {
|
|
25
|
+
const point: unknown = JSON.parse(sessionStorage.getItem(DOCUMENT_KEY) ?? 'null')
|
|
26
|
+
if (isReturnPoint(point)) return point
|
|
27
|
+
} catch { /* Invalid or unavailable storage cannot authorize navigation. */ }
|
|
28
|
+
try { sessionStorage.removeItem(DOCUMENT_KEY) } catch { /* Editing remains available. */ }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
declare global {
|
|
32
|
+
interface Window {
|
|
33
|
+
/** A page-lifetime flag, never a retained plugin, context or React root. */
|
|
34
|
+
__presmiumRootReload?: symbol | 'reloading'
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function checkpoint(): Checkpoint | undefined {
|
|
39
|
+
const value: unknown = JSON.parse(sessionStorage.getItem(KEY) ?? 'null')
|
|
40
|
+
if (!value || typeof value !== 'object' || !('version' in value) || value.version !== 1 || !('armed' in value) || typeof value.armed !== 'boolean') return
|
|
41
|
+
if (!('point' in value) || value.point === undefined) return { version: 1, armed: value.armed }
|
|
42
|
+
const point = value.point
|
|
43
|
+
if (!isReturnPoint(point)) return
|
|
44
|
+
return value as Checkpoint
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Root-owner code replacement needs a new shell mount in this Harness version. */
|
|
48
|
+
export function beginRootReload(): { reloading: boolean, point?: ReturnPoint } {
|
|
49
|
+
if (window.__presmiumRootReload && window.__presmiumRootReload !== CODE) {
|
|
50
|
+
if (window.__presmiumRootReload === 'reloading') return { reloading: true }
|
|
51
|
+
try {
|
|
52
|
+
const saved = checkpoint()
|
|
53
|
+
if (!saved) throw new Error('missing root return checkpoint')
|
|
54
|
+
sessionStorage.setItem(KEY, JSON.stringify({ ...saved, armed: true } satisfies Checkpoint))
|
|
55
|
+
window.__presmiumRootReload = 'reloading'
|
|
56
|
+
window.location.reload()
|
|
57
|
+
} catch {
|
|
58
|
+
// Do not navigate away when recovery storage could not be confirmed.
|
|
59
|
+
console.error('Presmium could not preserve the root reload checkpoint; automatic reload was stopped.')
|
|
60
|
+
}
|
|
61
|
+
return { reloading: true }
|
|
62
|
+
}
|
|
63
|
+
window.__presmiumRootReload = CODE
|
|
64
|
+
const opened = readOpenDocument()
|
|
65
|
+
try {
|
|
66
|
+
const saved = checkpoint()
|
|
67
|
+
sessionStorage.removeItem(KEY)
|
|
68
|
+
const point = saved?.armed && saved.point ? saved.point : opened
|
|
69
|
+
return { reloading: false, ...point ? { point } : {} }
|
|
70
|
+
} catch {
|
|
71
|
+
return { reloading: false, ...opened ? { point: opened } : {} }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Keep a pending address through another reload; only explicit navigation or settlement retires it. */
|
|
76
|
+
function retainOpenDocument(workbench: Workbench, returning?: ReturnPoint): { open<T>(start: () => T): T, settle(): void, close(): void } {
|
|
77
|
+
let previous: string | undefined
|
|
78
|
+
let pending = Boolean(returning)
|
|
79
|
+
let opening = false, closed = false
|
|
80
|
+
const navigation = workbench.documentNavigationVersion
|
|
81
|
+
let remembered: ReturnPoint | undefined
|
|
82
|
+
const write = (point: ReturnPoint | undefined) => {
|
|
83
|
+
const next = point ? JSON.stringify(point) : ''
|
|
84
|
+
if (next === previous) return
|
|
85
|
+
try {
|
|
86
|
+
if (point) sessionStorage.setItem(DOCUMENT_KEY, next)
|
|
87
|
+
else sessionStorage.removeItem(DOCUMENT_KEY)
|
|
88
|
+
previous = next
|
|
89
|
+
remembered = point
|
|
90
|
+
} catch {
|
|
91
|
+
// Do not keep an earlier address after a later explicit choice could not be stored.
|
|
92
|
+
try { sessionStorage.removeItem(DOCUMENT_KEY) } catch { /* Editing does not depend on address storage. */ }
|
|
93
|
+
previous = undefined
|
|
94
|
+
remembered = undefined
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (returning) write({ workspace: returning.workspace, directory: returning.directory, documentId: returning.documentId })
|
|
98
|
+
const retain = () => {
|
|
99
|
+
if (closed || opening) return
|
|
100
|
+
const { document, authoring } = workbench.state
|
|
101
|
+
let point: ReturnPoint | undefined
|
|
102
|
+
if (document !== undefined && authoring?.directory === document && authoring.identity && authoring.content) {
|
|
103
|
+
try {
|
|
104
|
+
const identity: unknown = JSON.parse(authoring.identity)
|
|
105
|
+
if (Array.isArray(identity) && identity.length === 2 && typeof identity[0] === 'string' && identity[1] === authoring.content.id) {
|
|
106
|
+
point = { workspace: identity[0], directory: document, documentId: authoring.content.id }
|
|
107
|
+
}
|
|
108
|
+
} catch { /* An unknown identity cannot authorize opening another document. */ }
|
|
109
|
+
}
|
|
110
|
+
if (point) write(point)
|
|
111
|
+
else if (document === undefined) {
|
|
112
|
+
if (!pending || workbench.documentNavigationVersion !== navigation) write(undefined)
|
|
113
|
+
} else if (remembered?.directory !== document) write(undefined)
|
|
114
|
+
}
|
|
115
|
+
const stop = workbench.subscribe(retain)
|
|
116
|
+
retain()
|
|
117
|
+
return {
|
|
118
|
+
// Opening synchronously emits an intermediate Home while retiring the previous subscriptions.
|
|
119
|
+
open<T>(start: () => T): T { opening = true; try { return start() } finally { opening = false; retain() } },
|
|
120
|
+
settle() { if (!closed) { pending = false; retain() } },
|
|
121
|
+
close() { closed = true; stop() },
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Store only a return address; document drafts keep their existing persistence and review rules. */
|
|
126
|
+
export function saveRootReturnPoint(workbench: Workbench): void {
|
|
127
|
+
const state = workbench.state, source = state.authoring
|
|
128
|
+
try {
|
|
129
|
+
if (source?.storageFailure) throw new Error('document recovery storage unavailable')
|
|
130
|
+
let point: ReturnPoint | undefined
|
|
131
|
+
if (state.document !== undefined && source?.identity && source.content) {
|
|
132
|
+
const identity: unknown = JSON.parse(source.identity)
|
|
133
|
+
if (Array.isArray(identity) && identity.length === 2 && typeof identity[0] === 'string' && identity[1] === source.content.id) {
|
|
134
|
+
const sectionId = state.position?.section, section = sectionId ? source.content.sections[sectionId] : undefined
|
|
135
|
+
const stepId = section?.stepOrder[state.position?.step ?? 0]
|
|
136
|
+
point = { workspace: identity[0], directory: state.document, documentId: source.content.id, ...section ? { sectionId, stepId } : {} }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
sessionStorage.setItem(KEY, JSON.stringify({ version: 1, armed: false, ...point ? { point } : {} } satisfies Checkpoint))
|
|
140
|
+
} catch {
|
|
141
|
+
// A previous checkpoint must not authorize a reload after this save failed.
|
|
142
|
+
try { sessionStorage.removeItem(KEY) } catch { /* Storage is unavailable. */ }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Restore only within this navigation; final source adoption checks the expected workspace and identity. */
|
|
147
|
+
export function restoreRootReturnPoint(workbench: Workbench, point: ReturnPoint | undefined): () => void {
|
|
148
|
+
let active = true
|
|
149
|
+
const retaining = retainOpenDocument(workbench, point), navigation = workbench.documentNavigationVersion
|
|
150
|
+
const stop = workbench.subscribe(() => { if (workbench.documentNavigationVersion !== navigation) active = false })
|
|
151
|
+
const restore = async () => {
|
|
152
|
+
if (!point) return
|
|
153
|
+
const info = await workbench.client.readWorkbenchInfo()
|
|
154
|
+
if (!active || info.root !== point.workspace || workbench.state.document !== undefined) return
|
|
155
|
+
stop()
|
|
156
|
+
await retaining.open(() => workbench.openDocument(point.directory, {
|
|
157
|
+
expectedIdentity: JSON.stringify([point.workspace, point.documentId]),
|
|
158
|
+
...point.sectionId ? { fallbackReturnPoint: { sectionId: point.sectionId, ...point.stepId ? { stepId: point.stepId } : {} } } : {},
|
|
159
|
+
}))
|
|
160
|
+
}
|
|
161
|
+
void restore().catch(() => { /* The normal home remains available when the return address no longer exists. */ }).finally(() => { stop(); retaining.settle() })
|
|
162
|
+
return () => { active = false; stop(); retaining.close() }
|
|
163
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { isAbsoluteWorkspacePath, parseFileAddress, resolveWorkspacePath, workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path'
|
|
2
|
+
import type { Workbench } from '@lincyaw/workbench'
|
|
3
|
+
import { useLayoutEffect, useRef, useSyncExternalStore } from 'react'
|
|
4
|
+
import { normalizePath } from './paths.ts'
|
|
5
|
+
|
|
6
|
+
const ID = '@lincyaw/dsh/source'
|
|
7
|
+
// A document's Markdown is shown as the document; other files keep Harness's own previews.
|
|
8
|
+
const DOCUMENT_SOURCE = /\.(?:md|markdown)$/i
|
|
9
|
+
const RESERVED = new Set(['node_modules', 'dist', '.git', '.presmium'])
|
|
10
|
+
|
|
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 {
|
|
13
|
+
ctx.inject(['sidebarRightTabs', 'sidebarRight', 'sessions', 'layout'], inner => inner.effect(() => {
|
|
14
|
+
let root: string | undefined
|
|
15
|
+
let active = true
|
|
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 => {
|
|
19
|
+
const file = parseFileAddress(address)
|
|
20
|
+
if (!root || file?.scope !== 'session') return
|
|
21
|
+
const session = inner.sessions.list.getSnapshot().byId[file.sessionId]
|
|
22
|
+
if (!session?.cwd || normalizePath(session.cwd) !== root || !DOCUMENT_SOURCE.test(file.path)) return
|
|
23
|
+
const absolute = normalizePath(resolveWorkspacePath(session.cwd, file.path))
|
|
24
|
+
if (!absolute || !isAbsoluteWorkspacePath(absolute) || !absolute.startsWith(`${root}/`)) return
|
|
25
|
+
const path = absolute.slice(root.length + 1)
|
|
26
|
+
if (path.split('/').some(part => RESERVED.has(part))) return
|
|
27
|
+
const documents = workbench.state.documents
|
|
28
|
+
if (!documents) return
|
|
29
|
+
const document = [...documents.documents].sort((a, b) => b.dir.length - a.dir.length).find(doc => !doc.dir || path.startsWith(`${doc.dir}/`))?.dir
|
|
30
|
+
if (document === undefined) return
|
|
31
|
+
return { path, document }
|
|
32
|
+
}
|
|
33
|
+
function SourceLink({ useTabInfo }: DshSourceTabProps) {
|
|
34
|
+
const { tab } = useTabInfo()
|
|
35
|
+
const opened = useRef<number>()
|
|
36
|
+
useLayoutEffect(() => {
|
|
37
|
+
if (!tab.visible || opened.current === tab.navigation.revision) return
|
|
38
|
+
const target = locate(tab.contentId)
|
|
39
|
+
if (!target) return
|
|
40
|
+
opened.current = tab.navigation.revision
|
|
41
|
+
const line = tab.navigation.params?.line
|
|
42
|
+
tab.actions.close()
|
|
43
|
+
if (inner.sidebarRight.isExpanded()) inner.sidebarRight.toggleExpanded()
|
|
44
|
+
showEditor(inner.layout)
|
|
45
|
+
void workbench.revealSource(target.path, typeof line === 'number' && Number.isInteger(line) && line > 0 ? line : 1)
|
|
46
|
+
}, [tab.visible, tab.contentId, tab.navigation.revision])
|
|
47
|
+
return <span data-pmw-source-route="" hidden />
|
|
48
|
+
}
|
|
49
|
+
// Previously saved Harness text tabs keep their kind when restored. Route those too.
|
|
50
|
+
function RestoredSource({ absolutePath }: { absolutePath: string }) {
|
|
51
|
+
const documents = useSyncExternalStore(workbench.subscribe, () => workbench.state.documents)
|
|
52
|
+
useLayoutEffect(() => {
|
|
53
|
+
const tab = inner.sidebarRight.active()
|
|
54
|
+
if (tab?.kind !== 'text') return
|
|
55
|
+
const target = locate(tab.contentId)
|
|
56
|
+
if (!target || normalizePath(absolutePath) !== normalizePath(resolveWorkspacePath(root, target.path))) return
|
|
57
|
+
inner.sidebarRight.close(tab.id)
|
|
58
|
+
if (inner.sidebarRight.isExpanded()) inner.sidebarRight.toggleExpanded()
|
|
59
|
+
showEditor(inner.layout)
|
|
60
|
+
void workbench.revealSource(target.path)
|
|
61
|
+
}, [absolutePath, documents])
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
const offType = inner.sidebarRightTabs.register({ id: ID, kind: 'presmium-source-link', priority: 'extension', patterns: ['dsh-resource://file/session/**'], canOpen: address => Boolean(locate(address)), title: (address = '') => workspaceTitleOf(parseFileAddress(address)?.path ?? address) })
|
|
65
|
+
let offBody: (() => void) | undefined
|
|
66
|
+
inner.slots.inject('sidebar.right.pane.tab', () => { offBody = inner.slots.register({ name: 'sidebar.right.pane.tab', key: ID }, SourceLink) })
|
|
67
|
+
let offRestored: (() => void) | undefined
|
|
68
|
+
inner.slots.inject('sidebar.right.tab.document.actions', () => { offRestored = inner.slots.register({ name: 'sidebar.right.tab.document.actions', id: ID }, RestoredSource) })
|
|
69
|
+
return () => { active = false; offRestored?.(); offBody?.(); offType() }
|
|
70
|
+
}, 'presmium: links to a document\'s source show the document'))
|
|
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
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
@import '@lincyaw/design/design.css';
|
|
2
|
+
@import '@lincyaw/workbench/styles.css';
|
|
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
|
+
}
|