@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,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
+ }