@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
@@ -1,13 +1,28 @@
1
- import { localeOf, Workbench } from '@lincyaw/workbench'
1
+ import { connectAuthoringClient, localeOf, Workbench, workspaceClient } from '@lincyaw/workbench'
2
2
  import type { Locale } from '@lincyaw/workbench'
3
- import { brand } from './brand.tsx'
4
- import { chrome } from './chrome.tsx'
5
- import { CLIENT_ID, ROOT_CHILDREN, UI_LAYOUT } from './contract.ts'
6
- import { FALLBACK_PANEL, fallback } from './fallback.tsx'
7
- import { frameFor } from './Frame.tsx'
3
+ import { workspaceSubscriptions } from './workspace-events.ts'
4
+ import { registerAuthorSettings } from './author-settings.ts'
5
+ import { registerSpeechSettings } from './speech-settings.tsx'
6
+ import { openSpeechSettings } from './settings-opener.ts'
7
+ import { registerBrand } from './brand.tsx'
8
+ import { registerMascot } from './mascot.tsx'
9
+ import { CLIENT_ID, isBootEntryLoaded, ROOT_CHILDREN, UI_LAYOUT, UI_SIDEBAR } from './contract.ts'
10
+ import { syncDocumentContext } from './document-context.ts'
11
+ import { FALLBACK_PANEL, registerFallbackLayout } from './fallback.tsx'
8
12
  import { createLayoutStore, LayoutController } from './layout.ts'
9
- import { sourceLinks } from './source.tsx'
10
- import { followScheme, ThemePresenter, TOKENS } from './theme.ts'
13
+ import { connectLaunchTarget } from './launch-target.ts'
14
+ import { CLIENT_MESSAGES } from './messages.ts'
15
+ import { registerNavigationShell } from './navigation-shell.tsx'
16
+ import { createObservable } from './observable.ts'
17
+ import { isSamePath } from './paths.ts'
18
+ import { registerAgentRequests } from './agent-requests.ts'
19
+ import { registerAssistantSetup } from './assistant-setup.tsx'
20
+ import { rootLayoutFor } from './RootLayout.tsx'
21
+ import { beginRootReload, restoreRootReturnPoint, saveRootReturnPoint } from './root-reload.ts'
22
+ import { registerSourceLinks } from './source-links.tsx'
23
+ import { registerWorkbenchKeys } from './workbench-keys.ts'
24
+ import { runtimeDiagnostics } from './runtime-diagnostics.tsx'
25
+ import { DSH_THEME_TOKENS, syncDarkClass, ThemePresenter } from './theme.ts'
11
26
 
12
27
  declare const __PRESMIUM_CSS__: string
13
28
 
@@ -21,69 +36,46 @@ if (typeof document !== 'undefined' && !document.querySelector(`style[data-plugi
21
36
 
22
37
  export const inject = ['slots', 'theme', 'locale', 'shortcuts']
23
38
 
39
+ // The studio's one language source: the language chosen in Settings, else the browser's.
24
40
  function workbenchLocale(active: string | undefined): Locale {
25
- const tag = active?.toLowerCase() ?? ''
26
- if (tag.startsWith('zh') || tag.startsWith('en')) return localeOf(tag)
27
- return localeOf(navigator.language)
41
+ return localeOf(active) ?? localeOf(navigator.language) ?? 'en'
28
42
  }
29
43
 
30
44
  export function apply(ctx: DshClientContext): void {
31
- const navigation = new Promise<DshClientContext['uiWorkspace']>(resolve => ctx.inject(['uiWorkspace'], inner => resolve(inner.uiWorkspace)))
32
- let workspaceSnapshot: ReturnType<DshClientContext['workspaces']['list']['getSnapshot']> = { items: [] }
33
- const workspaceListeners = new Set<() => void>()
34
- const workspaceStore = {
35
- getSnapshot: () => workspaceSnapshot,
36
- subscribe(listener: () => void) {
37
- workspaceListeners.add(listener)
38
- return () => { workspaceListeners.delete(listener) }
39
- },
40
- }
45
+ const nativeLayout = isBootEntryLoaded(UI_LAYOUT)
46
+ const reload = nativeLayout ? { reloading: false } : beginRootReload()
47
+ if (reload.reloading) return
48
+ const uiWorkspace = new Promise<DshClientContext['uiWorkspace']>(resolve => ctx.inject(['uiWorkspace'], inner => resolve(inner.uiWorkspace)))
49
+ const dshWorkspaces = createObservable<ReturnType<DshClientContext['workspaces']['list']['getSnapshot']>>({ items: [] })
41
50
  // Register the root before the renderer boots; remote-backed workspace data follows later.
42
51
  ctx.inject(['workspaces'], inner => inner.effect(() => {
43
- const update = () => {
44
- workspaceSnapshot = inner.workspaces.list.getSnapshot()
45
- for (const listener of workspaceListeners) listener()
46
- }
52
+ const update = () => dshWorkspaces.set(inner.workspaces.list.getSnapshot())
47
53
  update()
48
54
  return inner.workspaces.list.subscribe(update)
49
55
  }, 'presmium: workspace list'))
50
- const workbench = new Workbench({ locale: workbenchLocale(ctx.locale.getLocale().active) })
51
- const ownLayout = window.__DSH_BOOT__?.entries.some(entry => entry.id === UI_LAYOUT)
56
+ const workbench = new Workbench({ client: workspaceClient('api/presmium/', workspaceSubscriptions(ctx)), locale: workbenchLocale(ctx.locale.getLocale().active) })
57
+ workbench.client.openSpeechSettings = openSpeechSettings
58
+ ctx.effect(() => connectAuthoringClient(workbench), 'presmium: authoring client control')
59
+ ctx.effect(() => runtimeDiagnostics(workbench).start(), 'presmium: runtime diagnostics')
52
60
  let showEditor = (layout: DshClientContext['layout']) => layout.selectPanel(FALLBACK_PANEL)
53
- sourceLinks(ctx, workbench, layout => showEditor(layout))
54
- ctx.effect(() => () => workbench.dispose(), 'presmium: workbench')
55
- ctx.inject(['sessions'], inner => inner.effect(() => {
56
- let previous: string | undefined
57
- let pending: AbortController | undefined
58
- const sync = () => {
59
- const session = Object.values(inner.sessions.list.getSnapshot().byId).find(row => (row.retainedBy.mainView ?? 0) > 0)
60
- if (!session) return
61
- const document = workbench.state.document === '' && workbench.state.documents?.single === false ? null : workbench.state.document ?? null
62
- const key = JSON.stringify([session.id, document])
63
- if (key === previous) return
64
- previous = key
65
- pending?.abort()
66
- pending = new AbortController()
67
- void fetch('/api/presmium/context', {
68
- method: 'POST', headers: { 'Content-Type': 'application/json' },
69
- body: JSON.stringify({ session: session.id, document }), signal: pending.signal,
70
- }).catch(() => {})
71
- }
72
- const offSession = inner.sessions.list.subscribe(sync)
73
- const offDocument = workbench.subscribe(sync)
74
- sync()
75
- return () => { offSession(); offDocument(); pending?.abort() }
76
- }, 'presmium: selected document context (all layouts)'))
61
+ registerSourceLinks(ctx, workbench, layout => showEditor(layout))
62
+ ctx.effect(() => () => {
63
+ workbench.dispose()
64
+ if (!nativeLayout) saveRootReturnPoint(workbench)
65
+ }, 'presmium: workbench')
66
+ syncDocumentContext(ctx, workbench)
67
+ registerWorkbenchKeys(ctx, workbench)
68
+ registerAgentRequests(ctx, workbench, registerAssistantSetup(ctx, workbench))
77
69
  ctx.effect(() => ctx.locale.subscribe(() => workbench.setLocale(workbenchLocale(ctx.locale.getLocale().active))), 'presmium: language')
78
- ctx.effect(() => ctx.theme.overrideTokens(CLIENT_ID, TOKENS), 'presmium: 书卷 tokens')
70
+ ctx.effect(() => ctx.theme.overrideTokens(CLIENT_ID, DSH_THEME_TOKENS), 'presmium: 书卷 tokens')
79
71
  ctx.effect(() => {
80
- const follow = (snapshot: DshThemeSnapshot) => workbench.setDark(followScheme(snapshot))
81
- follow(ctx.theme.getTheme())
82
- return ctx.on('theme/change', follow)
72
+ const sync = (snapshot: DshThemeSnapshot) => workbench.setDark(syncDarkClass(snapshot))
73
+ sync(ctx.theme.getTheme())
74
+ return ctx.on('theme/change', sync)
83
75
  }, 'presmium: light and dark')
84
76
 
85
- if (ownLayout) {
86
- fallback(ctx, workbench)
77
+ if (nativeLayout) {
78
+ registerFallbackLayout(ctx, workbench)
87
79
  } else {
88
80
  ctx.effect(() => {
89
81
  const presenter = new ThemePresenter()
@@ -97,11 +89,11 @@ export function apply(ctx: DshClientContext): void {
97
89
  ctx.effect(() => {
98
90
  const handle = createLayoutStore()
99
91
  const instance = handle.create()
100
- const panelStore = chrome(ctx, workbench, instance.actions, instance.getSnapshot, async () => {
101
- const { root } = await workbench.client.workbench()
102
- const normalize = (path: string) => path.replace(/\\/g, '/').replace(/\/$/, '')
103
- const workspace = workspaceSnapshot.items.find(item => root && normalize(item.path) === normalize(root))
104
- if (workspace) (await navigation).startSession(workspace.workspaceId)
92
+ const globalPanels = createObservable<readonly DshSlotEntry[]>([])
93
+ registerNavigationShell(ctx, workbench, instance, globalPanels, async () => {
94
+ const { root } = await workbench.client.readWorkbenchInfo()
95
+ const workspace = dshWorkspaces.getSnapshot().items.find(item => root && isSamePath(item.path, root))
96
+ if (workspace) (await uiWorkspace).startSession(workspace.workspaceId)
105
97
  })
106
98
  const store = { ...handle, create: () => instance }
107
99
  const mainKeys = () => ctx.slots.entries('main').flatMap(entry => (entry.options.key === undefined ? [] : [entry.options.key]))
@@ -115,16 +107,21 @@ export function apply(ctx: DshClientContext): void {
115
107
  triggers[triggers.length - 1]?.click()
116
108
  }
117
109
  const disposePanelInfo = ctx.slots.provideRoot({ hooks: { panelInfo: layout.panelInfo } })
118
- const disposeService = ctx.reflect.provide('layout', layout)
119
- const nativeSidebar = window.__DSH_BOOT__?.entries.some(entry => entry.id === '@deepseek-ai/dsh-client-ui-sidebar')
120
110
  const disposeRoot = ctx.slots.register({ name: 'root', locale: 'common', children: {
121
111
  ...ROOT_CHILDREN,
122
- ...(nativeSidebar ? {} : { 'sidebar.panellist': { kind: 'list', scope: 'root' } as const }),
123
- }, store }, frameFor(workbench, openSettings, id => navigation.then(service => service.openWorkspace(id, () => layout.preserveNextConversation())), workspaceStore, panelStore))
112
+ ...(isBootEntryLoaded(UI_SIDEBAR) ? {} : { 'sidebar.panellist': { kind: 'list', scope: 'root' } as const }),
113
+ }, store }, rootLayoutFor({
114
+ workbench,
115
+ openSettings,
116
+ openDshWorkspace: id => uiWorkspace.then(service => service.openWorkspace(id, () => layout.preserveNextConversation())),
117
+ dshWorkspaces,
118
+ globalPanels,
119
+ }))
120
+ const disposeService = ctx.reflect.provide('layout', layout)
124
121
  const binding = { code: 'KeyB', modifiers: ['primary', 'alt'] }
125
122
  const disposeShortcut = ctx.shortcuts.register({
126
123
  id: 'sidebar.left.toggle',
127
- label: () => (workbench.state.locale === 'zh-CN' ? '展开/收起侧栏' : 'Toggle the side panel'),
124
+ label: () => CLIENT_MESSAGES[workbench.state.locale].toggleSide,
128
125
  aliases: ['sidebar', 'toggle left sidebar'],
129
126
  defaults: {
130
127
  'desktop:macos': { code: 'KeyB', modifiers: ['primary'] },
@@ -144,11 +141,17 @@ export function apply(ctx: DshClientContext): void {
144
141
  disposeShortcut()
145
142
  layout.dispose()
146
143
  disposePanels()
144
+ disposeService()
147
145
  disposeRoot()
148
146
  disposePanelInfo()
149
- disposeService()
150
147
  }
151
148
  }, 'presmium: the root layout and ctx.layout')
152
149
  }
153
- brand(ctx, workbench)
150
+ registerBrand(ctx, workbench)
151
+ registerMascot(ctx, workbench)
152
+ registerAuthorSettings(ctx)
153
+ registerSpeechSettings(ctx, workbench)
154
+ ctx.inject(['layout'], inner => inner.effect(() => connectLaunchTarget(workbench,
155
+ () => nativeLayout ? () => {} : restoreRootReturnPoint(workbench, reload.point),
156
+ () => showEditor(inner.layout)), 'presmium: launch target and root return point'))
154
157
  }
@@ -0,0 +1,89 @@
1
+ import type { Workbench } from '@lincyaw/workbench'
2
+ import { restoreRootReturnPoint } from './root-reload.ts'
3
+
4
+ const PREFIX = '#presmium?'
5
+ const INTERACTIONS = ['pointerdown', 'keydown', 'beforeinput', 'wheel', 'touchmove']
6
+
7
+ interface LaunchTarget { directory: string, documentId: string, sectionId?: string }
8
+
9
+ /** This fragment belongs to Presmium; Harness's authenticated root and API routes keep their own paths. */
10
+ export function parseLaunchTarget(fragment: string): LaunchTarget | undefined {
11
+ if (!fragment.startsWith(PREFIX) || fragment.length > 8192) return
12
+ const params = new URLSearchParams(fragment.slice(PREFIX.length))
13
+ const keys = ['document', 'id', 'section', 'request']
14
+ if ([...params.keys()].some(key => !keys.includes(key)) || keys.some(key => params.getAll(key).length > 1)) return
15
+ const request = params.get('request')
16
+ if (request !== null && !/^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/i.test(request)) return
17
+ const directory = params.get('document'), documentId = params.get('id'), sectionId = params.get('section')
18
+ if (directory === null || !documentId || sectionId === '' || /[\\:\u0000-\u001f\u007f]/.test(directory)
19
+ || directory !== '' && directory.split('/').some(part => !part || part === '.' || part === '..')) return
20
+ return { directory, documentId, ...sectionId !== null ? { sectionId } : {} }
21
+ }
22
+
23
+ /** Explicit addresses outrank reload recovery; source identity and ordinary user navigation still own adoption. */
24
+ export function connectLaunchTarget(workbench: Workbench, restore: () => () => void, showDocument: () => void): () => void {
25
+ let closed = false, generation = 0
26
+ let stopPending: (() => void) | undefined, stopReturn: (() => void) | undefined
27
+ const open = (initial = false) => {
28
+ const request = ++generation, fragment = window.location.hash
29
+ stopPending?.(); stopPending = undefined
30
+ stopReturn?.()
31
+ if (initial && !fragment.startsWith(PREFIX)) { stopReturn = restore(); return }
32
+ // Keep the ordinary address retention, without opening an older checkpoint behind this explicit intent.
33
+ stopReturn = restoreRootReturnPoint(workbench, undefined)
34
+ const target = parseLaunchTarget(fragment)
35
+ if (!target) return
36
+ let active = true, ownOpening = false, committing = false
37
+ const beforeDocument = workbench.state.document
38
+ let navigation = workbench.documentNavigationVersion
39
+ let position = workbench.state.documentPositionToken, view = workbench.state.webView
40
+ const current = () => active && !closed && request === generation && window.location.hash === fragment
41
+ && workbench.documentNavigationVersion === navigation && workbench.state.webView === view
42
+ const cancel = () => { active = false }
43
+ const interact = (event: Event) => { if (event.isTrusted) cancel() }
44
+ for (const name of INTERACTIONS) document.addEventListener(name, interact, true)
45
+ const stop = workbench.subscribe(() => {
46
+ if (committing) return
47
+ if (!current() || !ownOpening && workbench.state.documentPositionToken !== position) cancel()
48
+ })
49
+ const close = () => {
50
+ cancel(); stop()
51
+ for (const name of INTERACTIONS) document.removeEventListener(name, interact, true)
52
+ }
53
+ stopPending = close
54
+ const launch = async () => {
55
+ const [info] = await Promise.all([workbench.client.readWorkbenchInfo(), workbench.loadDocuments()])
56
+ if (!current() || !info.root || !workbench.state.documents?.documents.some(doc => doc.dir === target.directory)) return
57
+ // Only an already listed directory can be read. A stale ID or missing page never changes the current selection.
58
+ const answer = await workbench.client.readDocument(target.directory)
59
+ if (!current() || answer.content.id !== target.documentId
60
+ || !workbench.state.documents?.documents.some(doc => doc.dir === target.directory)
61
+ || target.sectionId && (!answer.content.sectionOrder.includes(target.sectionId) || !answer.content.sections[target.sectionId])) return
62
+ const identity = JSON.stringify([info.root, target.documentId])
63
+ ownOpening = true
64
+ committing = true
65
+ const opened = workbench.openDocument(target.directory, { expectedIdentity: identity })
66
+ navigation = workbench.documentNavigationVersion
67
+ position = workbench.state.documentPositionToken
68
+ view = workbench.state.webView
69
+ committing = false
70
+ await opened
71
+ if (!current() || workbench.state.document !== target.directory || workbench.state.authoring?.identity !== identity
72
+ || workbench.state.authoring.content?.id !== target.documentId) return
73
+ const section = target.sectionId
74
+ if (section && (!workbench.state.authoring.content.sectionOrder.includes(section) || !workbench.state.authoring.content.sections[section])) return
75
+ // A retained document keeps its editing or playback view; a newly opened document starts in Doc.
76
+ close()
77
+ showDocument()
78
+ if (beforeDocument !== target.directory && workbench.state.webView === 'document' && section) workbench.goToSection(section)
79
+ }
80
+ void launch().catch(() => { /* Invalid or unavailable addresses leave the ordinary document UI available. */ }).finally(() => {
81
+ close()
82
+ if (stopPending === close) stopPending = undefined
83
+ })
84
+ }
85
+ const changed = () => open()
86
+ window.addEventListener('hashchange', changed)
87
+ open(true)
88
+ return () => { closed = true; generation++; stopPending?.(); stopReturn?.(); window.removeEventListener('hashchange', changed) }
89
+ }
@@ -1,58 +1,69 @@
1
1
  import { defineStore } from '@deepseek-ai/dsh-client-store'
2
2
 
3
- export const CENTER_MIN = 400
3
+ export const CENTRE_MIN = 400
4
4
  export const RIGHTBAR_MIN = 300
5
5
  export const RIGHTBAR_MAX_RATIO = 0.7
6
6
  export const RIGHTBAR_DEFAULT_RATIO = 0.45
7
7
  export const SIDE_COLLAPSE = 1024
8
8
  export const NARROW = 768
9
- export const ACTIVITY = 48
10
9
  export const SIDE_DEFAULT = 264
11
10
  export const SIDE_MIN = 200
12
11
  export const SIDE_MAX = 420
13
- export const DOCK_DEFAULT = 400
14
- export const DOCK_MIN = 320
15
- export const DOCK_MAX = 640
12
+ export const CONVERSATION_DEFAULT = 400
13
+ export const CONVERSATION_MIN = 320
14
+ export const CONVERSATION_MAX = 640
15
+ /** The width of one resize handle between two columns: a hairline, with a wider area to grab. */
16
+ export const HANDLE = 1
16
17
 
17
18
  export const clamp = (px: number, min: number, max: number) => Math.min(max, Math.max(min, Math.round(px)))
18
19
 
19
- export type Activity = 'documents' | 'files' | 'changes' | 'sessions'
20
- export type NarrowView = 'documents' | 'files' | 'source' | 'preview' | 'changes' | 'sessions' | 'conversation'
20
+ /** The width left for the rightbar column: the viewport less the side panel, the conversation, the centre's minimum and `handles` resize handles. */
21
+ export function rightbarRoom(viewport: number, side: number, conversation: number, handles: number): number {
22
+ return viewport - side - conversation - CENTRE_MIN - handles * HANDLE
23
+ }
24
+
25
+ /** What the side panel shows: the chat history (DeepSeek Harness's sessions). */
26
+ export type Activity = 'sessions'
27
+ /** What a narrow screen shows, one at a time. */
28
+ export type NarrowPane = 'documents' | 'preview' | 'sessions' | 'conversation'
21
29
 
22
30
  export interface LayoutState {
23
31
  panelInfo: { activePanelId: string | null }
24
32
  layoutInfo: {
25
33
  viewportWidth: number
26
- sideOpen: boolean
27
- sideExpanded: boolean
28
- side: number
29
- dock: number
30
- dockOpen: boolean
31
- rightbar: number | null
32
- rightbarShown: boolean
33
- rightbarTrack: boolean
34
- rightbarFullscreen: boolean
34
+ isSideOpen: boolean
35
+ /** On a screen narrower than SIDE_COLLAPSE the side panel opens over the centre. */
36
+ isSideExpanded: boolean
37
+ sideWidth: number
38
+ conversationWidth: number
39
+ isConversationOpen: boolean
40
+ rightbarWidth: number | null
41
+ isRightbarOpen: boolean
42
+ hasRightbarTrack: boolean
43
+ isRightbarFullscreen: boolean
35
44
  activity: Activity
36
- view: NarrowView
45
+ narrowPane: NarrowPane
37
46
  }
38
47
  }
39
48
 
49
+ const openConversationWidth = (info: LayoutState['layoutInfo']) => (info.isConversationOpen ? info.conversationWidth : 0)
50
+
40
51
  function revealConversation(d: LayoutState): void {
41
52
  d.panelInfo.activePanelId = null
42
- d.layoutInfo.dockOpen = true
43
- d.layoutInfo.view = 'conversation'
44
- if (d.layoutInfo.viewportWidth < SIDE_COLLAPSE) d.layoutInfo.sideExpanded = false
53
+ d.layoutInfo.isConversationOpen = true
54
+ d.layoutInfo.narrowPane = 'conversation'
55
+ if (d.layoutInfo.viewportWidth < SIDE_COLLAPSE) d.layoutInfo.isSideExpanded = false
45
56
  }
46
57
 
47
58
  function showHistory(d: LayoutState): void {
48
59
  d.panelInfo.activePanelId = null
49
60
  d.layoutInfo.activity = 'sessions'
50
- d.layoutInfo.sideOpen = true
51
- d.layoutInfo.view = 'sessions'
61
+ d.layoutInfo.isSideOpen = true
62
+ d.layoutInfo.narrowPane = 'sessions'
52
63
  if (d.layoutInfo.viewportWidth < SIDE_COLLAPSE) {
53
- d.layoutInfo.sideExpanded = d.layoutInfo.viewportWidth >= NARROW
54
- d.layoutInfo.dockOpen = false
55
- } else d.layoutInfo.dockOpen = true
64
+ d.layoutInfo.isSideExpanded = d.layoutInfo.viewportWidth >= NARROW
65
+ d.layoutInfo.isConversationOpen = false
66
+ } else d.layoutInfo.isConversationOpen = true
56
67
  }
57
68
 
58
69
  export function createLayoutStore() {
@@ -61,17 +72,17 @@ export function createLayoutStore() {
61
72
  panelInfo: { activePanelId: null },
62
73
  layoutInfo: {
63
74
  viewportWidth: window.innerWidth,
64
- sideOpen: false,
65
- sideExpanded: false,
66
- side: SIDE_DEFAULT,
67
- dock: DOCK_DEFAULT,
68
- dockOpen: true,
69
- rightbar: null,
70
- rightbarShown: false,
71
- rightbarTrack: false,
72
- rightbarFullscreen: false,
73
- activity: 'files',
74
- view: 'documents',
75
+ isSideOpen: false,
76
+ isSideExpanded: false,
77
+ sideWidth: SIDE_DEFAULT,
78
+ conversationWidth: CONVERSATION_DEFAULT,
79
+ isConversationOpen: false,
80
+ rightbarWidth: null,
81
+ isRightbarOpen: false,
82
+ hasRightbarTrack: false,
83
+ isRightbarFullscreen: false,
84
+ activity: 'sessions',
85
+ narrowPane: 'documents',
75
86
  },
76
87
  }),
77
88
  actions: {
@@ -82,76 +93,78 @@ export function createLayoutStore() {
82
93
  if (d.panelInfo.activePanelId !== null && !ids.includes(d.panelInfo.activePanelId)) d.panelInfo.activePanelId = null
83
94
  },
84
95
  setViewportWidth: (d: LayoutState, width: number) => {
85
- if ((d.layoutInfo.viewportWidth < SIDE_COLLAPSE) !== (width < SIDE_COLLAPSE)) d.layoutInfo.sideExpanded = false
96
+ if ((d.layoutInfo.viewportWidth < SIDE_COLLAPSE) !== (width < SIDE_COLLAPSE)) d.layoutInfo.isSideExpanded = false
86
97
  d.layoutInfo.viewportWidth = width
87
- if (d.layoutInfo.rightbarTrack && width - ACTIVITY - d.layoutInfo.side - (d.layoutInfo.dockOpen ? d.layoutInfo.dock : 0) - CENTER_MIN - 24 < RIGHTBAR_MIN) {
88
- d.layoutInfo.sideOpen = false
89
- d.layoutInfo.sideExpanded = false
98
+ if (d.layoutInfo.hasRightbarTrack && rightbarRoom(width, d.layoutInfo.sideWidth, openConversationWidth(d.layoutInfo), 3) < RIGHTBAR_MIN) {
99
+ d.layoutInfo.isSideOpen = false
100
+ d.layoutInfo.isSideExpanded = false
90
101
  }
91
102
  },
92
103
  toggleSidebar: (d: LayoutState) => {
93
104
  if (d.layoutInfo.viewportWidth < SIDE_COLLAPSE) {
94
- d.layoutInfo.sideExpanded = !d.layoutInfo.sideExpanded
95
- if (d.layoutInfo.sideExpanded) d.layoutInfo.dockOpen = false
105
+ d.layoutInfo.isSideExpanded = !d.layoutInfo.isSideExpanded
106
+ if (d.layoutInfo.isSideExpanded) d.layoutInfo.isConversationOpen = false
96
107
  }
97
- else d.layoutInfo.sideOpen = !d.layoutInfo.sideOpen
108
+ else d.layoutInfo.isSideOpen = !d.layoutInfo.isSideOpen
98
109
  },
99
- setSide: (d: LayoutState, px: number) => {
100
- d.layoutInfo.side = clamp(px, SIDE_MIN, SIDE_MAX)
110
+ setSideWidth: (d: LayoutState, px: number) => {
111
+ d.layoutInfo.sideWidth = clamp(px, SIDE_MIN, SIDE_MAX)
101
112
  },
102
- setDock: (d: LayoutState, px: number) => {
103
- d.layoutInfo.dock = clamp(px, DOCK_MIN, Math.min(DOCK_MAX, d.layoutInfo.viewportWidth * 0.5))
113
+ setConversationWidth: (d: LayoutState, px: number) => {
114
+ d.layoutInfo.conversationWidth = clamp(px, CONVERSATION_MIN, Math.min(CONVERSATION_MAX, d.layoutInfo.viewportWidth * 0.5))
104
115
  },
105
- setDockOpen: (d: LayoutState, open: boolean) => {
106
- d.layoutInfo.dockOpen = open
107
- if (open && d.layoutInfo.viewportWidth < SIDE_COLLAPSE) d.layoutInfo.sideExpanded = false
116
+ setConversationOpen: (d: LayoutState, open: boolean) => {
117
+ d.layoutInfo.isConversationOpen = open
118
+ if (open && d.layoutInfo.viewportWidth < SIDE_COLLAPSE) d.layoutInfo.isSideExpanded = false
108
119
  },
109
120
  revealConversation,
110
121
  showHistory,
111
122
  toggleConversations: (d: LayoutState) => {
112
123
  const info = d.layoutInfo
113
124
  if (info.viewportWidth < NARROW) {
114
- if (info.view === 'conversation') showHistory(d)
125
+ if (info.narrowPane === 'conversation') showHistory(d)
115
126
  else revealConversation(d)
116
127
  } else {
117
- const historyShown = info.activity === 'sessions' && (info.viewportWidth < SIDE_COLLAPSE ? info.sideExpanded : info.sideOpen)
128
+ const historyShown = info.activity === 'sessions' && (info.viewportWidth < SIDE_COLLAPSE ? info.isSideExpanded : info.isSideOpen)
118
129
  if (historyShown && info.viewportWidth < SIDE_COLLAPSE) revealConversation(d)
119
- else if (historyShown && info.dockOpen) info.sideOpen = false
130
+ else if (historyShown && info.isConversationOpen) info.isSideOpen = false
120
131
  else showHistory(d)
121
132
  }
122
133
  },
123
- setRightbar: (d: LayoutState, px: number) => {
124
- d.layoutInfo.rightbar = clamp(px, RIGHTBAR_MIN, Math.max(RIGHTBAR_MIN, d.layoutInfo.viewportWidth * RIGHTBAR_MAX_RATIO))
134
+ setRightbarWidth: (d: LayoutState, px: number) => {
135
+ d.layoutInfo.rightbarWidth = clamp(px, RIGHTBAR_MIN, Math.max(RIGHTBAR_MIN, d.layoutInfo.viewportWidth * RIGHTBAR_MAX_RATIO))
125
136
  },
126
137
  openRightbar: (d: LayoutState, track: boolean, fullscreen: boolean) => {
127
- if (track && d.layoutInfo.viewportWidth - ACTIVITY - d.layoutInfo.side - (d.layoutInfo.dockOpen ? d.layoutInfo.dock : 0) - CENTER_MIN - 24 < RIGHTBAR_MIN) {
128
- d.layoutInfo.sideOpen = false
129
- d.layoutInfo.sideExpanded = false
138
+ const info = d.layoutInfo
139
+ if (track && rightbarRoom(info.viewportWidth, info.sideWidth, openConversationWidth(info), 3) < RIGHTBAR_MIN) {
140
+ info.isSideOpen = false
141
+ info.isSideExpanded = false
130
142
  }
131
- if (track && d.layoutInfo.viewportWidth - ACTIVITY - (d.layoutInfo.dockOpen ? d.layoutInfo.dock : 0) - CENTER_MIN - 16 < RIGHTBAR_MIN) d.layoutInfo.dockOpen = false
132
- d.layoutInfo.rightbar ??= Math.max(RIGHTBAR_MIN, Math.round(d.layoutInfo.viewportWidth * RIGHTBAR_DEFAULT_RATIO * 0.6))
133
- d.layoutInfo.rightbarShown = true
134
- d.layoutInfo.rightbarTrack = track
135
- d.layoutInfo.rightbarFullscreen = fullscreen
143
+ if (track && rightbarRoom(info.viewportWidth, 0, openConversationWidth(info), 2) < RIGHTBAR_MIN) info.isConversationOpen = false
144
+ info.rightbarWidth ??= Math.max(RIGHTBAR_MIN, Math.round(info.viewportWidth * RIGHTBAR_DEFAULT_RATIO * 0.6))
145
+ info.isRightbarOpen = true
146
+ info.hasRightbarTrack = track
147
+ info.isRightbarFullscreen = fullscreen
136
148
  },
137
149
  closeRightbar: (d: LayoutState) => {
138
- d.layoutInfo.rightbarShown = false
139
- d.layoutInfo.rightbarTrack = false
140
- d.layoutInfo.rightbarFullscreen = false
150
+ d.layoutInfo.isRightbarOpen = false
151
+ d.layoutInfo.hasRightbarTrack = false
152
+ d.layoutInfo.isRightbarFullscreen = false
141
153
  },
142
- choose: (d: LayoutState, activity: Activity) => {
143
- if (d.layoutInfo.activity === activity && d.layoutInfo.viewportWidth >= SIDE_COLLAPSE) d.layoutInfo.sideOpen = !d.layoutInfo.sideOpen
144
- else d.layoutInfo.sideOpen = true
154
+ /** An activity of the activity bar: opens the side panel on it, or toggles it on a wide screen. */
155
+ selectActivity: (d: LayoutState, activity: Activity) => {
156
+ if (d.layoutInfo.activity === activity && d.layoutInfo.viewportWidth >= SIDE_COLLAPSE) d.layoutInfo.isSideOpen = !d.layoutInfo.isSideOpen
157
+ else d.layoutInfo.isSideOpen = true
145
158
  d.layoutInfo.activity = activity
146
159
  if (d.layoutInfo.viewportWidth < SIDE_COLLAPSE) {
147
- d.layoutInfo.sideExpanded = d.layoutInfo.viewportWidth >= NARROW
148
- if (d.layoutInfo.sideExpanded) d.layoutInfo.dockOpen = false
160
+ d.layoutInfo.isSideExpanded = d.layoutInfo.viewportWidth >= NARROW
161
+ if (d.layoutInfo.isSideExpanded) d.layoutInfo.isConversationOpen = false
149
162
  }
150
- d.layoutInfo.view = activity
163
+ d.layoutInfo.narrowPane = activity
151
164
  },
152
- show: (d: LayoutState, view: NarrowView) => {
153
- d.layoutInfo.view = view
154
- d.layoutInfo.sideExpanded = false
165
+ showNarrowPane: (d: LayoutState, pane: NarrowPane) => {
166
+ d.layoutInfo.narrowPane = pane
167
+ d.layoutInfo.isSideExpanded = false
155
168
  },
156
169
  },
157
170
  })
@@ -186,11 +199,11 @@ export class LayoutController {
186
199
  this.#preserveNextConversation = true
187
200
  }
188
201
 
189
- /** Source navigation selects the document without revealing the conversation. */
202
+ /** A link to a place of the document shows the document without revealing the conversation. */
190
203
  showDocument(): void {
191
204
  this.#navigation.abort()
192
205
  this.#actions.selectPanel(null)
193
- this.#actions.show('source')
206
+ this.#actions.showNarrowPane('preview')
194
207
  }
195
208
 
196
209
  beginNavigation(): AbortSignal {
@@ -0,0 +1,37 @@
1
+ import { PmMascot, PmSegmented, setMascotEnabled, useAssistantMascot, useMascotEnabled, WorkbenchProvider } from '@lincyaw/workbench'
2
+ import type { Workbench } from '@lincyaw/workbench'
3
+ import { MASCOT_SETTING, MASCOT_SLOT } from './contract.ts'
4
+ import { useClientMessages } from './messages.ts'
5
+
6
+ /** The session header's standard prop: a selector over the bound session's snapshot. */
7
+ type UseSession = <T>(select: (snapshot: { running?: boolean }) => T) => T
8
+
9
+ function Kitten({ running }: { running: boolean }) {
10
+ return <span className="pmw-conversation-mascot"><PmMascot size="small" action={useAssistantMascot(running)} /></span>
11
+ }
12
+
13
+ function MascotSetting({ workbench }: { workbench: Workbench }) {
14
+ const t = useClientMessages(workbench)
15
+ const on = useMascotEnabled()
16
+ return (
17
+ <div className="pmw-mascot-setting pm-ui" data-pmw-setting="mascot">
18
+ <div className="pmw-mascot-setting__copy">
19
+ <p className="pmw-mascot-setting__title">{t.mascot}</p>
20
+ <p className="pmw-mascot-setting__hint">{t.mascotHint}</p>
21
+ </div>
22
+ <PmSegmented label={t.mascot} value={on ? 'show' : 'hide'} options={[{ value: 'show', label: t.mascotShow }, { value: 'hide', label: t.mascotHide }]} onChange={value => setMascotEnabled(value === 'show')} />
23
+ </div>
24
+ )
25
+ }
26
+
27
+ /** Puts the kitten in the conversation's header, where it acts out what the assistant is doing, and its switch in
28
+ * Settings → General. */
29
+ export function registerMascot(ctx: DshClientContext, workbench: Workbench): void {
30
+ ctx.effect(() => ctx.slots.inject(MASCOT_SLOT.name, () => ctx.slots.register({ name: MASCOT_SLOT.name, id: MASCOT_SLOT.id, order: -100 }, ({ useSession }: { useSession: UseSession }) => {
31
+ const running = useSession(snapshot => Boolean(snapshot.running))
32
+ return <WorkbenchProvider workbench={workbench}><Kitten running={running} /></WorkbenchProvider>
33
+ })), 'presmium: the kitten beside the conversation')
34
+ ctx.effect(() => ctx.slots.inject(MASCOT_SETTING.name, () => ctx.slots.register({ name: MASCOT_SETTING.name, id: MASCOT_SETTING.id, order: 1000 }, () => (
35
+ <WorkbenchProvider workbench={workbench}><MascotSetting workbench={workbench} /></WorkbenchProvider>
36
+ ))), 'presmium: the kitten\'s switch in Settings')
37
+ }