dsh-focus-overlay 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md ADDED
@@ -0,0 +1,120 @@
1
+ <h1 align="center">dsh-focus-overlay</h1>
2
+
3
+ <p align="center">
4
+ <a href="README.md">中文</a> | English
5
+ </p>
6
+
7
+ <p align="center">
8
+ A <b>focus mode</b> for the DeepSeek Harness (DSH) web GUI: one click to a full-screen reading view that hides the header and composer and folds the AI tool-call flow into a one-line summary — leaving only the conversation between you and the AI.<br>
9
+ Text and image rendering reuse the official primitives, matching the chat view 1:1.
10
+ </p>
11
+
12
+ <p align="center">
13
+ <img src="https://badgen.net/npm/v/dsh-focus-overlay" alt="npm version">
14
+ <img src="https://badgen.net/badge/license/MIT/green" alt="license">
15
+ <img src="https://badgen.net/badge/dsh/%3E%3D0.1.0-rc.5/blue" alt="dsh version">
16
+ </p>
17
+
18
+ ## Features
19
+
20
+ - **Full-screen display** — covers the whole UI, giving all vertical space to the conversation
21
+ - **Hide the header and composer** — a thin top bar remains, the input area is tucked away
22
+ - **Fold tool calls** — an assistant turn collapses into a one-line summary
23
+ - **Quick navigation** — a right-side node navbar with hover preview and click-to-jump
24
+ - **Auto-focus & reminders** — after a reply completes, optionally auto-enter focus at your question; while in focus, get a toast for a new reply or when the AI is waiting on you
25
+
26
+ These features are designed to give **small screens** more content space: tucking away the persistent header/composer and tool steps lets the conversation use as much of the screen as possible.
27
+
28
+ ## Screenshots
29
+
30
+ **Off — the normal chat view**
31
+
32
+ ![Off: the normal chat view](screenshots/before.png)
33
+
34
+ **On — focus mode**
35
+
36
+ ![On: focus mode](screenshots/after.png)
37
+
38
+ <!-- Drop your screenshots into screenshots/:
39
+ - before.png — the normal chat view (header / composer / tool cards)
40
+ - after.png — focus mode (full-screen overlay + summary line + right-side navbar)
41
+ Optionally add navbar.png as a navbar close-up. -->
42
+
43
+ Once in focus mode, an assistant turn no longer shows every step — it folds into a single summary line:
44
+
45
+ > ran 2 commands, edited 3 files, read 5 files, ran 1 search
46
+
47
+ ## Capabilities
48
+
49
+ | Feature | Description |
50
+ | --- | --- |
51
+ | Full-screen overlay | Registered into `shell.overlay` (additive, `replaceRisk: none`) — covers header / composer / sidebar, giving all vertical space to the conversation |
52
+ | Official rendering | Assistant text through official `MarkdownText` (GFM + code highlighting + TeX); user messages through `MessageText`; user bubble uses the official blue, borderless style |
53
+ | Image resolution | Assistant `image` blocks resolve through `conversation.resolveImage`, rendering session-authorized images 1:1 |
54
+ | Tool-call folding | Tool calls / commands / context injections fold into a categorized summary line (commands / edits / searches / reads / directory listings / subagents / todos / goals / workflows / skills / questions / plans / background jobs) |
55
+ | Precise scroll preservation | Opens at the message you're reading in the chat (aligned by `seq`), not from the top |
56
+ | Right-side node navbar | One dot per user message; active pill follows scroll, hover preview, click to jump, auto-hidden under 2 messages |
57
+ | Back to latest | A centered "↓ back to latest" floating button appears when you scroll away from the bottom |
58
+ | File mentions | Wires `chatFileMentions`, turning inline-code tokens that name real files into clickable links |
59
+ | i18n | Chinese / English copy, follows the UI language |
60
+ | Settings page | Navbar toggle, open-position strategy, text-area width — persisted to `localStorage` |
61
+ | Auto-enter focus | After a reply **completes normally**, auto-open focus at your question; abnormal endings (stop / error / max-tokens / interrupt) never fire |
62
+ | Reply / waiting reminders | In focus, a "New reply ready + View" toast on completion; an "AI is waiting for your reply + Reply" toast on a question/approval, auto-cleared once answered |
63
+
64
+ ## Install
65
+
66
+ Requires the `dsh` CLI (`>= 0.1.0-rc.5`).
67
+
68
+ **From npm (recommended)**
69
+
70
+ ```sh
71
+ dsh plugin --profile web add dsh-focus-overlay
72
+ dsh web
73
+ ```
74
+
75
+ **From GitHub**
76
+
77
+ ```sh
78
+ dsh plugin --profile web add github:boogoo619/dsh-focus-overlay
79
+ dsh web
80
+ ```
81
+
82
+ > A git install pulls the **source** and builds it in place via the `prepare` script. pnpm ≥10 refuses to run `prepare` until you allow it; the first `add` fails, then follow the `dsh` hint and add the exact package key to that profile's `pnpm-workspace.yaml`:
83
+ >
84
+ > ```yaml
85
+ > allowBuilds:
86
+ > dsh-focus-overlay: true
87
+ > ```
88
+ >
89
+ > Then re-run `add`. **This authorization lets the package's code run on your machine at install time** — only grant it to sources you trust, and pin the commit (`github:boogoo619/dsh-focus-overlay#<sha>`).
90
+
91
+ **Local directory / tarball**
92
+
93
+ ```sh
94
+ dsh plugin --profile web add ./dsh-focus-overlay # local directory
95
+ dsh plugin --profile web add ./dsh-focus-overlay-0.1.0.tgz # tarball
96
+ ```
97
+
98
+ Restart `dsh web` after install.
99
+
100
+ ## Usage
101
+
102
+ 1. Open a session and click the **"Focus"** button in the session header action row.
103
+ 2. You enter the full-screen focus view: a thin top bar (session title + exit) and the conversation only, with tool steps folded into summary lines.
104
+ 3. Hover the right-side dots to preview, click to jump; a centered "↓ back to latest" button appears when you're not at the bottom.
105
+ 4. Press `Esc` or click "Exit focus" to return — the original UI is untouched.
106
+
107
+ ## Settings
108
+
109
+ Sidebar "Settings → Focus Mode":
110
+
111
+ | Option | Description |
112
+ | --- | --- |
113
+ | Show right-side navbar | Toggle the nav dots (default on) |
114
+ | Preserve chat position on open | Open at your current reading position; off opens at the latest (default on) |
115
+ | Text width | 480–1200px slider for the reading column (default 760px) |
116
+ | Auto-enter focus mode when a reply completes | On a normal completion: auto-open at your question when closed, or a "New reply ready" toast when open; an "AI is waiting" toast on a question/approval (default off) |
117
+
118
+ ## License
119
+
120
+ [MIT](./LICENSE)
package/README.md CHANGED
@@ -1,55 +1,96 @@
1
- # dsh-focus-overlay
1
+ <h1 align="center">dsh-focus-overlay</h1>
2
2
 
3
- DeepSeek Harness(DSH)Web GUI 提供的**专注模式**插件:一键进入全屏阅读视图,隐藏标题区与输入区,把 AI 的工具调用流程折叠成一句话摘要,只保留"你与 AI 的对话"本身。文本与图片渲染直接复用官方的 `@deepseek-ai/dsh-client-ui-primitives` 原语,与聊天视图 1:1 一致。
3
+ <p align="center">中文 | <a href="README.en.md">English</a></p>
4
4
 
5
- 这是一个 **bundle 插件**(区别于会话内临时动态插件),能 `import` 官方 Markdown 渲染器、解析会话授权图片,并持久化设置。
5
+ <p align="center">
6
+ 为 DeepSeek Harness(DSH)Web GUI 提供的<b>专注模式</b>:一键全屏阅读,隐藏标题区与输入区,把 AI 的工具调用流程折叠成一句话摘要,只保留「你与 AI 的对话」本身。<br>
7
+ 文本与图片渲染复用官方原语,与聊天视图 1:1 一致。
8
+ </p>
6
9
 
7
- ## 特性
10
+ <p align="center">
11
+ <img src="https://badgen.net/npm/v/dsh-focus-overlay" alt="npm version">
12
+ <img src="https://badgen.net/badge/license/MIT/green" alt="license">
13
+ <img src="https://badgen.net/badge/dsh/%3E%3D0.1.0-rc.5/blue" alt="dsh version">
14
+ </p>
8
15
 
9
- - **全屏专注遮罩**:通过 `shell.overlay` 注册(纯增量,绝不替换/遮挡自带 UI 的数据),覆盖整个界面,纵向空间全留给对话。
10
- - **官方渲染**:AI 文本走官方 `MarkdownText`(GFM + 代码高亮 + TeX),用户消息走 `MessageText`,图片走 `conversation.resolveImage`。
11
- - **工具调用折叠**:工具调用 / 命令 / 上下文注入折叠成分类摘要行("运行了 2 个命令,编辑了 3 个文件,读取了 5 个文件…")。
12
- - **精确保留滚动位置**:进入专注时定位到聊天视图中当前正在阅读的消息,而非从头开始。
13
- - **右侧节点导航条**:每个 user 消息一个圆点,激活药丸跟随滚动、悬停预览、点击跳转。
14
- - **回到最新**:离开底部时显示居中的"↓ 回到最新"悬浮按钮。
15
- - **i18n**:中 / 英文案,跟随界面语言。
16
- - **设置页**:显示导航条、打开时保留位置、文字区宽度(可拖滑块),持久化到 `localStorage`。
16
+ ## 功能
17
+
18
+ - **全屏显示** —— 覆盖整个界面,把纵向空间全部留给对话
19
+ - **隐藏标题栏和输入栏** —— 顶部只留一条极窄栏,底部输入区收起
20
+ - **折叠工具调用** —— 一轮 AI 回复折叠成一句话摘要
21
+ - **快速导航** —— 右侧节点导航条,悬停预览、点击跳转
22
+ - **自动专注与提醒** —— 回复完成后可自动进入专注并定位到你的提问;专注中收到新回复、或被 AI 提问等待时弹出提醒
23
+
24
+ 这些功能专为**小屏幕**提供更多内容呈现空间:收起常驻的标题/输入区与工具步骤,让有限的屏幕尽可能多地展示对话。
25
+
26
+ ## 效果
27
+
28
+ **关闭 —— 普通聊天视图**
29
+
30
+ ![关闭:普通聊天视图](screenshots/before.png)
31
+
32
+ **开启 —— 专注模式**
33
+
34
+ ![开启:专注模式](screenshots/after.png)
35
+
36
+ <!-- 截图请放到 screenshots/ 目录:
37
+ - before.png —— 普通聊天视图(含标题区/输入区/工具卡)
38
+ - after.png —— 专注模式(全屏遮罩 + 摘要行 + 右侧导航条)
39
+ 可再补一张 navbar.png 作为导航条特写。 -->
40
+
41
+ 进入专注后,一轮 AI 回复不再逐个展示步骤,而是折叠成一行摘要:
42
+
43
+ > 运行了 2 个命令,编辑了 3 个文件,读取了 5 个文件,搜索了 1 个正则
44
+
45
+ ## 能力
46
+
47
+ | 功能 | 说明 |
48
+ | --- | --- |
49
+ | 全屏专注遮罩 | 经 `shell.overlay` 注册(纯增量、`replaceRisk: none`),覆盖标题区/输入区/侧栏,纵向空间尽给对话 |
50
+ | 官方渲染 | AI 文本走官方 `MarkdownText`(GFM + 代码高亮 + TeX),用户消息走 `MessageText`,用户气泡用官方蓝底无描边样式 |
51
+ | 图片解析 | assistant 的 `image` 块经 `conversation.resolveImage` 解析,会话授权图片 1:1 显示 |
52
+ | 工具调用折叠 | 工具调用 / 命令 / 上下文注入按类型计数折叠成摘要行(命令/编辑/搜索/读取/列目录/子代理/待办/目标/工作流/技能/提问/计划/后台任务) |
53
+ | 精确保留位置 | 进入专注时定位到聊天视图中正在阅读的消息(按 `seq` 对齐),而非从头开始 |
54
+ | 右侧节点导航条 | 每个 user 消息一个圆点,激活药丸跟随滚动、悬停预览、点击平滑跳转,少于 2 条自动隐藏 |
55
+ | 回到最新 | 离开底部时显示居中的「↓ 回到最新」悬浮按钮 |
56
+ | 文件提及 | 接 `chatFileMentions`,内联代码命中真实文件时变成可点链接 |
57
+ | i18n | 中 / 英文案,跟随界面语言 |
58
+ | 设置页 | 导航条开关、打开定位策略、文字区宽度,持久化到 `localStorage` |
59
+ | 自动进入专注 | 回复**正常完成**后自动打开专注并定位到本轮你的提问;异常结束(停止 / 报错 / 超 token / 打断)不触发 |
60
+ | 回复 / 等待提醒 | 专注中回复完成弹「新回复已生成 + 查看」;AI 提问 / 审批等待时弹「AI 正在等待你的回复 + 去回复」,回答完自动消失 |
17
61
 
18
62
  ## 安装
19
63
 
20
64
  需要 `dsh` CLI(`>= 0.1.0-rc.5`)。
21
65
 
22
- ### npm
66
+ **从 npm(推荐)**
23
67
 
24
68
  ```sh
25
69
  dsh plugin --profile web add dsh-focus-overlay
26
70
  dsh web
27
71
  ```
28
72
 
29
- ### GitHub
73
+ **从 GitHub**
30
74
 
31
75
  ```sh
32
76
  dsh plugin --profile web add github:boogoo619/dsh-focus-overlay
33
77
  dsh web
34
78
  ```
35
79
 
36
- > git 安装会拉取**源码**并由 `prepare` 脚本现场构建。pnpm ≥10 会先拒绝运行 `prepare`,首次 `add` 失败后,按 `dsh` 提示把包键复制进该 profile 的 `pnpm-workspace.yaml` 授权:
80
+ > git 安装拉取**源码**并由 `prepare` 脚本现场构建。pnpm ≥10 会先拒绝运行 `prepare`,首次 `add` 失败后,按 `dsh` 提示把包键复制进该 profile 的 `pnpm-workspace.yaml`:
37
81
  >
38
82
  > ```yaml
39
83
  > allowBuilds:
40
84
  > dsh-focus-overlay: true
41
85
  > ```
42
86
  >
43
- > 然后重新执行 `add`。**这项授权意味着允许本包代码在你的机器上于安装时执行**——请只对可信来源授权,并用 commit 锁定(`github:boogoo619/dsh-focus-overlay#<sha>`)。
87
+ > 然后重新执行 `add`。**该授权允许本包代码在安装时于你的机器上执行**——请只对可信来源授权,并锁定 commit(`github:boogoo619/dsh-focus-overlay#<sha>`)。
44
88
 
45
- ### 从本地目录 / tarball
89
+ **本地目录 / tarball**
46
90
 
47
91
  ```sh
48
- # 本地目录(开发)
49
- dsh plugin --profile web add ./dsh-focus-overlay
50
-
51
- # tarball(npm pack 产物)
52
- dsh plugin --profile web add ./dsh-focus-overlay-0.1.0.tgz
92
+ dsh plugin --profile web add ./dsh-focus-overlay # 本地目录
93
+ dsh plugin --profile web add ./dsh-focus-overlay-0.1.0.tgz # tarball
53
94
  ```
54
95
 
55
96
  安装后重启 `dsh web` 生效。
@@ -58,7 +99,7 @@ dsh plugin --profile web add ./dsh-focus-overlay-0.1.0.tgz
58
99
 
59
100
  1. 打开任意会话,点击标题栏操作区的 **「专注」** 按钮。
60
101
  2. 进入全屏专注视图:顶部只有一条极窄栏(会话标题 + 退出),正文只显示你与 AI 的对话,工具步骤折叠为摘要行。
61
- 3. 右侧的导航圆点可悬停预览、点击跳转;离开底部时右下出现居中的"↓ 回到最新"。
102
+ 3. 右侧导航圆点可悬停预览、点击跳转;离开底部时右下出现居中的「↓ 回到最新」。
62
103
  4. 按 `Esc` 或点「退出专注」返回原界面,原位置/状态保持不变。
63
104
 
64
105
  ## 设置
@@ -66,33 +107,12 @@ dsh plugin --profile web add ./dsh-focus-overlay-0.1.0.tgz
66
107
  侧栏「设置 → 专注模式」:
67
108
 
68
109
  | 选项 | 说明 |
69
- |---|---|
110
+ | --- | --- |
70
111
  | 显示右侧导航条 | 开关导航圆点(默认开) |
71
- | 打开时保留聊天位置 | 进入专注时定位到当前阅读位置,关闭则定位到最新(默认开) |
112
+ | 打开时保留聊天位置 | 进入时定位到当前阅读位置;关闭则定位到最新(默认开) |
72
113
  | 文字区宽度 | 480–1200px 滑块,调整阅读列宽(默认 760px) |
114
+ | 回复结束后自动进入专注模式 | 回复正常完成后:关闭时自动进入并定位到提问,已打开时弹「新回复已生成」;AI 提问 / 审批等待时弹「AI 正在等待你的回复」(默认关) |
73
115
 
74
- ## 构建与开发
75
-
76
- ```sh
77
- npm install
78
- npm run build # 产出 lib/index.mjs(Node half)+ lib/client.js(浏览器 bundle)
79
- ```
80
-
81
- 构建产物 `lib/` 不入库;npm 发布与 git 安装时由 `prepare` 脚本现场构建。
82
-
83
- ## 目录结构
84
-
85
- ```
86
- src/index.mjs Node half(空 apply)
87
- src/client/index.ts client apply:注入样式、注册 overlay / 按钮 / 设置页
88
- src/client/FocusView.tsx 核心组件(MarkdownText/MessageText、图片、摘要、导航条)
89
- src/client/styles.ts 包内样式(仅 --dsw-* token)
90
- src/client/settings.ts 偏好(localStorage)
91
- src/client/locales.ts 中/英词典
92
- cordis.patch.yml 组合层补丁
93
- tsdown.config.ts 双 half 构建(Node ESM + 浏览器 __ModuleLoader__ CJS)
94
- ```
95
-
96
- ## License
116
+ ## 许可
97
117
 
98
118
  [MIT](./LICENSE)
package/lib/client.js CHANGED
@@ -11,7 +11,8 @@ window.__ModuleLoader__.load({
11
11
  const DEFAULTS = {
12
12
  navbar: true,
13
13
  scroll: "preserve",
14
- width: 760
14
+ width: 760,
15
+ autoFocus: false
15
16
  };
16
17
  function load() {
17
18
  try {
@@ -52,49 +53,7 @@ window.__ModuleLoader__.load({
52
53
  return p;
53
54
  }
54
55
  //#endregion
55
- //#region src/client/FocusView.tsx
56
- let focusOn = false;
57
- let pendingAnchorKey = null;
58
- const focusListeners = [];
59
- const focusStore = {
60
- get: () => focusOn,
61
- set: (v) => {
62
- focusOn = !!v;
63
- for (const l of focusListeners) l();
64
- },
65
- subscribe: (l) => {
66
- focusListeners.push(l);
67
- return () => {
68
- const i = focusListeners.indexOf(l);
69
- if (i >= 0) focusListeners.splice(i, 1);
70
- };
71
- },
72
- captureChatAnchor: () => {
73
- pendingAnchorKey = null;
74
- try {
75
- const scrollport = document.querySelector("[data-conversation-scroll]");
76
- if (!scrollport) return;
77
- const vp = scrollport.getBoundingClientRect();
78
- const rows = scrollport.querySelectorAll("[data-chat-flow-kind=\"user\"], [data-chat-flow-kind=\"steering\"]");
79
- for (const row of rows) if (row.getBoundingClientRect().bottom > vp.top + 8) {
80
- pendingAnchorKey = row.dataset.chatAnchorKey || null;
81
- return;
82
- }
83
- } catch {}
84
- },
85
- consumeAnchorKey: () => {
86
- const k = pendingAnchorKey;
87
- pendingAnchorKey = null;
88
- return k;
89
- }
90
- };
91
- function useFocus() {
92
- const [v, setV] = (0, react.useState)(focusStore.get);
93
- (0, react.useEffect)(() => focusStore.subscribe(() => setV(focusStore.get)), []);
94
- return v;
95
- }
96
- let bodyEl = null;
97
- const anchors = {};
56
+ //#region src/client/model.ts
98
57
  function flattenText(content) {
99
58
  if (!content || !content.length) return "";
100
59
  let out = "";
@@ -285,6 +244,132 @@ window.__ModuleLoader__.load({
285
244
  }
286
245
  return -1;
287
246
  }
247
+ /** Seq of the message that started the latest turn: the last `user` node, or a
248
+ * `steering` node as a fallback when no user message exists (e.g. a
249
+ * command-only session). Returns null when neither is present. */
250
+ function lastUserSeq(nodes) {
251
+ let user = null;
252
+ let steering = null;
253
+ for (const n of nodes || []) {
254
+ if (!n || typeof n.seq !== "number") continue;
255
+ if (n.kind === "user") user = n.seq;
256
+ else if (n.kind === "steering") steering = n.seq;
257
+ }
258
+ return user != null ? user : steering;
259
+ }
260
+ /** Index of the last user item in an already-built focus list (toast "View"
261
+ * jump target); -1 when absent. */
262
+ function lastUserIndex(items) {
263
+ for (let i = items.length - 1; i >= 0; i--) if (items[i].kind === "user") return i;
264
+ return -1;
265
+ }
266
+ /**
267
+ * Decide whether an already-settled conversation snapshot (`running === false`)
268
+ * ends in a *normal* completed reply, and which user message started it.
269
+ *
270
+ * The caller owns the `running true → false` edge; this is the pure
271
+ * "is the tail a finished reply?" predicate: it walks backwards to the first
272
+ * turn-terminating node (`assistant` / `turn-error` / `turn-max-tokens`), then
273
+ * accepts only a finalized, non-interrupted assistant node.
274
+ */
275
+ function detectSettledCompletion(snap) {
276
+ const nodes = snap && snap.nodes ? snap.nodes : [];
277
+ let terminal = null;
278
+ for (let i = nodes.length - 1; i >= 0; i--) {
279
+ const n = nodes[i];
280
+ if (!n) continue;
281
+ if (n.kind === "assistant" || n.kind === "turn-error" || n.kind === "turn-max-tokens") {
282
+ terminal = n;
283
+ break;
284
+ }
285
+ }
286
+ if (!terminal || terminal.kind !== "assistant" || terminal.interrupted) return {
287
+ completed: false,
288
+ anchorSeq: null
289
+ };
290
+ return {
291
+ completed: true,
292
+ anchorSeq: lastUserSeq(nodes)
293
+ };
294
+ }
295
+ /** True while the session has a pending interaction — the AI is blocked waiting
296
+ * for the user (to answer a question or approve an action). It stays true for
297
+ * the whole wait (`running` remains true throughout) and clears as soon as the
298
+ * user answers, so it is the live signal for "questions still unanswered". */
299
+ function hasPendingInteraction(snap) {
300
+ const pending = snap && snap.pending;
301
+ return Array.isArray(pending) && pending.length > 0;
302
+ }
303
+ //#endregion
304
+ //#region src/client/FocusView.tsx
305
+ let focusOn = false;
306
+ let pendingAnchorKey = null;
307
+ let pendingAutoSeq = null;
308
+ let donePing = 0;
309
+ const focusListeners = [];
310
+ const notify = () => {
311
+ for (const l of focusListeners) l();
312
+ };
313
+ const focusStore = {
314
+ get: () => focusOn,
315
+ set: (v) => {
316
+ focusOn = !!v;
317
+ notify();
318
+ },
319
+ subscribe: (l) => {
320
+ focusListeners.push(l);
321
+ return () => {
322
+ const i = focusListeners.indexOf(l);
323
+ if (i >= 0) focusListeners.splice(i, 1);
324
+ };
325
+ },
326
+ captureChatAnchor: () => {
327
+ pendingAnchorKey = null;
328
+ try {
329
+ const scrollport = document.querySelector("[data-conversation-scroll]");
330
+ if (!scrollport) return;
331
+ const vp = scrollport.getBoundingClientRect();
332
+ const rows = scrollport.querySelectorAll("[data-chat-flow-kind=\"user\"], [data-chat-flow-kind=\"steering\"]");
333
+ for (const row of rows) if (row.getBoundingClientRect().bottom > vp.top + 8) {
334
+ pendingAnchorKey = row.dataset.chatAnchorKey || null;
335
+ return;
336
+ }
337
+ } catch {}
338
+ },
339
+ consumeAnchorKey: () => {
340
+ const k = pendingAnchorKey;
341
+ pendingAnchorKey = null;
342
+ return k;
343
+ },
344
+ setAutoAnchor: (seq) => {
345
+ pendingAutoSeq = seq;
346
+ },
347
+ consumeAutoSeq: () => {
348
+ const s = pendingAutoSeq;
349
+ pendingAutoSeq = null;
350
+ return s;
351
+ },
352
+ getDonePing: () => donePing,
353
+ notifyDone: () => {
354
+ donePing++;
355
+ notify();
356
+ },
357
+ clearDonePing: () => {
358
+ donePing = 0;
359
+ }
360
+ };
361
+ function useFocus() {
362
+ const [v, setV] = (0, react.useState)(focusStore.get);
363
+ (0, react.useEffect)(() => focusStore.subscribe(() => setV(focusStore.get)), []);
364
+ return v;
365
+ }
366
+ function useDonePing() {
367
+ const [v, setV] = (0, react.useState)(focusStore.getDonePing);
368
+ (0, react.useEffect)(() => focusStore.subscribe(() => setV(focusStore.getDonePing())), []);
369
+ return v;
370
+ }
371
+ let bodyEl = null;
372
+ const anchors = {};
288
373
  function useSessionSnapshot(sessions, sessionId) {
289
374
  const [snap, setSnap] = (0, react.useState)(() => {
290
375
  if (!sessionId || !sessions) return null;
@@ -397,6 +482,21 @@ window.__ModuleLoader__.load({
397
482
  });
398
483
  };
399
484
  (0, react.useEffect)(() => {
485
+ const autoSeq = focusStore.consumeAutoSeq();
486
+ if (autoSeq != null) {
487
+ const idx = findSeqIndex(items, autoSeq);
488
+ if (idx >= 0) {
489
+ scrollToKey("fm-" + idx, false);
490
+ setActiveKey("fm-" + idx);
491
+ } else if (bodyEl) {
492
+ bodyEl.scrollTo({
493
+ top: bodyEl.scrollHeight,
494
+ behavior: "auto"
495
+ });
496
+ setActiveKey(navKeys.length ? navKeys[navKeys.length - 1] : null);
497
+ }
498
+ return;
499
+ }
400
500
  const anchorKey = focusStore.consumeAnchorKey();
401
501
  const seq = resolveAnchorSeq(snap && snap.chat, anchorKey);
402
502
  const idx = seq != null ? findSeqIndex(items, seq) : -1;
@@ -509,17 +609,22 @@ window.__ModuleLoader__.load({
509
609
  children: kids
510
610
  });
511
611
  }
612
+ const activeIndex = activeKey ? navKeys.indexOf(activeKey) : -1;
613
+ const navHeight = (navKeys.length - 1) * 14 + 22;
614
+ const dotTop = (i) => i * 14 + 11 + (i < activeIndex ? -7 : i > activeIndex ? 7 : 0);
512
615
  const nav = prefs.navbar && navKeys.length >= 2 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
513
616
  className: "fm-nav",
514
- children: navKeys.map((k) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
617
+ style: { height: navHeight },
618
+ children: navKeys.map((k, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
515
619
  type: "button",
516
620
  "aria-label": navPreviews[k],
517
621
  className: "fm-nav-dot" + (activeKey === k ? " fm-nav-dot-active" : ""),
622
+ style: { top: dotTop(i) },
518
623
  onClick: () => scrollToKey(k, true),
519
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
624
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "fm-nav-dot-core" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
520
625
  className: "fm-nav-tip",
521
626
  children: navPreviews[k]
522
- })
627
+ })]
523
628
  }, k))
524
629
  }) : null;
525
630
  const toBottom = !atBottom ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -533,6 +638,57 @@ window.__ModuleLoader__.load({
533
638
  children: "↓"
534
639
  })
535
640
  }) : null;
641
+ const waiting = hasPendingInteraction(snap);
642
+ const donePing = useDonePing();
643
+ const [showDoneNotice, setShowDoneNotice] = (0, react.useState)(false);
644
+ (0, react.useEffect)(() => {
645
+ if (!donePing) return;
646
+ setShowDoneNotice(true);
647
+ const id = setTimeout(() => setShowDoneNotice(false), 6e3);
648
+ return () => clearTimeout(id);
649
+ }, [donePing]);
650
+ (0, react.useEffect)(() => () => focusStore.clearDonePing(), []);
651
+ const jumpToLatestQuestion = () => {
652
+ const idx = lastUserIndex(items);
653
+ if (idx >= 0) {
654
+ scrollToKey("fm-" + idx, true);
655
+ setActiveKey("fm-" + idx);
656
+ } else scrollToBottom();
657
+ };
658
+ const replyNotice = waiting ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
659
+ className: "fm-reply-toast",
660
+ children: [
661
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "fm-reply-toast-dot" }),
662
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
663
+ className: "fm-reply-toast-text",
664
+ children: t("reply.waiting")
665
+ }),
666
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
667
+ variant: "primary",
668
+ size: "sm",
669
+ onClick: () => focusStore.set(false),
670
+ children: t("reply.respond")
671
+ })
672
+ ]
673
+ }) : showDoneNotice ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
674
+ className: "fm-reply-toast",
675
+ children: [
676
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "fm-reply-toast-dot" }),
677
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
678
+ className: "fm-reply-toast-text",
679
+ children: t("reply.ready")
680
+ }),
681
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
682
+ variant: "primary",
683
+ size: "sm",
684
+ onClick: () => {
685
+ setShowDoneNotice(false);
686
+ jumpToLatestQuestion();
687
+ },
688
+ children: t("reply.view")
689
+ })
690
+ ]
691
+ }) : null;
536
692
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
537
693
  className: "fm-overlay",
538
694
  tabIndex: -1,
@@ -562,7 +718,8 @@ window.__ModuleLoader__.load({
562
718
  children: body
563
719
  }),
564
720
  nav,
565
- toBottom
721
+ toBottom,
722
+ replyNotice
566
723
  ]
567
724
  });
568
725
  }
@@ -603,6 +760,14 @@ window.__ModuleLoader__.load({
603
760
  onChange: (e) => prefsStore.update({ scroll: e.target.checked ? "preserve" : "bottom" })
604
761
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("settings.scrollPreserve") })]
605
762
  }),
763
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
764
+ className: "fm-setting-row",
765
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
766
+ type: "checkbox",
767
+ checked: prefs.autoFocus,
768
+ onChange: (e) => prefsStore.update({ autoFocus: e.target.checked })
769
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("settings.autoFocus") })]
770
+ }),
606
771
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
607
772
  className: "fm-setting-row",
608
773
  children: [
@@ -640,14 +805,21 @@ window.__ModuleLoader__.load({
640
805
  .fm-error{color:var(--dsw-alias-state-error-primary,#d23);font-size:13px;white-space:pre-wrap}
641
806
  .fm-running{color:var(--dsw-alias-label-secondary,#888);font-size:13px;margin:6px 0 16px}
642
807
  .fm-empty{color:var(--dsw-alias-label-secondary,#888);padding:48px 16px;text-align:center}
643
- .fm-nav{position:absolute;right:20px;top:50%;transform:translateY(-50%);display:flex;flex-direction:column;align-items:center;gap:5px;z-index:2;pointer-events:auto}
644
- .fm-nav-dot{position:relative;cursor:pointer;background:var(--dsw-alias-label-secondary,#999);border:0;border-radius:999px;width:8px;height:8px;padding:0;transition:background .15s,height .15s}
645
- .fm-nav-dot:hover{background:var(--dsw-alias-brand-primary,#4c6ef5)}
646
- .fm-nav-dot-active{background:var(--dsw-alias-brand-primary,#4c6ef5);height:22px;width:8px}
647
- .fm-nav-tip{position:absolute;right:18px;top:50%;transform:translateY(-50%);display:none;width:260px;box-sizing:border-box;white-space:normal;overflow-wrap:break-word;word-break:break-word;background:var(--dsw-alias-bg-overlay,#fff);color:var(--dsw-alias-label-primary,#1a1a1a);border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.12));border-radius:8px;padding:8px 10px;font-size:12px;line-height:1.5;box-shadow:0 4px 16px rgba(0,0,0,.12);pointer-events:none;text-align:left}
808
+ .fm-nav{position:absolute;right:20px;top:50%;transform:translateY(-50%);display:block;width:16px;z-index:2;pointer-events:auto;transition:transform .18s ease}
809
+ .fm-nav:hover{transform:translateY(-50%) translateX(-6px)}
810
+ .fm-nav-dot{position:absolute;left:50%;transform:translate(-50%,-50%);cursor:pointer;background:transparent;border:0;padding:0;width:16px;height:auto;display:flex;align-items:center;justify-content:center;transition:top .22s ease}
811
+ .fm-nav-dot::after{content:'';position:absolute;inset:-5px -8px}
812
+ .fm-nav-dot-core{display:block;width:8px;height:8px;border-radius:999px;background:var(--dsw-alias-label-secondary,#999);opacity:.45;transition:opacity .15s ease,background .15s ease,height .15s ease,transform .18s ease}
813
+ .fm-nav:hover .fm-nav-dot:not(.fm-nav-dot-active) .fm-nav-dot-core{transform:scale(1.15);opacity:.9}
814
+ .fm-nav-dot:not(.fm-nav-dot-active):hover .fm-nav-dot-core{background:var(--dsw-static-deepseek-500,#4176e6);opacity:1;transform:scale(1.5)}
815
+ .fm-nav-dot-active .fm-nav-dot-core{background:var(--dsw-static-deepseek-500,#4176e6);height:22px;opacity:1}
816
+ .fm-nav-tip{position:absolute;right:calc(100% + 8px);top:50%;transform:translateY(-50%);display:none;width:260px;box-sizing:border-box;white-space:normal;overflow-wrap:break-word;word-break:break-word;background:var(--dsw-alias-bg-overlay,#fff);color:var(--dsw-alias-label-primary,#1a1a1a);border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.12));border-radius:8px;padding:8px 10px;font-size:12px;line-height:1.5;box-shadow:0 4px 16px rgba(0,0,0,.12);pointer-events:none;text-align:left}
648
817
  .fm-nav-dot:hover .fm-nav-tip{display:block}
649
818
  .fm-tobottom-wrap{position:absolute;left:50%;transform:translateX(-50%);bottom:18px;z-index:2;pointer-events:none}
650
819
  .fm-tobottom{pointer-events:auto;border-radius:100px;min-width:34px;height:34px;padding:0 10px}
820
+ .fm-reply-toast{position:absolute;left:50%;transform:translateX(-50%);bottom:64px;z-index:2;display:flex;align-items:center;gap:10px;background:var(--dsw-alias-bg-overlay,#fff);color:var(--dsw-alias-label-primary,#1a1a1a);border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.12));border-radius:100px;padding:8px 8px 8px 16px;box-shadow:0 4px 16px rgba(0,0,0,.12);font-size:13px}
821
+ .fm-reply-toast-dot{width:8px;height:8px;border-radius:999px;background:var(--dsw-static-deepseek-500,#4176e6);flex:0 0 auto}
822
+ .fm-reply-toast-text{white-space:nowrap}
651
823
  .fm-settings{display:flex;flex-direction:column;gap:16px;max-width:560px;padding:8px 0}
652
824
  .fm-setting-row{display:flex;align-items:center;gap:10px;font-size:14px;cursor:pointer;user-select:none}
653
825
  .fm-setting-row input[type="checkbox"]{width:16px;height:16px;cursor:pointer}
@@ -664,10 +836,15 @@ window.__ModuleLoader__.load({
664
836
  "settings.label": "专注模式",
665
837
  "settings.navbar": "显示右侧导航条",
666
838
  "settings.scrollPreserve": "打开时保留聊天位置",
839
+ "settings.autoFocus": "回复结束后自动进入专注模式",
667
840
  "settings.width": "文字区宽度",
668
841
  "empty": "暂无打开的会话",
669
842
  "loading": "正在读取会话…",
670
843
  "running": "· 正在工作 …",
844
+ "reply.ready": "新回复已生成",
845
+ "reply.view": "查看",
846
+ "reply.waiting": "AI 正在等待你的回复",
847
+ "reply.respond": "去回复",
671
848
  "sum.context": "载入了 {n} 项上下文",
672
849
  "sum.commands": "运行了 {n} 个命令",
673
850
  "sum.edits": "编辑了 {n} 个文件",
@@ -692,10 +869,15 @@ window.__ModuleLoader__.load({
692
869
  "settings.label": "Focus Mode",
693
870
  "settings.navbar": "Show right-side navbar",
694
871
  "settings.scrollPreserve": "Preserve chat position on open",
872
+ "settings.autoFocus": "Auto-enter focus mode when a reply completes",
695
873
  "settings.width": "Text width",
696
874
  "empty": "No open session",
697
875
  "loading": "Reading session…",
698
876
  "running": "· Working …",
877
+ "reply.ready": "New reply ready",
878
+ "reply.view": "View",
879
+ "reply.waiting": "AI is waiting for your reply",
880
+ "reply.respond": "Reply",
699
881
  "sum.context": "loaded {n} context items",
700
882
  "sum.commands": "ran {n} commands",
701
883
  "sum.edits": "edited {n} files",
@@ -750,6 +932,52 @@ window.__ModuleLoader__.load({
750
932
  const workspaces = ctx.workspaces;
751
933
  const conversation = ctx.get("conversation");
752
934
  const chatFileMentions = ctx.get("chatFileMentions");
935
+ ctx.effect(() => {
936
+ let currentId = void 0;
937
+ let unsubSession = null;
938
+ let prevRunning = false;
939
+ const watch = (id) => {
940
+ if (unsubSession) {
941
+ unsubSession();
942
+ unsubSession = null;
943
+ }
944
+ prevRunning = false;
945
+ if (id == null) return;
946
+ const binding = sessions.binding(id);
947
+ if (!binding) return;
948
+ const face = binding.session;
949
+ const onSnap = () => {
950
+ const snap = face.getSnapshot();
951
+ if (!snap) return;
952
+ const running = !!snap.running;
953
+ if (prevRunning && !running) {
954
+ const outcome = detectSettledCompletion(snap);
955
+ if (outcome.completed && prefsStore.get().autoFocus) if (focusStore.get()) focusStore.notifyDone();
956
+ else {
957
+ focusStore.setAutoAnchor(outcome.anchorSeq);
958
+ focusStore.set(true);
959
+ }
960
+ }
961
+ prevRunning = running;
962
+ };
963
+ onSnap();
964
+ unsubSession = face.subscribe(onSnap);
965
+ };
966
+ const onList = () => {
967
+ const ls = sessions.list.getSnapshot();
968
+ const next = ls ? ls.current : void 0;
969
+ if (next !== currentId) {
970
+ currentId = next;
971
+ watch(next);
972
+ }
973
+ };
974
+ onList();
975
+ const unsubList = sessions.list.subscribe(onList);
976
+ return () => {
977
+ if (unsubList) unsubList();
978
+ if (unsubSession) unsubSession();
979
+ };
980
+ });
753
981
  ctx.slots.inject("shell.overlay", () => ctx.slots.register({
754
982
  name: "shell.overlay",
755
983
  id: "focus-mode-overlay",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-focus-overlay",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Focus Mode for the dsh web GUI: a full-screen reading overlay that hides chrome and folds the AI tool-call flow into summaries, reusing the official Markdown / image primitives.",
5
5
  "type": "module",
6
6
  "main": "lib/index.mjs",
@@ -37,7 +37,8 @@
37
37
  },
38
38
  "scripts": {
39
39
  "build": "tsdown --config ./tsdown.config.ts",
40
- "prepare": "npm run build"
40
+ "prepare": "npm run build",
41
+ "test": "vitest run"
41
42
  },
42
43
  "keywords": [
43
44
  "dsh",
@@ -47,7 +48,8 @@
47
48
  "bundle"
48
49
  ],
49
50
  "devDependencies": {
50
- "tsdown": "0.22.2"
51
+ "tsdown": "0.22.2",
52
+ "vitest": "^4.1.10"
51
53
  },
52
54
  "repository": {
53
55
  "type": "git",