@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,332 @@
|
|
|
1
|
+
import { Group, Panel, Separator, usePanelRef } from 'react-resizable-panels'
|
|
2
|
+
import { DocumentView, HomePanel, PmDialog, PmIcon, PmMenu, SHORTCUTS, useWorkbenchState, WorkbenchApprovals, WorkbenchProvider } from '@lincyaw/workbench'
|
|
3
|
+
import type { Workbench } from '@lincyaw/workbench'
|
|
4
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
|
5
|
+
import type { ReactNode, RefObject } from 'react'
|
|
6
|
+
import { Logo } from './brand.tsx'
|
|
7
|
+
import { mainViewAgentSession } from './document-context.ts'
|
|
8
|
+
import { clamp, CONVERSATION_MAX, CONVERSATION_MIN, NARROW, RIGHTBAR_MAX_RATIO, RIGHTBAR_MIN, rightbarRoom, SIDE_COLLAPSE, SIDE_MAX, SIDE_MIN } from './layout.ts'
|
|
9
|
+
import type { LayoutActions, LayoutState, NarrowPane } from './layout.ts'
|
|
10
|
+
import { useClientMessages } from './messages.ts'
|
|
11
|
+
import { isSamePath } from './paths.ts'
|
|
12
|
+
import { RuntimeDiagnosticsNotice, WorkbenchRuntimeBoundary } from './runtime-diagnostics.tsx'
|
|
13
|
+
|
|
14
|
+
interface RootSlotProps {
|
|
15
|
+
useStore: DshSelectorHook<LayoutState>
|
|
16
|
+
useSessions: DshSelectorHook<{ byId: Record<string, { id: string, title?: string, cwd?: string, retainedBy: { mainView?: number } }> }>
|
|
17
|
+
usePanelInfo: DshSelectorHook<LayoutState['panelInfo']>
|
|
18
|
+
actions: LayoutActions
|
|
19
|
+
renderSlot(name: string, props?: Record<string, unknown>, options?: Record<string, unknown>): ReactNode
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RootLayoutOptions {
|
|
23
|
+
workbench: Workbench
|
|
24
|
+
openSettings(): void
|
|
25
|
+
/** Opens a DeepSeek Harness workspace in the main view, keeping the current layout. */
|
|
26
|
+
openDshWorkspace(id: string): Promise<void>
|
|
27
|
+
dshWorkspaces: DshObservable<{ items: readonly { workspaceId: string, path: string }[] }>
|
|
28
|
+
globalPanels: DshObservable<readonly DshSlotEntry[]>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type Geometry = ReturnType<typeof solveGeometry>
|
|
32
|
+
type Box = { left: number, top: number, width: number, height: number }
|
|
33
|
+
|
|
34
|
+
/** The component of the `root` slot: the root layout with the workbench of `options`. */
|
|
35
|
+
export function rootLayoutFor(options: RootLayoutOptions) {
|
|
36
|
+
return function PresmiumRootLayout(props: RootSlotProps) {
|
|
37
|
+
return (
|
|
38
|
+
<WorkbenchProvider workbench={options.workbench}>
|
|
39
|
+
<WorkbenchRuntimeBoundary workbench={options.workbench}><RootLayout {...props} {...options} /></WorkbenchRuntimeBoundary>
|
|
40
|
+
</WorkbenchProvider>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The columns' widths for the viewport: 0 for a column that is not shown. */
|
|
46
|
+
function solveGeometry(info: LayoutState['layoutInfo']) {
|
|
47
|
+
const viewport = info.viewportWidth
|
|
48
|
+
const narrow = viewport < NARROW
|
|
49
|
+
const collapsed = viewport < SIDE_COLLAPSE
|
|
50
|
+
const side = narrow ? 0 : (collapsed ? info.isSideExpanded : info.isSideOpen) ? info.sideWidth : 0
|
|
51
|
+
const conversation = narrow || !info.isConversationOpen ? 0 : collapsed ? Math.min(info.conversationWidth, Math.max(300, Math.round(viewport * 0.4))) : info.conversationWidth
|
|
52
|
+
const room = rightbarRoom(viewport, side, conversation, 3)
|
|
53
|
+
const besideChat = room >= RIGHTBAR_MIN ? room : rightbarRoom(viewport, 0, conversation, 2)
|
|
54
|
+
const available = besideChat >= RIGHTBAR_MIN || info.isRightbarOpen ? besideChat : rightbarRoom(viewport, 0, 0, 2)
|
|
55
|
+
const preference = info.rightbarWidth ?? Math.round(viewport * 0.3)
|
|
56
|
+
const rightbar = narrow || available < RIGHTBAR_MIN ? 0 : Math.min(available, clamp(preference, RIGHTBAR_MIN, viewport * RIGHTBAR_MAX_RATIO))
|
|
57
|
+
return { viewport, narrow, collapsed, side, conversation, rightbar, track: info.hasRightbarTrack ? rightbar : 0 }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Reports the frame's width to the layout store, at most once a frame. */
|
|
61
|
+
function useViewportWidth(frame: RefObject<HTMLDivElement>, actions: LayoutActions): void {
|
|
62
|
+
useLayoutEffect(() => {
|
|
63
|
+
const element = frame.current!
|
|
64
|
+
let raf: number | undefined
|
|
65
|
+
const measure = () => {
|
|
66
|
+
const width = element.getBoundingClientRect().width
|
|
67
|
+
if (width > 0) actions.setViewportWidth(width)
|
|
68
|
+
}
|
|
69
|
+
measure()
|
|
70
|
+
const observer = new ResizeObserver(() => {
|
|
71
|
+
raf ??= requestAnimationFrame(() => {
|
|
72
|
+
raf = undefined
|
|
73
|
+
measure()
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
observer.observe(element)
|
|
77
|
+
return () => {
|
|
78
|
+
observer.disconnect()
|
|
79
|
+
if (raf !== undefined) cancelAnimationFrame(raf)
|
|
80
|
+
}
|
|
81
|
+
}, [actions])
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The rightbar track's box in the frame: the overlay that draws the rightbar follows the resized track. */
|
|
85
|
+
function useRightbarBox(frame: RefObject<HTMLDivElement>, track: RefObject<HTMLDivElement>, dependencies: unknown[]): Box | undefined {
|
|
86
|
+
const [box, setBox] = useState<Box>()
|
|
87
|
+
useLayoutEffect(() => {
|
|
88
|
+
const element = track.current
|
|
89
|
+
if (!element) { setBox(undefined); return }
|
|
90
|
+
const measure = () => {
|
|
91
|
+
const rect = element.getBoundingClientRect()
|
|
92
|
+
const origin = frame.current!.getBoundingClientRect()
|
|
93
|
+
setBox(before => before?.left === rect.left - origin.left && before.top === rect.top - origin.top && before.width === rect.width && before.height === rect.height ? before : { left: rect.left - origin.left, top: rect.top - origin.top, width: rect.width, height: rect.height })
|
|
94
|
+
}
|
|
95
|
+
measure()
|
|
96
|
+
const observer = new ResizeObserver(measure)
|
|
97
|
+
observer.observe(element)
|
|
98
|
+
observer.observe(frame.current!)
|
|
99
|
+
return () => observer.disconnect()
|
|
100
|
+
}, dependencies)
|
|
101
|
+
return box
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Opens the workspace root's DeepSeek Harness workspace once at startup, unless the main view's session is already in it. */
|
|
105
|
+
function useOpenStartupWorkspace(workbench: Workbench, dshWorkspaces: RootLayoutOptions['dshWorkspaces'], openDshWorkspace: RootLayoutOptions['openDshWorkspace'], session: { id: string, cwd?: string } | undefined): void {
|
|
106
|
+
const workspaces = useSyncExternalStore(dshWorkspaces.subscribe, dshWorkspaces.getSnapshot).items
|
|
107
|
+
const [root, setRoot] = useState<string>()
|
|
108
|
+
const workspaceId = root ? workspaces.find(workspace => isSamePath(workspace.path, root))?.workspaceId : undefined
|
|
109
|
+
const opened = useRef(false)
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
let active = true
|
|
112
|
+
void workbench.client.readWorkbenchInfo().then(answer => { if (active) setRoot(answer.root) }).catch(() => {})
|
|
113
|
+
return () => { active = false }
|
|
114
|
+
}, [workbench])
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (!root || opened.current) return
|
|
117
|
+
if (session?.cwd && isSamePath(session.cwd, root)) {
|
|
118
|
+
opened.current = true
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
if (!workspaceId) return
|
|
122
|
+
opened.current = true
|
|
123
|
+
void openDshWorkspace(workspaceId).catch(() => { opened.current = false })
|
|
124
|
+
}, [root, workspaceId, openDshWorkspace, session?.id, session?.cwd])
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The page's title: the main view's session, else the open document. */
|
|
128
|
+
function useDocumentTitle(sessionTitle: string | undefined, documentTitle: string | undefined): void {
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
const title = sessionTitle ?? documentTitle
|
|
131
|
+
document.title = title ? `${title} — Presmium` : 'Presmium'
|
|
132
|
+
}, [sessionTitle, documentTitle])
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function RootLayout({ useStore, useSessions, usePanelInfo, actions, renderSlot, workbench, openSettings, openDshWorkspace, dshWorkspaces, globalPanels }: RootSlotProps & RootLayoutOptions) {
|
|
136
|
+
const info = useStore(state => state.layoutInfo)
|
|
137
|
+
const activePanel = usePanelInfo(panel => panel.activePanelId)
|
|
138
|
+
const t = useClientMessages(workbench)
|
|
139
|
+
const document_ = useWorkbenchState(state => state.document)
|
|
140
|
+
const retainedDocument = useRef(false)
|
|
141
|
+
if (document_ !== undefined) retainedDocument.current = true
|
|
142
|
+
const documentTitle = useWorkbenchState(state => state.documents?.documents.find(doc => doc.dir === state.document)?.title)
|
|
143
|
+
const session = useSessions(state => mainViewAgentSession(state.byId))
|
|
144
|
+
const frame = useRef<HTMLDivElement>(null)
|
|
145
|
+
const rightbarTrack = useRef<HTMLDivElement>(null)
|
|
146
|
+
const [helpOpen, setHelpOpen] = useState(false)
|
|
147
|
+
const geometry = solveGeometry(info)
|
|
148
|
+
const { narrow, collapsed } = geometry
|
|
149
|
+
const conversationPanel = usePanelRef()
|
|
150
|
+
const sidePanelRef = usePanelRef()
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (narrow || info.isConversationOpen) conversationPanel.current?.expand()
|
|
153
|
+
else conversationPanel.current?.collapse()
|
|
154
|
+
}, [narrow, info.isConversationOpen, conversationPanel])
|
|
155
|
+
const sideShown = !narrow && (collapsed ? info.isSideExpanded : info.isSideOpen)
|
|
156
|
+
useLayoutEffect(() => {
|
|
157
|
+
if (sideShown || (narrow && info.narrowPane === 'sessions')) sidePanelRef.current?.expand()
|
|
158
|
+
else sidePanelRef.current?.collapse()
|
|
159
|
+
}, [sideShown, narrow, info.narrowPane, sidePanelRef])
|
|
160
|
+
|
|
161
|
+
const rightbarBox = useRightbarBox(frame, rightbarTrack, [info.isRightbarOpen, info.hasRightbarTrack, info.isRightbarFullscreen, info.isConversationOpen, sideShown, narrow])
|
|
162
|
+
useViewportWidth(frame, actions)
|
|
163
|
+
|
|
164
|
+
const opened = useRef({ document: document_, narrow })
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
// Entering the single-pane layout keeps the document the author was viewing on desktop.
|
|
167
|
+
// Once narrow, explicit navigation to history, chat or the document list remains the user's choice.
|
|
168
|
+
const documentChanged = document_ !== opened.current.document
|
|
169
|
+
const enteredNarrow = narrow && !opened.current.narrow
|
|
170
|
+
if (document_ !== undefined && narrow && (documentChanged || enteredNarrow)) actions.showNarrowPane('preview')
|
|
171
|
+
if (document_ === undefined && narrow && info.narrowPane === 'preview') actions.showNarrowPane('documents')
|
|
172
|
+
opened.current = { document: document_, narrow }
|
|
173
|
+
}, [document_, narrow, actions, info.narrowPane])
|
|
174
|
+
|
|
175
|
+
useOpenStartupWorkspace(workbench, dshWorkspaces, openDshWorkspace, session)
|
|
176
|
+
useDocumentTitle(session?.title, documentTitle)
|
|
177
|
+
|
|
178
|
+
const sidebar = useMemo(() => renderSlot('sidebar', { collapsed: false, width: narrow ? geometry.viewport : Math.max(geometry.side, 264) }), [renderSlot, narrow, geometry.viewport, geometry.side])
|
|
179
|
+
const conversation = useMemo(() => renderSlot('main', {}, { entryKey: 'conversation' }), [renderSlot])
|
|
180
|
+
const overlays = useMemo(() => renderSlot('shell.overlay', {}), [renderSlot])
|
|
181
|
+
const bottom = useMemo(() => renderSlot('shell.bottom', {}), [renderSlot])
|
|
182
|
+
const panel = activePanel !== null ? renderSlot('main', {}, { entryKey: activePanel }) : null
|
|
183
|
+
const rightbar = renderSlot('rightbar', { width: rightbarBox?.width ?? geometry.rightbar, viewportWidth: geometry.viewport, canShow: geometry.rightbar > 0 })
|
|
184
|
+
|
|
185
|
+
const pane: NarrowPane = narrow ? info.narrowPane : 'documents'
|
|
186
|
+
const documentShown = panel === null && document_ !== undefined && (!narrow || pane === 'preview')
|
|
187
|
+
const centre = <>
|
|
188
|
+
{panel !== null && <div className="pmw-frame__panel" data-pmw-slot={`main:${activePanel}`}>{panel}</div>}
|
|
189
|
+
{retainedDocument.current && <div className="pmw-frame__document" hidden={!documentShown}>
|
|
190
|
+
<DocumentView active={documentShown} framed />
|
|
191
|
+
</div>}
|
|
192
|
+
{panel === null && (document_ === undefined || (narrow && pane === 'documents')) && <HomePanel />}
|
|
193
|
+
</>
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<div
|
|
197
|
+
ref={frame}
|
|
198
|
+
className="pmw-frame pm-ui"
|
|
199
|
+
data-narrow={narrow || undefined}
|
|
200
|
+
data-collapsed={collapsed || undefined}
|
|
201
|
+
data-pane={narrow ? pane : undefined}
|
|
202
|
+
data-rightbar-fullscreen={info.isRightbarFullscreen || undefined}
|
|
203
|
+
>
|
|
204
|
+
<a className="pmw-frame__skip" href="#pmw-centre">{t.skip}</a>
|
|
205
|
+
<AppBar workbench={workbench} info={info} actions={actions} activePanel={activePanel} globalPanels={globalPanels} pane={pane} narrow={narrow} openSettings={openSettings} openHelp={() => setHelpOpen(true)} />
|
|
206
|
+
|
|
207
|
+
<RuntimeDiagnosticsNotice workbench={workbench} />
|
|
208
|
+
<Group className="pmw-frame__panels" disabled={narrow} onLayoutChanged={(layout, meta) => {
|
|
209
|
+
if (!meta.isUserInteraction) return
|
|
210
|
+
const width = info.viewportWidth
|
|
211
|
+
if (layout.sessions !== undefined) actions.setSideWidth(layout.sessions * width / 100)
|
|
212
|
+
if (layout.sessions === 0 && sideShown) actions.toggleSidebar()
|
|
213
|
+
if (layout.conversation !== undefined) {
|
|
214
|
+
actions.setConversationOpen(layout.conversation > 0)
|
|
215
|
+
if (layout.conversation > 0) actions.setConversationWidth(layout.conversation * width / 100)
|
|
216
|
+
}
|
|
217
|
+
if (layout.rightbar !== undefined) actions.setRightbarWidth(layout.rightbar * width / 100)
|
|
218
|
+
}}>
|
|
219
|
+
{/* Collapsed, not hidden, on a wide screen: a hidden panel's box sits at the page's corner, and react-resizable-panels takes the gap from it to the centre (the activity bar) for a handle. */}
|
|
220
|
+
<Panel id="sessions" panelRef={sidePanelRef} groupResizeBehavior="preserve-pixel-size" collapsible collapsedSize={0} defaultSize={info.sideWidth} minSize={narrow ? 0 : SIDE_MIN} maxSize={SIDE_MAX} className="pmw-frame__side-panel" hidden={narrow && pane !== 'sessions'}>
|
|
221
|
+
<div className="pmw-frame__sidebar" data-pmw-slot="sidebar" data-shown={(narrow ? pane === 'sessions' : sideShown) || undefined}>{sidebar}</div>
|
|
222
|
+
</Panel>
|
|
223
|
+
{sideShown && <Separator className="pmw-frame__handle" data-side="side" aria-label={t.resize} />}
|
|
224
|
+
<Panel id="centre" minSize={narrow ? 0 : 200} className="pmw-frame__centre-panel" hidden={narrow && (pane === 'sessions' || pane === 'conversation') && panel === null}>
|
|
225
|
+
<main id="pmw-centre" className="pmw-frame__centre" aria-label={documentTitle ?? t.centre}>{centre}</main>
|
|
226
|
+
</Panel>
|
|
227
|
+
{!narrow && info.isRightbarOpen && !info.isRightbarFullscreen && geometry.track > 0 && (
|
|
228
|
+
<>
|
|
229
|
+
<Separator className="pmw-frame__handle" data-side="rightbar" aria-label={t.resize} />
|
|
230
|
+
<Panel id="rightbar" groupResizeBehavior="preserve-pixel-size" defaultSize={geometry.track} minSize={RIGHTBAR_MIN} maxSize="70%"><div className="pmw-frame__rightbar-track" ref={rightbarTrack} /></Panel>
|
|
231
|
+
</>
|
|
232
|
+
)}
|
|
233
|
+
{!narrow && info.isConversationOpen && <Separator className="pmw-frame__handle" data-side="conversation" aria-label={t.resize} />}
|
|
234
|
+
<Panel id="conversation" panelRef={conversationPanel} groupResizeBehavior="preserve-pixel-size" collapsible collapsedSize={0} defaultSize={info.conversationWidth} minSize={narrow ? 0 : CONVERSATION_MIN} maxSize={narrow ? '100%' : CONVERSATION_MAX} hidden={narrow && pane !== 'conversation'}>
|
|
235
|
+
<aside className="pmw-frame__conversation" aria-label={t.conversation} data-pmw-slot="main:conversation" data-shown={narrow ? pane === 'conversation' || undefined : info.isConversationOpen || undefined} hidden={!narrow && !info.isConversationOpen}>
|
|
236
|
+
{conversation}
|
|
237
|
+
</aside>
|
|
238
|
+
</Panel>
|
|
239
|
+
</Group>
|
|
240
|
+
<div className="pmw-frame__rightbar pmw-frame__rightbar-overlay" data-pmw-slot="rightbar" style={info.isRightbarFullscreen ? { right: 0, width: geometry.viewport } : rightbarBox ?? { right: geometry.conversation, width: geometry.track }}>{rightbar}</div>
|
|
241
|
+
|
|
242
|
+
<div className="pmw-frame__bottom" data-pmw-slot="shell.bottom">{bottom}</div>
|
|
243
|
+
<div className="pmw-frame__overlay" data-pmw-slot="shell.overlay">{overlays}</div>
|
|
244
|
+
|
|
245
|
+
<PmDialog open={helpOpen} label={t.help} closeLabel={t.close} onClose={() => setHelpOpen(false)}>
|
|
246
|
+
<ul className="pmw-help">{t.helpItems.map(item => <li key={item}>{item.replace('{undo}', SHORTCUTS.undo).replace('{redo}', SHORTCUTS.redo)}</li>)}</ul>
|
|
247
|
+
</PmDialog>
|
|
248
|
+
</div>
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
interface AppBarProps {
|
|
253
|
+
workbench: Workbench
|
|
254
|
+
info: LayoutState['layoutInfo']
|
|
255
|
+
actions: LayoutActions
|
|
256
|
+
activePanel: string | null
|
|
257
|
+
globalPanels: RootLayoutOptions['globalPanels']
|
|
258
|
+
pane: NarrowPane
|
|
259
|
+
narrow: boolean
|
|
260
|
+
openSettings(): void
|
|
261
|
+
openHelp(): void
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The bar across the top, as an editor has: the brand and where the author is (the documents, the open document or a
|
|
266
|
+
* global panel), then operations waiting for the author's approval, the AI assistant, more (plugins, automation), settings and help. On a narrow screen the places
|
|
267
|
+
* are the panes it switches between.
|
|
268
|
+
*/
|
|
269
|
+
function AppBar({ workbench, info, actions, activePanel, globalPanels, pane, narrow, openSettings, openHelp }: AppBarProps) {
|
|
270
|
+
const t = useClientMessages(workbench)
|
|
271
|
+
const document_ = useWorkbenchState(state => state.document)
|
|
272
|
+
const documentTitle = useWorkbenchState(state => state.documents?.documents.find(doc => doc.dir === state.document)?.title)
|
|
273
|
+
const working = useWorkbenchState(state => Boolean(state.review?.running))
|
|
274
|
+
const panels = useSyncExternalStore(globalPanels.subscribe, globalPanels.getSnapshot)
|
|
275
|
+
const chatShown = narrow ? pane === 'conversation' || pane === 'sessions' : info.isConversationOpen
|
|
276
|
+
const more = panels.map(({ options }) => ({ id: options.id!, label: typeof options.label === 'function' ? options.label() : options.label ?? options.id! }))
|
|
277
|
+
const panelLabel = activePanel === null ? undefined : more.find(item => item.id === activePanel)?.label
|
|
278
|
+
const atDocuments = activePanel === null && (narrow ? pane === 'documents' : document_ === undefined)
|
|
279
|
+
const atDocument = activePanel === null && document_ !== undefined && (!narrow || pane === 'preview')
|
|
280
|
+
|
|
281
|
+
const showDocuments = () => {
|
|
282
|
+
actions.selectPanel(null)
|
|
283
|
+
workbench.closeDocument()
|
|
284
|
+
if (narrow) actions.showNarrowPane('documents')
|
|
285
|
+
}
|
|
286
|
+
const showDocument = () => {
|
|
287
|
+
actions.selectPanel(null)
|
|
288
|
+
if (narrow) actions.showNarrowPane('preview')
|
|
289
|
+
}
|
|
290
|
+
const toggleAssistant = () => {
|
|
291
|
+
if (narrow) actions.showNarrowPane(chatShown ? (document_ === undefined ? 'documents' : 'preview') : 'conversation')
|
|
292
|
+
else actions.setConversationOpen(!info.isConversationOpen)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<nav className="pmw-appbar" aria-label={t.activity}>
|
|
297
|
+
<span className="pmw-appbar__brand"><Logo /><span className="pmw-brand-name">{t.product}</span></span>
|
|
298
|
+
<ol className="pmw-appbar__places">
|
|
299
|
+
<li>
|
|
300
|
+
<button type="button" className="pmw-appbar__place" data-activity="documents" title={t.documents} aria-current={atDocuments ? 'page' : undefined} onClick={showDocuments}>
|
|
301
|
+
<PmIcon name="folder" /><span className="pmw-appbar__label">{t.documents}</span>
|
|
302
|
+
</button>
|
|
303
|
+
</li>
|
|
304
|
+
{document_ !== undefined && (
|
|
305
|
+
<li>
|
|
306
|
+
<button type="button" className="pmw-appbar__place pmw-appbar__title" data-activity="document" title={documentTitle ?? t.document} aria-current={atDocument ? 'page' : undefined} onClick={showDocument}>
|
|
307
|
+
<PmIcon name="document" /><span className="pmw-appbar__label">{documentTitle ?? t.document}</span>
|
|
308
|
+
</button>
|
|
309
|
+
</li>
|
|
310
|
+
)}
|
|
311
|
+
{panelLabel && <li><span className="pmw-appbar__place" aria-current="page"><span className="pmw-appbar__label">{panelLabel}</span></span></li>}
|
|
312
|
+
</ol>
|
|
313
|
+
<span className="pmw-spacer" />
|
|
314
|
+
<span className="pmw-appbar__approvals" data-activity="approvals"><WorkbenchApprovals density={narrow ? 'touch' : 'compact'} /></span>
|
|
315
|
+
<button type="button" className="pmw-appbar__button pmw-appbar__assistant" data-activity="assistant" title={t.assistant} aria-pressed={chatShown} onClick={toggleAssistant}>
|
|
316
|
+
<PmIcon name="sparkle" /><span className="pmw-appbar__label">{t.assistant}</span>
|
|
317
|
+
{working && <span className="pmw-appbar__busy" aria-hidden="true" />}
|
|
318
|
+
</button>
|
|
319
|
+
<span className="pmw-appbar__divider" aria-hidden="true" />
|
|
320
|
+
{more.length > 0 && (
|
|
321
|
+
<span className="pmw-appbar__more" data-activity="more">
|
|
322
|
+
<PmMenu label={t.more} items={more} onSelect={(id) => {
|
|
323
|
+
actions.selectPanel(activePanel === id ? null : id)
|
|
324
|
+
if (narrow) actions.showNarrowPane('documents')
|
|
325
|
+
}} />
|
|
326
|
+
</span>
|
|
327
|
+
)}
|
|
328
|
+
<button type="button" className="pmw-appbar__button" data-activity="settings" aria-label={t.settings} title={t.settings} onClick={openSettings}><PmIcon name="sliders" /></button>
|
|
329
|
+
<button type="button" className="pmw-appbar__button" data-activity="help" aria-label={t.help} title={t.help} onClick={openHelp}><PmIcon name="help" /></button>
|
|
330
|
+
</nav>
|
|
331
|
+
)
|
|
332
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { selectedDocument } from '@lincyaw/workbench'
|
|
2
|
+
import type { AgentAnswer, Workbench } from '@lincyaw/workbench'
|
|
3
|
+
import type { AssistantSetup } from './assistant-setup.tsx'
|
|
4
|
+
import { mainViewAgentSession, postDocumentContext } from './document-context.ts'
|
|
5
|
+
import { CLIENT_MESSAGES } from './messages.ts'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Lets the workbench hand requests to the main view's agent session: the author's "Rewrite with AI" and comments, and
|
|
9
|
+
* the repair of the selected document's diagnostics. Each is prompted on the session after its document context is
|
|
10
|
+
* set, and the conversation is shown. Without a usable model nothing is sent: the conversation is shown, where the
|
|
11
|
+
* composer offers to set one up, and the request answers with that in the author's terms.
|
|
12
|
+
*/
|
|
13
|
+
export function registerAgentRequests(ctx: DshClientContext, workbench: Workbench, setup: AssistantSetup): void {
|
|
14
|
+
ctx.inject(['sessions', 'layout'], inner => inner.effect(() => {
|
|
15
|
+
let active: DshSessionBinding | undefined
|
|
16
|
+
let offRequests: (() => void) | undefined
|
|
17
|
+
const update = () => {
|
|
18
|
+
const row = mainViewAgentSession(inner.sessions.list.getSnapshot().byId)
|
|
19
|
+
const binding = row && inner.sessions.binding(row.id)
|
|
20
|
+
if (binding === active) return
|
|
21
|
+
offRequests?.()
|
|
22
|
+
offRequests = undefined
|
|
23
|
+
active = binding
|
|
24
|
+
if (!row || !binding) return
|
|
25
|
+
const id = row.id
|
|
26
|
+
const t = () => CLIENT_MESSAGES[workbench.state.locale]
|
|
27
|
+
const prompt = async (document: string | null, text: string): Promise<AgentAnswer> => {
|
|
28
|
+
const isCurrent = () => active === binding && inner.sessions.binding(id) === binding
|
|
29
|
+
&& mainViewAgentSession(inner.sessions.list.getSnapshot().byId)?.id === id
|
|
30
|
+
&& selectedDocument(workbench.state) === document
|
|
31
|
+
if (document === null || !isCurrent()) throw new Error(t().repairStale)
|
|
32
|
+
if (setup.readiness.getSnapshot() === 'missing') {
|
|
33
|
+
inner.layout.selectPanel(null)
|
|
34
|
+
throw new Error(t().needsModelRequest)
|
|
35
|
+
}
|
|
36
|
+
const response = await postDocumentContext(id, document)
|
|
37
|
+
if (!response.ok) throw new Error(await response.text() || `HTTP ${response.status}`)
|
|
38
|
+
if (!isCurrent()) throw new Error(t().repairStale)
|
|
39
|
+
const submission = binding.session.beginSubmission({ mode: 'queue', text, attachments: [] })
|
|
40
|
+
try {
|
|
41
|
+
const result = await binding.session.prompt([{ type: 'text', text }], 'queue', undefined, submission.requestId)
|
|
42
|
+
if (!result.ok) throw new Error(result.error.message)
|
|
43
|
+
inner.layout.selectPanel(null)
|
|
44
|
+
return { session: id, request: submission.requestId }
|
|
45
|
+
} catch (error) {
|
|
46
|
+
submission.abandon()
|
|
47
|
+
throw error
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const offRepair = workbench.onRepairDiagnostics(async (diagnostics) => {
|
|
51
|
+
const document = selectedDocument(workbench.state)
|
|
52
|
+
if (diagnostics.some(diagnostic => diagnostic.document !== document)) throw new Error(t().repairStale)
|
|
53
|
+
const text = `${t().repairRequest}\n${JSON.stringify(diagnostics.slice(0, 20))}`
|
|
54
|
+
const answer = await prompt(document, text)
|
|
55
|
+
workbench.telemetry.record('agent.ask', { 'presmium.document': document ?? undefined, 'presmium.ask.kind': 'repair', 'presmium.ask.text': text, 'presmium.outcome': 'sent', 'dsh.session.id': answer.session, 'dsh.request.id': answer.request })
|
|
56
|
+
})
|
|
57
|
+
const offAsk = workbench.onAgentRequest(request => prompt(selectedDocument(workbench.state), request.text))
|
|
58
|
+
offRequests = () => { offRepair(); offAsk() }
|
|
59
|
+
}
|
|
60
|
+
const off = inner.sessions.list.subscribe(update)
|
|
61
|
+
update()
|
|
62
|
+
return () => { off(); offRequests?.() }
|
|
63
|
+
}, 'presmium: hand the author\'s requests to the current session'))
|
|
64
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { PmButton, PmIcon, WorkbenchProvider } from '@lincyaw/workbench'
|
|
2
|
+
import type { Workbench } from '@lincyaw/workbench'
|
|
3
|
+
import { ASSISTANT_SLOTS, MODEL_SETTINGS } from './contract.ts'
|
|
4
|
+
import { useClientMessages } from './messages.ts'
|
|
5
|
+
import type { ClientMessages } from './messages.ts'
|
|
6
|
+
import { createObservable } from './observable.ts'
|
|
7
|
+
import { openModelSettings } from './settings-opener.ts'
|
|
8
|
+
|
|
9
|
+
/** Whether the AI assistant has a model it can run on: unknown until Harness's model catalog has answered. */
|
|
10
|
+
export type AssistantReadiness = 'unknown' | 'ready' | 'missing'
|
|
11
|
+
|
|
12
|
+
/** Harness's model catalog (`session/modelCatalog`), as far as it is read here. */
|
|
13
|
+
interface ModelCatalogAnswer {
|
|
14
|
+
ok: boolean
|
|
15
|
+
value?: { routableProviders?: readonly string[] }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The end of a failed turn, as the `turn-error` chat node carries it. */
|
|
19
|
+
interface TurnErrorNode {
|
|
20
|
+
location?: { turn?: { end?: { data?: { reason?: { kind?: string, error?: { message?: string, code?: string } } } } } }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Author-facing kinds of a failed turn, by the failure's stable code. */
|
|
24
|
+
type FailureKind = 'needsModel' | 'keyRejected' | 'quota' | 'busy' | 'tooLong' | 'stopped' | 'other'
|
|
25
|
+
|
|
26
|
+
const FAILURES: Record<string, FailureKind> = {
|
|
27
|
+
MISSING_CREDENTIAL: 'needsModel',
|
|
28
|
+
NO_ADAPTER: 'needsModel',
|
|
29
|
+
AUTH: 'keyRejected',
|
|
30
|
+
INVALID_CREDENTIAL: 'keyRejected',
|
|
31
|
+
QUOTA: 'quota',
|
|
32
|
+
ACCOUNT_QUOTA: 'quota',
|
|
33
|
+
RATE_LIMIT: 'busy',
|
|
34
|
+
OVERLOADED: 'busy',
|
|
35
|
+
CONTEXT_WINDOW_EXCEEDED: 'tooLong',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The kinds whose recovery is in Settings → Models. */
|
|
39
|
+
const SETUP_KINDS = new Set<FailureKind>(['needsModel', 'keyRejected', 'quota'])
|
|
40
|
+
|
|
41
|
+
function failureOf(node: TurnErrorNode | undefined): { kind: FailureKind, message?: string, code?: string } {
|
|
42
|
+
const reason = node?.location?.turn?.end?.data?.reason
|
|
43
|
+
const error = reason?.error
|
|
44
|
+
if (reason && reason.kind !== 'error') return { kind: 'stopped', message: error?.message, code: error?.code }
|
|
45
|
+
return { kind: (error?.code && FAILURES[error.code]) || 'other', message: error?.message, code: error?.code }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function failureCopy(t: ClientMessages, kind: FailureKind): { title: string, body: string } {
|
|
49
|
+
switch (kind) {
|
|
50
|
+
case 'needsModel': return { title: t.needsModelTitle, body: t.needsModelTurn }
|
|
51
|
+
case 'keyRejected': return { title: t.keyRejectedTitle, body: t.keyRejected }
|
|
52
|
+
case 'quota': return { title: t.quotaTitle, body: t.quota }
|
|
53
|
+
case 'busy': return { title: t.turnFailedTitle, body: t.busy }
|
|
54
|
+
case 'tooLong': return { title: t.turnFailedTitle, body: t.tooLong }
|
|
55
|
+
case 'stopped': return { title: t.turnStoppedTitle, body: t.turnStopped }
|
|
56
|
+
case 'other': return { title: t.turnFailedTitle, body: t.turnFailed }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Keeps an author from sending a request the AI cannot run, and says in their terms why a turn failed.
|
|
62
|
+
*
|
|
63
|
+
* Readiness comes from Harness's model catalog: with no routable provider (no model set up, or no key for any) every
|
|
64
|
+
* turn would fail, so the composer is taken over by a notice whose one action opens Settings → Models. The editor stays
|
|
65
|
+
* mounted beneath the takeover, so a request typed before it appeared is still there afterwards. Failed turns are shown
|
|
66
|
+
* with author copy (and the setup action where the fix is in Models) instead of the provider's diagnostic, which stays
|
|
67
|
+
* behind a disclosure.
|
|
68
|
+
*/
|
|
69
|
+
export function registerAssistantSetup(ctx: DshClientContext, workbench: Workbench): AssistantSetup {
|
|
70
|
+
const readiness = createObservable<AssistantReadiness>('unknown')
|
|
71
|
+
let refresh = () => {}
|
|
72
|
+
|
|
73
|
+
ctx.inject(['remote', 'remote.session'], inner => inner.effect(() => {
|
|
74
|
+
let generation = 0
|
|
75
|
+
let disposed = false
|
|
76
|
+
const load = () => {
|
|
77
|
+
const current = ++generation
|
|
78
|
+
void inner.remote.session.modelCatalog().then((answer: ModelCatalogAnswer) => {
|
|
79
|
+
if (disposed || current !== generation || !answer.ok) return
|
|
80
|
+
const next = answer.value?.routableProviders?.length ? 'ready' : 'missing'
|
|
81
|
+
if (next !== readiness.getSnapshot()) readiness.set(next)
|
|
82
|
+
}, () => {})
|
|
83
|
+
}
|
|
84
|
+
refresh = load
|
|
85
|
+
load()
|
|
86
|
+
const offs = MODEL_SETTINGS.events.map(name => inner.on(name, load))
|
|
87
|
+
const whileMissing = () => {
|
|
88
|
+
if (readiness.getSnapshot() !== 'ready') load()
|
|
89
|
+
}
|
|
90
|
+
window.addEventListener('focus', load)
|
|
91
|
+
const timer = window.setInterval(whileMissing, MODEL_SETTINGS.pollMs)
|
|
92
|
+
return () => {
|
|
93
|
+
disposed = true
|
|
94
|
+
refresh = () => {}
|
|
95
|
+
for (const off of offs) off()
|
|
96
|
+
window.removeEventListener('focus', load)
|
|
97
|
+
window.clearInterval(timer)
|
|
98
|
+
}
|
|
99
|
+
}, 'presmium: the assistant\'s model readiness'))
|
|
100
|
+
|
|
101
|
+
// Before the settings shell has offered `openSection`, the app bar's own Settings button opens Settings.
|
|
102
|
+
const openModels = () => {
|
|
103
|
+
if (!openModelSettings()) document.querySelector<HTMLElement>('[data-activity="settings"]')?.click()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const [composer, chatNode] = ASSISTANT_SLOTS
|
|
107
|
+
ctx.effect(() => ctx.slots.inject(composer, () => {
|
|
108
|
+
let takeover: (() => void) | undefined
|
|
109
|
+
const update = () => {
|
|
110
|
+
const missing = readiness.getSnapshot() === 'missing'
|
|
111
|
+
if (missing && !takeover) {
|
|
112
|
+
takeover = ctx.slots.register({ name: composer, select: () => MISSING }, () => <NeedsModel workbench={workbench} openModels={openModels} />)
|
|
113
|
+
} else if (!missing && takeover) {
|
|
114
|
+
takeover()
|
|
115
|
+
takeover = undefined
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
update()
|
|
119
|
+
const off = readiness.subscribe(update)
|
|
120
|
+
return () => {
|
|
121
|
+
off()
|
|
122
|
+
takeover?.()
|
|
123
|
+
}
|
|
124
|
+
}), 'presmium: the composer explains a missing model')
|
|
125
|
+
|
|
126
|
+
ctx.effect(() => ctx.slots.inject(chatNode.name, () => ctx.slots.register({ name: chatNode.name, key: chatNode.key, priority: -1 }, ({ node }: { node?: TurnErrorNode }) => (
|
|
127
|
+
<TurnFailure workbench={workbench} node={node} openModels={openModels} />
|
|
128
|
+
))), 'presmium: failed turns in the author\'s terms')
|
|
129
|
+
|
|
130
|
+
return { readiness, refresh: () => refresh(), openModels }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface AssistantSetup {
|
|
134
|
+
readiness: { getSnapshot(): AssistantReadiness, subscribe(listener: () => void): () => void }
|
|
135
|
+
refresh(): void
|
|
136
|
+
openModels(): void
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const MISSING = Object.freeze({ reason: 'no-model' })
|
|
140
|
+
|
|
141
|
+
function NeedsModel({ workbench, openModels }: { workbench: Workbench, openModels(): void }) {
|
|
142
|
+
const t = useClientMessages(workbench)
|
|
143
|
+
return (
|
|
144
|
+
<WorkbenchProvider workbench={workbench}>
|
|
145
|
+
<div className="pmw-assistant-setup pm-ui" role="status" data-pmw-assistant-setup="needs-model">
|
|
146
|
+
<span className="pmw-assistant-setup__icon"><PmIcon name="sliders" /></span>
|
|
147
|
+
<div className="pmw-assistant-setup__copy">
|
|
148
|
+
<p className="pmw-assistant-setup__title">{t.needsModelTitle}</p>
|
|
149
|
+
<p>{t.needsModel}</p>
|
|
150
|
+
</div>
|
|
151
|
+
<div className="pmw-assistant-setup__actions">
|
|
152
|
+
<PmButton variant="primary" onClick={openModels}>{t.models}</PmButton>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
</WorkbenchProvider>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function TurnFailure({ workbench, node, openModels }: { workbench: Workbench, node?: TurnErrorNode, openModels(): void }) {
|
|
160
|
+
const t = useClientMessages(workbench)
|
|
161
|
+
const failure = failureOf(node)
|
|
162
|
+
const copy = failureCopy(t, failure.kind)
|
|
163
|
+
return (
|
|
164
|
+
<WorkbenchProvider workbench={workbench}>
|
|
165
|
+
<div className="pmw-turn-failure pm-ui" role="status" data-pmw-turn-failure={failure.kind}>
|
|
166
|
+
<span className="pmw-turn-failure__mark" aria-hidden="true"><PmIcon name="warning" /></span>
|
|
167
|
+
<div className="pmw-turn-failure__copy">
|
|
168
|
+
<p className="pmw-turn-failure__title">{copy.title}</p>
|
|
169
|
+
<p>{copy.body}</p>
|
|
170
|
+
{SETUP_KINDS.has(failure.kind) && (
|
|
171
|
+
<div className="pmw-turn-failure__actions">
|
|
172
|
+
<PmButton small onClick={openModels}>{failure.kind === 'needsModel' ? t.models : t.checkModels}</PmButton>
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
{(failure.message || failure.code) && (
|
|
176
|
+
<details className="pmw-turn-failure__details">
|
|
177
|
+
<summary>{t.failureDetails}</summary>
|
|
178
|
+
<p>{[failure.message, failure.code].filter(Boolean).join(' · ')}</p>
|
|
179
|
+
</details>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
</WorkbenchProvider>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { AUTHOR_DEFAULTS, TECHNICAL_SETTINGS } from './contract.ts'
|
|
2
|
+
|
|
3
|
+
const Hidden = () => null
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Gives an author the workbench's defaults for Harness's display preferences and keeps Harness's technical rows out of
|
|
7
|
+
* Settings → General.
|
|
8
|
+
*
|
|
9
|
+
* The defaults are saved through Harness's settings forms, in the profile's own layer, for each field the author has not
|
|
10
|
+
* chosen yet; a launch patch would outrank that layer and make the forms refuse the author's later choices.
|
|
11
|
+
*
|
|
12
|
+
* While the coding view (`ui-settings.enabled`) is off, each technical row is shadowed by an empty cell under its own id,
|
|
13
|
+
* the slots' public way to replace a shipped cell. Turning the coding view on removes the shadows and the rows come back.
|
|
14
|
+
*/
|
|
15
|
+
export function registerAuthorSettings(ctx: DshClientContext): void {
|
|
16
|
+
ctx.inject(['configForms'], inner => {
|
|
17
|
+
for (const [namespace, defaults] of Object.entries(AUTHOR_DEFAULTS)) {
|
|
18
|
+
inner.effect(() => inner.configForms.whileServed([namespace], () => {
|
|
19
|
+
const form = inner.configForms.get(namespace)
|
|
20
|
+
let seeded = false
|
|
21
|
+
const seed = () => {
|
|
22
|
+
const snapshot = form.getSnapshot()
|
|
23
|
+
if (seeded || snapshot.status !== 'ready' || !snapshot.writable || snapshot.revision === undefined) return
|
|
24
|
+
seeded = true
|
|
25
|
+
const chosen = (snapshot.user ?? {}) as Record<string, unknown>
|
|
26
|
+
void (async () => {
|
|
27
|
+
for (const [field, value] of Object.entries(defaults)) {
|
|
28
|
+
if (!(field in chosen)) await form.set(field, value).catch(() => false)
|
|
29
|
+
}
|
|
30
|
+
})()
|
|
31
|
+
}
|
|
32
|
+
const off = form.subscribe(seed)
|
|
33
|
+
seed()
|
|
34
|
+
return off
|
|
35
|
+
}), `presmium: author defaults for ${namespace}`)
|
|
36
|
+
}
|
|
37
|
+
const { slot, ids } = TECHNICAL_SETTINGS
|
|
38
|
+
inner.effect(() => inner.slots.inject(slot, () => {
|
|
39
|
+
const enabled = inner.configForms.developerTools.enabled
|
|
40
|
+
let shadows: (() => void)[] = []
|
|
41
|
+
const update = () => {
|
|
42
|
+
if (enabled.getSnapshot()) {
|
|
43
|
+
for (const shadow of shadows) shadow()
|
|
44
|
+
shadows = []
|
|
45
|
+
} else if (!shadows.length) {
|
|
46
|
+
shadows = ids.map(id => inner.slots.register({ name: slot, id, priority: -1 }, Hidden))
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
update()
|
|
50
|
+
const off = enabled.subscribe(update)
|
|
51
|
+
return () => {
|
|
52
|
+
off()
|
|
53
|
+
for (const shadow of shadows) shadow()
|
|
54
|
+
}
|
|
55
|
+
}), 'presmium: technical settings behind the coding view')
|
|
56
|
+
})
|
|
57
|
+
}
|