@lincyaw/dsh 0.1.0 → 0.2.0-rc.1

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,78 +1,171 @@
1
- import type { Locale } from '@lincyaw/workbench'
1
+ import type { Locale, Workbench } from '@lincyaw/workbench'
2
+ import { useSyncExternalStore } from 'react'
2
3
 
3
4
  const zh = {
4
5
  product: 'Presmium',
5
- activity: '活动栏',
6
+ activity: '应用栏',
6
7
  skip: '跳到内容',
7
- documents: '文档',
8
- files: '文件',
9
- sessions: '会话',
10
- conversation: '对话',
8
+ documents: '文稿',
9
+ document: '当前文稿',
10
+ assistant: 'AI 助手',
11
+ more: '更多',
12
+ conversation: 'AI 助手',
11
13
  newConversation: '新建对话',
12
- conversationHistory: '历史对话',
13
- closeHistory: '收起历史对话',
14
- tools: '工具',
15
- hideConversation: '收起对话',
16
- source: '源码',
17
- preview: '预览',
18
- changes: '修改',
14
+ conversationHistory: '对话记录',
15
+ closeHistory: '隐藏对话记录',
16
+ hideConversation: '隐藏 AI 助手',
17
+ preview: '文稿',
18
+ history: '版本历史',
19
19
  settings: '设置',
20
- help: '使用说明',
20
+ help: '帮助',
21
+ mascot: '小猫',
22
+ mascotHint: 'AI 助手工作时,对话和文稿旁的小猫会演示它在做什么:思考、阅读、修改或等你确认。',
23
+ mascotShow: '显示',
24
+ mascotHide: '隐藏',
21
25
  close: '关闭',
22
- toggleSide: '展开/收起侧栏',
23
- dark: '切换明暗',
24
- side: '侧栏',
25
- status: '状态栏',
26
+ toggleSide: '显示或隐藏对话记录',
27
+ keys: {
28
+ save: '保存',
29
+ undo: '撤消',
30
+ redo: '恢复',
31
+ play: '播放/暂停(放映、视频制作)',
32
+ step: '上一步/下一步(放映、视频制作)',
33
+ overview: '放映:总览',
34
+ fullscreen: '放映:全屏',
35
+ },
26
36
  resize: '拖动调整宽度',
27
- centre: '工作区',
37
+ centre: '文稿',
28
38
  welcomeTitle: '欢迎使用 Presmium',
29
- welcome: '打开讲义即可预览,按需要切换源码或并排查看。右侧对话可以收起;选中文字或点选预览中的块,就能把关注内容带给 AI。不配模型也能编辑、保存与预览。',
39
+ welcome: '打开文稿即可编辑全文、调整页面并添加批注,修改自动保存,可以撤消。放映前会用已保存的版本准备放映;配音和视频制作会先检查所需的服务。使用 AI 助手前请设置模型。',
30
40
  start: '开始使用',
31
41
  models: '设置模型',
32
- helpText: '文档顶部可选“预览”“并排”“源码”;对话顶部可新建对话、打开历史。活动栏统一使用“历史对话”入口,选择历史会话会直接显示聊天,收起后也可从这里恢复。Mod+Alt+B 收起侧栏。选中文字或用“点选”选择一块,顶部显示带给 AI 的关注内容;可用关闭按钮清除。',
42
+ helpItems: [
43
+ '直接在文稿中输入即可修改。修改自动保存,文稿上方显示“已保存”。',
44
+ '选中文字会出现格式工具栏:段落样式、加粗、对齐、颜色、“插入链接”与“新建批注”。“新建批注”在选中的文字上留下批注,“批注”窗格列出全部批注。',
45
+ '鼠标移到一段内容上,旁边的“插入内容块”可插入标题、列表、表格、图片等内容,“打开内容块菜单”可删除这一段或修改它的颜色。',
46
+ '“导航窗格”列出全部页面:“新建页面”添加一页,页面旁的“更多”可以重命名、复制、上移、下移或删除这一页。',
47
+ '“撤消”({undo})与“恢复”({redo})逐步撤消或恢复修改。“版本历史”列出保存过的每次修改,“撤消此更改”单独撤消其中一次。',
48
+ '“放映”按幻灯片逐步展示文稿;“导出”可导出网站、幻灯片网站、Notebook、幻灯片 PDF、幻灯片图片或幻灯片 PowerPoint;“视频制作”制作讲授视频。',
49
+ '“AI 助手”显示或隐藏与 AI 的对话。用自己的话告诉它要改什么,它处理的是当前打开的文稿。第一次使用前,请在“设置”的“模型”中设置模型。',
50
+ ],
51
+ runtimeProblems: '有操作无法完成。请查看详细信息,或重试刚才的操作。',
52
+ runtimeStopped: 'Presmium 暂时无法显示。请重新加载后继续。',
53
+ runtimeDetails: '详细信息',
54
+ runtimeReportFailed: '暂时无法把诊断发送给工作区服务,下面的详细信息仍可查看。',
55
+ runtimeRetry: '重新发送诊断',
56
+ runtimeDismiss: '关闭这条提示',
57
+ runtimeReload: '重新加载',
58
+ repairStale: '文稿或对话已切换,请在当前文稿中重新发送。',
59
+ repairRequest: '请修复当前文稿中以下无法正确显示的内容。诊断是观测到的结果,请核对文稿内容后再修复。',
60
+ needsModelTitle: 'AI 助手需要先设置模型',
61
+ needsModel: '设置一个模型后,就能用自己的话告诉 AI 要改什么。已经写下的请求会保留在这里。',
62
+ needsModelTurn: '这次请求没有发出:还没有可用的模型。设置模型后重新发送即可。',
63
+ needsModelRequest: 'AI 助手需要先设置模型:在 AI 助手中选择“设置模型”,设置好后重试。',
64
+ keyRejectedTitle: '模型没有接受当前的密钥',
65
+ keyRejected: '无法完成这次请求:模型服务拒绝了设置中的密钥。请在模型设置中检查或重新填写,然后重新发送。',
66
+ quotaTitle: '模型的额度用完了',
67
+ quota: '无法完成这次请求:模型服务的额度不足。请充值,或在模型设置中换一个模型,然后重新发送。',
68
+ turnFailedTitle: '无法完成这次请求',
69
+ turnFailed: 'AI 助手无法完成这次请求。请重新发送;如果反复出现,请新建对话。',
70
+ busy: '模型服务暂时繁忙。请稍后重新发送。',
71
+ tooLong: '这段对话太长了,模型读不下。请新建对话,再把要改的内容告诉 AI。',
72
+ turnStoppedTitle: '这次请求中途停止',
73
+ turnStopped: 'Presmium 在 AI 处理时重新启动了。请重新发送。',
74
+ checkModels: '检查模型设置',
75
+ failureDetails: '详细信息',
33
76
  }
34
77
 
35
- export type FrameMessages = typeof zh
78
+ export type ClientMessages = typeof zh
36
79
 
37
- const en: FrameMessages = {
80
+ const en: ClientMessages = {
38
81
  product: 'Presmium',
39
- activity: 'Activity bar',
40
- skip: 'Skip to content',
82
+ activity: 'App Bar',
83
+ skip: 'Skip to Content',
41
84
  documents: 'Documents',
42
- files: 'Files',
43
- sessions: 'Sessions',
44
- conversation: 'Conversation',
45
- newConversation: 'New chat',
46
- conversationHistory: 'Chat history',
47
- closeHistory: 'Hide chat history',
48
- tools: 'Tools',
49
- hideConversation: 'Hide conversation',
50
- source: 'Source',
51
- preview: 'Preview',
52
- changes: 'Changes',
85
+ document: 'Document',
86
+ assistant: 'AI Assistant',
87
+ more: 'More',
88
+ conversation: 'AI Assistant',
89
+ newConversation: 'New Chat',
90
+ conversationHistory: 'Chat History',
91
+ closeHistory: 'Hide Chat History',
92
+ hideConversation: 'Hide AI Assistant',
93
+ preview: 'Document',
94
+ history: 'Version History',
53
95
  settings: 'Settings',
54
- help: 'How to use',
96
+ help: 'Help',
97
+ mascot: 'Kitten',
98
+ mascotHint: 'While the AI Assistant works, a kitten beside the chat and the document acts out what it is doing: thinking, reading, changing the document, or waiting for you.',
99
+ mascotShow: 'Show',
100
+ mascotHide: 'Hide',
55
101
  close: 'Close',
56
- toggleSide: 'Toggle the side panel',
57
- dark: 'Toggle light and dark',
58
- side: 'Side panel',
59
- status: 'Status bar',
102
+ toggleSide: 'Show or Hide Chat History',
103
+ keys: {
104
+ save: 'Save',
105
+ undo: 'Undo',
106
+ redo: 'Redo',
107
+ play: 'Play or Pause (Presentation, Video Production)',
108
+ step: 'Previous or Next Step (Presentation, Video Production)',
109
+ overview: 'Presentation: Overview',
110
+ fullscreen: 'Presentation: Full Screen',
111
+ },
60
112
  resize: 'Drag to resize',
61
- centre: 'Workspace',
113
+ centre: 'Document',
62
114
  welcomeTitle: 'Welcome to Presmium',
63
- welcome: 'Open a lecture to preview it, and switch to source or a side by side view when needed. The conversation can be hidden. Select text or pick a block to give the AI context. Editing, saving and previewing need no model.',
64
- start: 'Start',
65
- models: 'Set up a model',
66
- helpText: 'Choose Preview, Side by side or Source above the document. Start a new chat or open history from the conversation header. Chat history in the activity bar is the shared entry for history and chat. Selecting a history row reveals its conversation, including when chat was hidden. Mod+Alt+B toggles the sidebar. Select text or pick a block to add conversation context, shown above the document. Clear it with its close button.',
115
+ welcome: 'Open a document to edit its full text, arrange its pages, and add comments. Changes save automatically and can be undone. Presenting prepares the saved version first; voiceover and Video Production check the services they need first. Set up a model before using the AI Assistant.',
116
+ start: 'Get Started',
117
+ models: 'Set Up a Model',
118
+ helpItems: [
119
+ 'Type directly in the document to change it. Changes save automatically, and the top of the document shows Saved.',
120
+ 'Select text to show the formatting toolbar: paragraph style, Bold, alignment, Colors, Insert Link, and New Comment. New Comment leaves a comment on the selected text; the Comments pane lists them all.',
121
+ 'Point at a paragraph: Insert Block, beside it, inserts a heading, list, table, picture, and more; Open Block Menu deletes the paragraph or changes its colors.',
122
+ 'The Navigation Pane lists the pages: New Page adds one, and More beside a page renames, duplicates, moves, or deletes it.',
123
+ 'Undo ({undo}) and Redo ({redo}) step back and forth through your changes. Version History lists every saved change; Undo This Change reverses one of them.',
124
+ 'Present shows the document as slides, step by step; Export makes a website, slides website, Notebook, slides as PDF, slides as pictures, or slides as PowerPoint; Video Production makes a lecture video with voiceover.',
125
+ 'AI Assistant shows or hides the chat with the AI. Tell it in your own words what to change; it works on the document you have open. Before the first use, set up a model under Models in Settings.',
126
+ ],
127
+ runtimeProblems: 'An operation couldn’t finish. Check the details, or retry the operation.',
128
+ runtimeStopped: 'Presmium can’t display right now. Reload to continue.',
129
+ runtimeDetails: 'Details',
130
+ runtimeReportFailed: 'Couldn’t send the diagnostics to the workspace service. The details below are still available.',
131
+ runtimeRetry: 'Send Diagnostics Again',
132
+ runtimeDismiss: 'Close',
133
+ runtimeReload: 'Reload',
134
+ repairStale: 'The document or chat changed. Send it again from the current document.',
135
+ repairRequest: 'Please fix the content below that does not display correctly in the current document. The diagnostics are what was observed; check the document’s content before fixing it.',
136
+ needsModelTitle: 'The AI Assistant needs a model',
137
+ needsModel: 'Set up a model, then tell the AI in your own words what to change. Anything you have typed stays here.',
138
+ needsModelTurn: 'This request was not sent: no model is set up yet. Set one up, then send it again.',
139
+ needsModelRequest: 'The AI Assistant needs a model first: choose Set Up a Model in the AI Assistant, then try again.',
140
+ keyRejectedTitle: 'The model didn’t accept your API key',
141
+ keyRejected: 'Couldn’t finish this request: the model service refused the API key in Settings. Check or re-enter it in the model settings, then send again.',
142
+ quotaTitle: 'The model’s quota is used up',
143
+ quota: 'Couldn’t finish this request: the model service has no quota left. Top up, or choose another model in the model settings, then send again.',
144
+ turnFailedTitle: 'Couldn’t finish this request',
145
+ turnFailed: 'The AI Assistant couldn’t finish this request. Send it again; if this keeps happening, start a new chat.',
146
+ busy: 'The model service is busy right now. Wait a moment, then send again.',
147
+ tooLong: 'This chat has grown too long for the model. Start a new chat and tell the AI again what to change.',
148
+ turnStoppedTitle: 'This request stopped midway',
149
+ turnStopped: 'Presmium restarted while the AI was working. Send it again.',
150
+ checkModels: 'Check Model Settings',
151
+ failureDetails: 'Details',
152
+ }
153
+
154
+ export const CLIENT_MESSAGES: Record<Locale, ClientMessages> = { 'zh-CN': zh, 'en': en }
155
+
156
+ export function useClientMessages(workbench: Workbench): ClientMessages {
157
+ const locale = useSyncExternalStore(workbench.subscribe, () => workbench.getState().locale)
158
+ return CLIENT_MESSAGES[locale]
67
159
  }
68
160
 
69
- export const FRAME_MESSAGES: Record<Locale, FrameMessages> = { 'zh-CN': zh, 'en': en }
161
+ /** The languages the client part adds to DeepSeek Harness's language list, with Presmium's copy for them. */
162
+ export const BRAND_LANGUAGES = [{ id: 'zh-CN', label: '简体中文(Presmium)', fallback: 'zh' }, { id: 'en-US', label: 'English (Presmium)', fallback: 'en' }] as const
70
163
 
71
164
  export const BRAND_COPY: Record<'zh-CN' | 'en-US', Record<string, Record<string, string>>> = {
72
165
  'zh-CN': {
73
- conversation: { 'hero.headline': '写讲义,从这里开始', 'hero.preview': '工作台' },
166
+ conversation: { 'hero.headline': '有什么想改的,告诉我', 'hero.preview': 'AI 助手', 'placeholder.hero': '例如:给第二节补一个生活中的例子', 'placeholder.default': '继续告诉 AI 要改什么…', 'placeholder.workspace': '先打开一份文稿' },
74
167
  },
75
168
  'en-US': {
76
- conversation: { 'hero.headline': 'Write your lecture', 'hero.preview': 'Studio' },
169
+ conversation: { 'hero.headline': 'Tell me what to change', 'hero.preview': 'AI Assistant', 'placeholder.hero': 'For example: add an everyday example to section 2', 'placeholder.default': 'Tell the AI what to change next…', 'placeholder.workspace': 'Open a document first' },
77
170
  },
78
171
  }
@@ -1,41 +1,36 @@
1
1
  import { PmIconButton } from '@lincyaw/workbench'
2
2
  import type { Workbench } from '@lincyaw/workbench'
3
3
  import type { ReactNode } from 'react'
4
- import { useFrameMessages } from './brand.tsx'
4
+ import { isBootEntryLoaded, UI_SIDEBAR } from './contract.ts'
5
5
  import type { LayoutActions, LayoutState } from './layout.ts'
6
6
  import { NARROW, SIDE_COLLAPSE } from './layout.ts'
7
+ import { useClientMessages } from './messages.ts'
7
8
 
8
9
  interface SidebarProps {
9
10
  renderSlot(name: string, props?: Record<string, unknown>, options?: Record<string, unknown>): ReactNode
10
11
  }
11
12
 
12
- /** Replace navigation chrome through public slots; Harness still owns the session browser and dialogs. */
13
- export function chrome(ctx: DshClientContext, workbench: Workbench, actions: LayoutActions, currentLayout: () => LayoutState, startSession: () => Promise<void>): DshObservable<readonly DshSlotEntry[]> {
14
- let panels: DshSlotEntry[] = []
15
- const listeners = new Set<() => void>()
16
- const panelStore = {
17
- getSnapshot: () => panels,
18
- subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener) } },
19
- }
13
+ /**
14
+ * Replaces Harness's navigation shell through public slots: the history sidebar, the conversation's header actions,
15
+ * and the global panels (`sidebar.panellist`), which it keeps in `globalPanels` for the activity bar. Harness still
16
+ * owns the session browser and its dialogs.
17
+ */
18
+ export function registerNavigationShell(ctx: DshClientContext, workbench: Workbench, layout: { actions: LayoutActions, getSnapshot(): LayoutState }, globalPanels: { set(entries: readonly DshSlotEntry[]): void }, startSession: () => Promise<void>): void {
19
+ const { actions } = layout
20
20
  // An already running host retains its old plugin roster until it is restarted.
21
- if (!window.__DSH_BOOT__?.entries.some(entry => entry.id === '@deepseek-ai/dsh-client-ui-sidebar')) ctx.effect(() => {
22
- const offPanels = ctx.slots.subscribe('sidebar.panellist', () => {
23
- panels = ctx.slots.entries('sidebar.panellist')
24
- for (const listener of listeners) listener()
25
- })
26
- ctx.slots.inject('sidebar.panellist', () => {
27
- panels = ctx.slots.entries('sidebar.panellist')
28
- for (const listener of listeners) listener()
29
- })
21
+ if (!isBootEntryLoaded(UI_SIDEBAR)) ctx.effect(() => {
22
+ const updatePanels = () => globalPanels.set(ctx.slots.entries('sidebar.panellist'))
23
+ const offPanels = ctx.slots.subscribe('sidebar.panellist', updatePanels)
24
+ ctx.slots.inject('sidebar.panellist', updatePanels)
30
25
  const closeHistory = () => {
31
- if (currentLayout().layoutInfo.viewportWidth < SIDE_COLLAPSE) actions.revealConversation()
26
+ if (layout.getSnapshot().layoutInfo.viewportWidth < SIDE_COLLAPSE) actions.revealConversation()
32
27
  else actions.toggleSidebar()
33
28
  }
34
29
  function Sidebar({ renderSlot }: SidebarProps) {
35
- const t = useFrameMessages(workbench)
30
+ const t = useClientMessages(workbench)
36
31
  return <section className="pmw-history" aria-label={t.conversationHistory}>
37
32
  <header className="pmw-history__header"><h2>{t.conversationHistory}</h2><PmIconButton icon="close" small label={t.closeHistory} onClick={closeHistory} /></header>
38
- <div className="pmw-history__browser">{renderSlot('sidebar.workspaces', { wide: true, expandSidebar: () => actions.choose('sessions') })}</div>
33
+ <div className="pmw-history__browser">{renderSlot('sidebar.workspaces', { wide: true, expandSidebar: () => actions.selectActivity('sessions') })}</div>
39
34
  <div hidden>{renderSlot('sidebar.settings', { wide: false })}</div>
40
35
  </section>
41
36
  }
@@ -54,13 +49,14 @@ export function chrome(ctx: DshClientContext, workbench: Workbench, actions: Lay
54
49
  }, 'presmium: one navigation shell')
55
50
  ctx.effect(() => {
56
51
  function ConversationActions() {
57
- const t = useFrameMessages(workbench)
52
+ const t = useClientMessages(workbench)
58
53
  return <div className="pmw-chat-actions pm-ui" role="group" aria-label={t.conversation}>
54
+ <span className="pmw-chat-actions__title" aria-hidden="true">{t.conversation}</span>
59
55
  <PmIconButton icon="plus" label={t.newConversation} small onClick={() => void startSession()} />
60
56
  <PmIconButton icon="history" label={t.conversationHistory} small onClick={() => actions.showHistory()} />
61
57
  <PmIconButton icon="close" label={t.hideConversation} small onClick={() => {
62
- if (currentLayout().layoutInfo.viewportWidth < NARROW) actions.show(workbench.state.document === undefined ? 'documents' : 'preview')
63
- else actions.setDockOpen(false)
58
+ if (layout.getSnapshot().layoutInfo.viewportWidth < NARROW) actions.showNarrowPane(workbench.state.document === undefined ? 'documents' : 'preview')
59
+ else actions.setConversationOpen(false)
64
60
  }} />
65
61
  </div>
66
62
  }
@@ -75,5 +71,4 @@ export function chrome(ctx: DshClientContext, workbench: Workbench, actions: Lay
75
71
  })
76
72
  return () => off?.()
77
73
  }, 'presmium: remove the duplicate sidebar opener')
78
- return panelStore
79
74
  }
@@ -0,0 +1,16 @@
1
+ /** A value with listeners, in the shape of DeepSeek Harness's observables. */
2
+ export function createObservable<T>(initial: T): DshObservable<T> & { set(value: T): void } {
3
+ let value = initial
4
+ const listeners = new Set<() => void>()
5
+ return {
6
+ getSnapshot: () => value,
7
+ subscribe(listener: () => void) {
8
+ listeners.add(listener)
9
+ return () => { listeners.delete(listener) }
10
+ },
11
+ set(next: T) {
12
+ value = next
13
+ for (const listener of listeners) listener()
14
+ },
15
+ }
16
+ }
@@ -0,0 +1,17 @@
1
+ /** A path with `/` separators and without `.`, `..` or empty parts; undefined when `..` climbs above its start. */
2
+ export function normalizePath(path: string): string | undefined {
3
+ const parts: string[] = []
4
+ for (const part of path.replace(/\\/g, '/').split('/')) {
5
+ if (!part || part === '.') continue
6
+ if (part === '..') {
7
+ if (!parts.length || parts.at(-1)?.endsWith(':')) return
8
+ parts.pop()
9
+ } else parts.push(part)
10
+ }
11
+ return `${path.startsWith('/') ? '/' : ''}${parts.join('/')}`
12
+ }
13
+
14
+ export function isSamePath(a: string, b: string): boolean {
15
+ const normalized = normalizePath(a)
16
+ return normalized !== undefined && normalized === normalizePath(b)
17
+ }
@@ -0,0 +1,163 @@
1
+ import type { Workbench } from '@lincyaw/workbench'
2
+
3
+ const KEY = 'presmium.root-reload.v1'
4
+ const DOCUMENT_KEY = 'presmium.open-document.v1'
5
+ const CODE = Symbol('Presmium root client code')
6
+
7
+ interface ReturnPoint {
8
+ workspace: string
9
+ directory: string
10
+ documentId: string
11
+ sectionId?: string
12
+ stepId?: string
13
+ }
14
+
15
+ interface Checkpoint { version: 1, armed: boolean, point?: ReturnPoint }
16
+
17
+ function isReturnPoint(point: unknown): point is ReturnPoint {
18
+ return Boolean(point && typeof point === 'object' && 'workspace' in point && typeof point.workspace === 'string'
19
+ && 'directory' in point && typeof point.directory === 'string' && 'documentId' in point && typeof point.documentId === 'string'
20
+ && (!('sectionId' in point) || typeof point.sectionId === 'string') && (!('stepId' in point) || typeof point.stepId === 'string'))
21
+ }
22
+
23
+ function readOpenDocument(): ReturnPoint | undefined {
24
+ try {
25
+ const point: unknown = JSON.parse(sessionStorage.getItem(DOCUMENT_KEY) ?? 'null')
26
+ if (isReturnPoint(point)) return point
27
+ } catch { /* Invalid or unavailable storage cannot authorize navigation. */ }
28
+ try { sessionStorage.removeItem(DOCUMENT_KEY) } catch { /* Editing remains available. */ }
29
+ }
30
+
31
+ declare global {
32
+ interface Window {
33
+ /** A page-lifetime flag, never a retained plugin, context or React root. */
34
+ __presmiumRootReload?: symbol | 'reloading'
35
+ }
36
+ }
37
+
38
+ function checkpoint(): Checkpoint | undefined {
39
+ const value: unknown = JSON.parse(sessionStorage.getItem(KEY) ?? 'null')
40
+ if (!value || typeof value !== 'object' || !('version' in value) || value.version !== 1 || !('armed' in value) || typeof value.armed !== 'boolean') return
41
+ if (!('point' in value) || value.point === undefined) return { version: 1, armed: value.armed }
42
+ const point = value.point
43
+ if (!isReturnPoint(point)) return
44
+ return value as Checkpoint
45
+ }
46
+
47
+ /** Root-owner code replacement needs a new shell mount in this Harness version. */
48
+ export function beginRootReload(): { reloading: boolean, point?: ReturnPoint } {
49
+ if (window.__presmiumRootReload && window.__presmiumRootReload !== CODE) {
50
+ if (window.__presmiumRootReload === 'reloading') return { reloading: true }
51
+ try {
52
+ const saved = checkpoint()
53
+ if (!saved) throw new Error('missing root return checkpoint')
54
+ sessionStorage.setItem(KEY, JSON.stringify({ ...saved, armed: true } satisfies Checkpoint))
55
+ window.__presmiumRootReload = 'reloading'
56
+ window.location.reload()
57
+ } catch {
58
+ // Do not navigate away when recovery storage could not be confirmed.
59
+ console.error('Presmium could not preserve the root reload checkpoint; automatic reload was stopped.')
60
+ }
61
+ return { reloading: true }
62
+ }
63
+ window.__presmiumRootReload = CODE
64
+ const opened = readOpenDocument()
65
+ try {
66
+ const saved = checkpoint()
67
+ sessionStorage.removeItem(KEY)
68
+ const point = saved?.armed && saved.point ? saved.point : opened
69
+ return { reloading: false, ...point ? { point } : {} }
70
+ } catch {
71
+ return { reloading: false, ...opened ? { point: opened } : {} }
72
+ }
73
+ }
74
+
75
+ /** Keep a pending address through another reload; only explicit navigation or settlement retires it. */
76
+ function retainOpenDocument(workbench: Workbench, returning?: ReturnPoint): { open<T>(start: () => T): T, settle(): void, close(): void } {
77
+ let previous: string | undefined
78
+ let pending = Boolean(returning)
79
+ let opening = false, closed = false
80
+ const navigation = workbench.documentNavigationVersion
81
+ let remembered: ReturnPoint | undefined
82
+ const write = (point: ReturnPoint | undefined) => {
83
+ const next = point ? JSON.stringify(point) : ''
84
+ if (next === previous) return
85
+ try {
86
+ if (point) sessionStorage.setItem(DOCUMENT_KEY, next)
87
+ else sessionStorage.removeItem(DOCUMENT_KEY)
88
+ previous = next
89
+ remembered = point
90
+ } catch {
91
+ // Do not keep an earlier address after a later explicit choice could not be stored.
92
+ try { sessionStorage.removeItem(DOCUMENT_KEY) } catch { /* Editing does not depend on address storage. */ }
93
+ previous = undefined
94
+ remembered = undefined
95
+ }
96
+ }
97
+ if (returning) write({ workspace: returning.workspace, directory: returning.directory, documentId: returning.documentId })
98
+ const retain = () => {
99
+ if (closed || opening) return
100
+ const { document, authoring } = workbench.state
101
+ let point: ReturnPoint | undefined
102
+ if (document !== undefined && authoring?.directory === document && authoring.identity && authoring.content) {
103
+ try {
104
+ const identity: unknown = JSON.parse(authoring.identity)
105
+ if (Array.isArray(identity) && identity.length === 2 && typeof identity[0] === 'string' && identity[1] === authoring.content.id) {
106
+ point = { workspace: identity[0], directory: document, documentId: authoring.content.id }
107
+ }
108
+ } catch { /* An unknown identity cannot authorize opening another document. */ }
109
+ }
110
+ if (point) write(point)
111
+ else if (document === undefined) {
112
+ if (!pending || workbench.documentNavigationVersion !== navigation) write(undefined)
113
+ } else if (remembered?.directory !== document) write(undefined)
114
+ }
115
+ const stop = workbench.subscribe(retain)
116
+ retain()
117
+ return {
118
+ // Opening synchronously emits an intermediate Home while retiring the previous subscriptions.
119
+ open<T>(start: () => T): T { opening = true; try { return start() } finally { opening = false; retain() } },
120
+ settle() { if (!closed) { pending = false; retain() } },
121
+ close() { closed = true; stop() },
122
+ }
123
+ }
124
+
125
+ /** Store only a return address; document drafts keep their existing persistence and review rules. */
126
+ export function saveRootReturnPoint(workbench: Workbench): void {
127
+ const state = workbench.state, source = state.authoring
128
+ try {
129
+ if (source?.storageFailure) throw new Error('document recovery storage unavailable')
130
+ let point: ReturnPoint | undefined
131
+ if (state.document !== undefined && source?.identity && source.content) {
132
+ const identity: unknown = JSON.parse(source.identity)
133
+ if (Array.isArray(identity) && identity.length === 2 && typeof identity[0] === 'string' && identity[1] === source.content.id) {
134
+ const sectionId = state.position?.section, section = sectionId ? source.content.sections[sectionId] : undefined
135
+ const stepId = section?.stepOrder[state.position?.step ?? 0]
136
+ point = { workspace: identity[0], directory: state.document, documentId: source.content.id, ...section ? { sectionId, stepId } : {} }
137
+ }
138
+ }
139
+ sessionStorage.setItem(KEY, JSON.stringify({ version: 1, armed: false, ...point ? { point } : {} } satisfies Checkpoint))
140
+ } catch {
141
+ // A previous checkpoint must not authorize a reload after this save failed.
142
+ try { sessionStorage.removeItem(KEY) } catch { /* Storage is unavailable. */ }
143
+ }
144
+ }
145
+
146
+ /** Restore only within this navigation; final source adoption checks the expected workspace and identity. */
147
+ export function restoreRootReturnPoint(workbench: Workbench, point: ReturnPoint | undefined): () => void {
148
+ let active = true
149
+ const retaining = retainOpenDocument(workbench, point), navigation = workbench.documentNavigationVersion
150
+ const stop = workbench.subscribe(() => { if (workbench.documentNavigationVersion !== navigation) active = false })
151
+ const restore = async () => {
152
+ if (!point) return
153
+ const info = await workbench.client.readWorkbenchInfo()
154
+ if (!active || info.root !== point.workspace || workbench.state.document !== undefined) return
155
+ stop()
156
+ await retaining.open(() => workbench.openDocument(point.directory, {
157
+ expectedIdentity: JSON.stringify([point.workspace, point.documentId]),
158
+ ...point.sectionId ? { fallbackReturnPoint: { sectionId: point.sectionId, ...point.stepId ? { stepId: point.stepId } : {} } } : {},
159
+ }))
160
+ }
161
+ void restore().catch(() => { /* The normal home remains available when the return address no longer exists. */ }).finally(() => { stop(); retaining.settle() })
162
+ return () => { active = false; stop(); retaining.close() }
163
+ }