dsh-focus-overlay 0.1.0 → 0.4.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 +121 -0
- package/README.md +74 -53
- package/lib/client.js +440 -99
- package/package.json +5 -3
package/README.en.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
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
|
+

|
|
33
|
+
|
|
34
|
+
**On — focus mode**
|
|
35
|
+
|
|
36
|
+

|
|
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
|
+
| Plugin configuration card | A collapsible card under "Settings → Plugins → Plugin configuration": 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
|
+
> If `add` doesn't install the latest version, pnpm's `minimumReleaseAge` (minimum release age) security policy is at work: by default it won't resolve a version as `latest` within **24 hours** of publication, falling back to the previous stable release. To install the latest immediately, pin the version explicitly:
|
|
76
|
+
>
|
|
77
|
+
> ```sh
|
|
78
|
+
> dsh plugin --profile web add dsh-focus-overlay@<version>
|
|
79
|
+
> ```
|
|
80
|
+
>
|
|
81
|
+
> Or wait 24 hours, then re-run the plain `add` command.
|
|
82
|
+
|
|
83
|
+
**From GitHub**
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
dsh plugin --profile web add github:boogoo619/dsh-focus-overlay
|
|
87
|
+
dsh web
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
> 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`:
|
|
91
|
+
>
|
|
92
|
+
> ```yaml
|
|
93
|
+
> allowBuilds:
|
|
94
|
+
> dsh-focus-overlay: true
|
|
95
|
+
> ```
|
|
96
|
+
>
|
|
97
|
+
> 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>`).
|
|
98
|
+
|
|
99
|
+
Restart `dsh web` after install.
|
|
100
|
+
|
|
101
|
+
## Usage
|
|
102
|
+
|
|
103
|
+
1. Open a session and click the **"Focus"** button in the session header action row.
|
|
104
|
+
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.
|
|
105
|
+
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.
|
|
106
|
+
4. Press `Esc` or click "Exit focus" to return — the original UI is untouched.
|
|
107
|
+
|
|
108
|
+
## Settings
|
|
109
|
+
|
|
110
|
+
In the sidebar "Settings → Plugins → Plugin configuration", expand the "Focus Mode" card:
|
|
111
|
+
|
|
112
|
+
| Option | Description |
|
|
113
|
+
| --- | --- |
|
|
114
|
+
| Show right-side navbar | Toggle the nav dots (default on) |
|
|
115
|
+
| Preserve chat position on open | Open at your current reading position; off opens at the latest (default on) |
|
|
116
|
+
| Text width | 480–1200px slider for the reading column (default 760px) |
|
|
117
|
+
| 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) |
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
[MIT](./LICENSE)
|
package/README.md
CHANGED
|
@@ -1,56 +1,98 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">dsh-focus-overlay</h1>
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center">中文 | <a href="README.en.md">English</a></p>
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
|
|
16
|
+
## 功能
|
|
17
|
+
|
|
18
|
+
- **全屏显示** —— 覆盖整个界面,把纵向空间全部留给对话
|
|
19
|
+
- **隐藏标题栏和输入栏** —— 顶部只留一条极窄栏,底部输入区收起
|
|
20
|
+
- **折叠工具调用** —— 一轮 AI 回复折叠成一句话摘要
|
|
21
|
+
- **快速导航** —— 右侧节点导航条,悬停预览、点击跳转
|
|
22
|
+
- **自动专注与提醒** —— 回复完成后可自动进入专注并定位到你的提问;专注中收到新回复、或被 AI 提问等待时弹出提醒
|
|
23
|
+
|
|
24
|
+
这些功能专为**小屏幕**提供更多内容呈现空间:收起常驻的标题/输入区与工具步骤,让有限的屏幕尽可能多地展示对话。
|
|
25
|
+
|
|
26
|
+
## 效果
|
|
27
|
+
|
|
28
|
+
**关闭 —— 普通聊天视图**
|
|
29
|
+
|
|
30
|
+

|
|
31
|
+
|
|
32
|
+
**开启 —— 专注模式**
|
|
33
|
+
|
|
34
|
+

|
|
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
|
-
|
|
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
|
-
|
|
73
|
+
> 如果 `add` 装到的不是最新版本:这是 pnpm 的 `minimumReleaseAge`(最小发布年龄)安全机制在起作用——默认 **24 小时**内不会把刚发布的版本当作 `latest` 解析,而是回退到上一个稳定版。想立即装最新版,显式指定版本号即可:
|
|
74
|
+
>
|
|
75
|
+
> ```sh
|
|
76
|
+
> dsh plugin --profile web add dsh-focus-overlay@<版本号>
|
|
77
|
+
> ```
|
|
78
|
+
>
|
|
79
|
+
> 或者等满 24 小时,再执行不带版本号的 `add` 命令。
|
|
80
|
+
|
|
81
|
+
**从 GitHub**
|
|
30
82
|
|
|
31
83
|
```sh
|
|
32
84
|
dsh plugin --profile web add github:boogoo619/dsh-focus-overlay
|
|
33
85
|
dsh web
|
|
34
86
|
```
|
|
35
87
|
|
|
36
|
-
>
|
|
88
|
+
> git 安装拉取**源码**并由 `prepare` 脚本现场构建。pnpm ≥10 会先拒绝运行 `prepare`,首次 `add` 失败后,按 `dsh` 提示把包键复制进该 profile 的 `pnpm-workspace.yaml`:
|
|
37
89
|
>
|
|
38
90
|
> ```yaml
|
|
39
91
|
> allowBuilds:
|
|
40
92
|
> dsh-focus-overlay: true
|
|
41
93
|
> ```
|
|
42
94
|
>
|
|
43
|
-
> 然后重新执行 `add
|
|
44
|
-
|
|
45
|
-
### 从本地目录 / tarball
|
|
46
|
-
|
|
47
|
-
```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
|
|
53
|
-
```
|
|
95
|
+
> 然后重新执行 `add`。**该授权允许本包代码在安装时于你的机器上执行**——请只对可信来源授权,并锁定 commit(`github:boogoo619/dsh-focus-overlay#<sha>`)。
|
|
54
96
|
|
|
55
97
|
安装后重启 `dsh web` 生效。
|
|
56
98
|
|
|
@@ -58,41 +100,20 @@ dsh plugin --profile web add ./dsh-focus-overlay-0.1.0.tgz
|
|
|
58
100
|
|
|
59
101
|
1. 打开任意会话,点击标题栏操作区的 **「专注」** 按钮。
|
|
60
102
|
2. 进入全屏专注视图:顶部只有一条极窄栏(会话标题 + 退出),正文只显示你与 AI 的对话,工具步骤折叠为摘要行。
|
|
61
|
-
3.
|
|
103
|
+
3. 右侧导航圆点可悬停预览、点击跳转;离开底部时右下出现居中的「↓ 回到最新」。
|
|
62
104
|
4. 按 `Esc` 或点「退出专注」返回原界面,原位置/状态保持不变。
|
|
63
105
|
|
|
64
106
|
## 设置
|
|
65
107
|
|
|
66
|
-
侧栏「设置 →
|
|
108
|
+
侧栏「设置 → 插件 → 插件配置」中,展开「专注模式」卡片:
|
|
67
109
|
|
|
68
110
|
| 选项 | 说明 |
|
|
69
|
-
|
|
111
|
+
| --- | --- |
|
|
70
112
|
| 显示右侧导航条 | 开关导航圆点(默认开) |
|
|
71
|
-
| 打开时保留聊天位置 |
|
|
113
|
+
| 打开时保留聊天位置 | 进入时定位到当前阅读位置;关闭则定位到最新(默认开) |
|
|
72
114
|
| 文字区宽度 | 480–1200px 滑块,调整阅读列宽(默认 760px) |
|
|
115
|
+
| 回复结束后自动进入专注模式 | 回复正常完成后:关闭时自动进入并定位到提问,已打开时弹「新回复已生成」;AI 提问 / 审批等待时弹「AI 正在等待你的回复」(默认关) |
|
|
73
116
|
|
|
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
|
|
117
|
+
## 许可
|
|
97
118
|
|
|
98
119
|
[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/
|
|
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,145 @@ 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 a settled conversation snapshot (`running === false`) ends in
|
|
268
|
+
* a *normal* completed reply, and which user message started it.
|
|
269
|
+
*
|
|
270
|
+
* The caller owns the `running true → false` edge. `running` is relayed on a
|
|
271
|
+
* different stream than the conversation events, so the edge can fire before an
|
|
272
|
+
* aborted turn's `interrupted` node has landed — at that instant the tail is
|
|
273
|
+
* still held in `partial` and `nodes` is missing the final assistant (a
|
|
274
|
+
* finalized question message can then be mistaken for the tail). To close that
|
|
275
|
+
* race we require the snapshot to be *stable* first: while `partial` is non-null
|
|
276
|
+
* we return `settled: false` and the caller must re-evaluate on a later
|
|
277
|
+
* snapshot. Once stable, this walks backwards to the first turn-terminating node
|
|
278
|
+
* (`assistant` / `turn-error` / `turn-max-tokens`) and accepts only a finalized,
|
|
279
|
+
* non-interrupted assistant node.
|
|
280
|
+
*/
|
|
281
|
+
function detectSettledCompletion(snap) {
|
|
282
|
+
if (snap && snap.partial != null) return {
|
|
283
|
+
settled: false,
|
|
284
|
+
completed: false,
|
|
285
|
+
anchorSeq: null
|
|
286
|
+
};
|
|
287
|
+
const nodes = snap && snap.nodes ? snap.nodes : [];
|
|
288
|
+
let terminal = null;
|
|
289
|
+
for (let i = nodes.length - 1; i >= 0; i--) {
|
|
290
|
+
const n = nodes[i];
|
|
291
|
+
if (!n) continue;
|
|
292
|
+
if (n.kind === "assistant" || n.kind === "turn-error" || n.kind === "turn-max-tokens") {
|
|
293
|
+
terminal = n;
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (!terminal || terminal.kind !== "assistant" || terminal.interrupted) return {
|
|
298
|
+
settled: true,
|
|
299
|
+
completed: false,
|
|
300
|
+
anchorSeq: null
|
|
301
|
+
};
|
|
302
|
+
return {
|
|
303
|
+
settled: true,
|
|
304
|
+
completed: true,
|
|
305
|
+
anchorSeq: lastUserSeq(nodes)
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
/** True while the session has a pending interaction — the AI is blocked waiting
|
|
309
|
+
* for the user (to answer a question or approve an action). It stays true for
|
|
310
|
+
* the whole wait (`running` remains true throughout) and clears as soon as the
|
|
311
|
+
* user answers, so it is the live signal for "questions still unanswered". */
|
|
312
|
+
function hasPendingInteraction(snap) {
|
|
313
|
+
const pending = snap && snap.pending;
|
|
314
|
+
return Array.isArray(pending) && pending.length > 0;
|
|
315
|
+
}
|
|
316
|
+
//#endregion
|
|
317
|
+
//#region src/client/FocusView.tsx
|
|
318
|
+
let focusOn = false;
|
|
319
|
+
let pendingAnchorKey = null;
|
|
320
|
+
let pendingAutoSeq = null;
|
|
321
|
+
let donePing = 0;
|
|
322
|
+
const focusListeners = [];
|
|
323
|
+
const notify = () => {
|
|
324
|
+
for (const l of focusListeners) l();
|
|
325
|
+
};
|
|
326
|
+
const focusStore = {
|
|
327
|
+
get: () => focusOn,
|
|
328
|
+
set: (v) => {
|
|
329
|
+
focusOn = !!v;
|
|
330
|
+
notify();
|
|
331
|
+
},
|
|
332
|
+
subscribe: (l) => {
|
|
333
|
+
focusListeners.push(l);
|
|
334
|
+
return () => {
|
|
335
|
+
const i = focusListeners.indexOf(l);
|
|
336
|
+
if (i >= 0) focusListeners.splice(i, 1);
|
|
337
|
+
};
|
|
338
|
+
},
|
|
339
|
+
captureChatAnchor: () => {
|
|
340
|
+
pendingAnchorKey = null;
|
|
341
|
+
try {
|
|
342
|
+
const scrollport = document.querySelector("[data-conversation-scroll]");
|
|
343
|
+
if (!scrollport) return;
|
|
344
|
+
const vp = scrollport.getBoundingClientRect();
|
|
345
|
+
const rows = scrollport.querySelectorAll("[data-chat-flow-kind=\"user\"], [data-chat-flow-kind=\"steering\"]");
|
|
346
|
+
for (const row of rows) if (row.getBoundingClientRect().bottom > vp.top + 8) {
|
|
347
|
+
pendingAnchorKey = row.dataset.chatAnchorKey || null;
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
} catch {}
|
|
351
|
+
},
|
|
352
|
+
consumeAnchorKey: () => {
|
|
353
|
+
const k = pendingAnchorKey;
|
|
354
|
+
pendingAnchorKey = null;
|
|
355
|
+
return k;
|
|
356
|
+
},
|
|
357
|
+
setAutoAnchor: (seq) => {
|
|
358
|
+
pendingAutoSeq = seq;
|
|
359
|
+
},
|
|
360
|
+
consumeAutoSeq: () => {
|
|
361
|
+
const s = pendingAutoSeq;
|
|
362
|
+
pendingAutoSeq = null;
|
|
363
|
+
return s;
|
|
364
|
+
},
|
|
365
|
+
getDonePing: () => donePing,
|
|
366
|
+
notifyDone: () => {
|
|
367
|
+
donePing++;
|
|
368
|
+
notify();
|
|
369
|
+
},
|
|
370
|
+
clearDonePing: () => {
|
|
371
|
+
donePing = 0;
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
function useFocus() {
|
|
375
|
+
const [v, setV] = (0, react.useState)(focusStore.get);
|
|
376
|
+
(0, react.useEffect)(() => focusStore.subscribe(() => setV(focusStore.get)), []);
|
|
377
|
+
return v;
|
|
378
|
+
}
|
|
379
|
+
function useDonePing() {
|
|
380
|
+
const [v, setV] = (0, react.useState)(focusStore.getDonePing);
|
|
381
|
+
(0, react.useEffect)(() => focusStore.subscribe(() => setV(focusStore.getDonePing())), []);
|
|
382
|
+
return v;
|
|
383
|
+
}
|
|
384
|
+
let bodyEl = null;
|
|
385
|
+
const anchors = {};
|
|
288
386
|
function useSessionSnapshot(sessions, sessionId) {
|
|
289
387
|
const [snap, setSnap] = (0, react.useState)(() => {
|
|
290
388
|
if (!sessionId || !sessions) return null;
|
|
@@ -397,6 +495,21 @@ window.__ModuleLoader__.load({
|
|
|
397
495
|
});
|
|
398
496
|
};
|
|
399
497
|
(0, react.useEffect)(() => {
|
|
498
|
+
const autoSeq = focusStore.consumeAutoSeq();
|
|
499
|
+
if (autoSeq != null) {
|
|
500
|
+
const idx = findSeqIndex(items, autoSeq);
|
|
501
|
+
if (idx >= 0) {
|
|
502
|
+
scrollToKey("fm-" + idx, false);
|
|
503
|
+
setActiveKey("fm-" + idx);
|
|
504
|
+
} else if (bodyEl) {
|
|
505
|
+
bodyEl.scrollTo({
|
|
506
|
+
top: bodyEl.scrollHeight,
|
|
507
|
+
behavior: "auto"
|
|
508
|
+
});
|
|
509
|
+
setActiveKey(navKeys.length ? navKeys[navKeys.length - 1] : null);
|
|
510
|
+
}
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
400
513
|
const anchorKey = focusStore.consumeAnchorKey();
|
|
401
514
|
const seq = resolveAnchorSeq(snap && snap.chat, anchorKey);
|
|
402
515
|
const idx = seq != null ? findSeqIndex(items, seq) : -1;
|
|
@@ -509,17 +622,22 @@ window.__ModuleLoader__.load({
|
|
|
509
622
|
children: kids
|
|
510
623
|
});
|
|
511
624
|
}
|
|
625
|
+
const activeIndex = activeKey ? navKeys.indexOf(activeKey) : -1;
|
|
626
|
+
const navHeight = (navKeys.length - 1) * 14 + 22;
|
|
627
|
+
const dotTop = (i) => i * 14 + 11 + (i < activeIndex ? -7 : i > activeIndex ? 7 : 0);
|
|
512
628
|
const nav = prefs.navbar && navKeys.length >= 2 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
513
629
|
className: "fm-nav",
|
|
514
|
-
|
|
630
|
+
style: { height: navHeight },
|
|
631
|
+
children: navKeys.map((k, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
515
632
|
type: "button",
|
|
516
633
|
"aria-label": navPreviews[k],
|
|
517
634
|
className: "fm-nav-dot" + (activeKey === k ? " fm-nav-dot-active" : ""),
|
|
635
|
+
style: { top: dotTop(i) },
|
|
518
636
|
onClick: () => scrollToKey(k, true),
|
|
519
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
637
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "fm-nav-dot-core" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
520
638
|
className: "fm-nav-tip",
|
|
521
639
|
children: navPreviews[k]
|
|
522
|
-
})
|
|
640
|
+
})]
|
|
523
641
|
}, k))
|
|
524
642
|
}) : null;
|
|
525
643
|
const toBottom = !atBottom ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
@@ -533,6 +651,57 @@ window.__ModuleLoader__.load({
|
|
|
533
651
|
children: "↓"
|
|
534
652
|
})
|
|
535
653
|
}) : null;
|
|
654
|
+
const waiting = hasPendingInteraction(snap);
|
|
655
|
+
const donePing = useDonePing();
|
|
656
|
+
const [showDoneNotice, setShowDoneNotice] = (0, react.useState)(false);
|
|
657
|
+
(0, react.useEffect)(() => {
|
|
658
|
+
if (!donePing) return;
|
|
659
|
+
setShowDoneNotice(true);
|
|
660
|
+
const id = setTimeout(() => setShowDoneNotice(false), 6e3);
|
|
661
|
+
return () => clearTimeout(id);
|
|
662
|
+
}, [donePing]);
|
|
663
|
+
(0, react.useEffect)(() => () => focusStore.clearDonePing(), []);
|
|
664
|
+
const jumpToLatestQuestion = () => {
|
|
665
|
+
const idx = lastUserIndex(items);
|
|
666
|
+
if (idx >= 0) {
|
|
667
|
+
scrollToKey("fm-" + idx, true);
|
|
668
|
+
setActiveKey("fm-" + idx);
|
|
669
|
+
} else scrollToBottom();
|
|
670
|
+
};
|
|
671
|
+
const replyNotice = waiting ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
672
|
+
className: "fm-reply-toast",
|
|
673
|
+
children: [
|
|
674
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "fm-reply-toast-dot" }),
|
|
675
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
676
|
+
className: "fm-reply-toast-text",
|
|
677
|
+
children: t("reply.waiting")
|
|
678
|
+
}),
|
|
679
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
680
|
+
variant: "primary",
|
|
681
|
+
size: "sm",
|
|
682
|
+
onClick: () => focusStore.set(false),
|
|
683
|
+
children: t("reply.respond")
|
|
684
|
+
})
|
|
685
|
+
]
|
|
686
|
+
}) : showDoneNotice ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
687
|
+
className: "fm-reply-toast",
|
|
688
|
+
children: [
|
|
689
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "fm-reply-toast-dot" }),
|
|
690
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
691
|
+
className: "fm-reply-toast-text",
|
|
692
|
+
children: t("reply.ready")
|
|
693
|
+
}),
|
|
694
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
695
|
+
variant: "primary",
|
|
696
|
+
size: "sm",
|
|
697
|
+
onClick: () => {
|
|
698
|
+
setShowDoneNotice(false);
|
|
699
|
+
jumpToLatestQuestion();
|
|
700
|
+
},
|
|
701
|
+
children: t("reply.view")
|
|
702
|
+
})
|
|
703
|
+
]
|
|
704
|
+
}) : null;
|
|
536
705
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
537
706
|
className: "fm-overlay",
|
|
538
707
|
tabIndex: -1,
|
|
@@ -562,7 +731,8 @@ window.__ModuleLoader__.load({
|
|
|
562
731
|
children: body
|
|
563
732
|
}),
|
|
564
733
|
nav,
|
|
565
|
-
toBottom
|
|
734
|
+
toBottom,
|
|
735
|
+
replyNotice
|
|
566
736
|
]
|
|
567
737
|
});
|
|
568
738
|
}
|
|
@@ -582,43 +752,112 @@ window.__ModuleLoader__.load({
|
|
|
582
752
|
children: t("toggle")
|
|
583
753
|
});
|
|
584
754
|
}
|
|
585
|
-
function
|
|
755
|
+
function FocusSettingsCard({ t }) {
|
|
586
756
|
const prefs = usePrefs();
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
/* @__PURE__ */ (0, react_jsx_runtime.
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
757
|
+
const [open, setOpen] = (0, react.useState)(false);
|
|
758
|
+
const title = t("settings.label");
|
|
759
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
|
|
760
|
+
className: "fm-plugin-card" + (open ? " fm-plugin-card-open" : ""),
|
|
761
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
762
|
+
type: "button",
|
|
763
|
+
className: "fm-plugin-card-header",
|
|
764
|
+
"aria-expanded": open,
|
|
765
|
+
"aria-label": `${t(open ? "settings.collapse" : "settings.expand")}: ${title}`,
|
|
766
|
+
onClick: () => setOpen(!open),
|
|
767
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
768
|
+
className: "fm-plugin-card-headtext",
|
|
769
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
770
|
+
className: "fm-plugin-card-name",
|
|
771
|
+
children: title
|
|
772
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
773
|
+
className: "fm-plugin-card-desc",
|
|
774
|
+
children: t("settings.description")
|
|
775
|
+
})]
|
|
776
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, { className: "fm-plugin-card-chevron" + (open ? " fm-plugin-card-chevron-open" : "") })]
|
|
777
|
+
}), open ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
778
|
+
className: "fm-plugin-card-body",
|
|
779
|
+
children: [
|
|
780
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
781
|
+
className: "fm-plugin-field",
|
|
782
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
783
|
+
className: "fm-plugin-check",
|
|
784
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
785
|
+
type: "checkbox",
|
|
786
|
+
checked: prefs.navbar,
|
|
787
|
+
onChange: (e) => prefsStore.update({ navbar: e.target.checked })
|
|
788
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
789
|
+
className: "fm-plugin-check-label",
|
|
790
|
+
children: t("settings.navbar")
|
|
791
|
+
})]
|
|
792
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
793
|
+
className: "fm-plugin-field-hint",
|
|
794
|
+
children: t("settings.navbar.hint")
|
|
795
|
+
})]
|
|
796
|
+
}),
|
|
797
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
798
|
+
className: "fm-plugin-field",
|
|
799
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
800
|
+
className: "fm-plugin-check",
|
|
801
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
802
|
+
type: "checkbox",
|
|
803
|
+
checked: prefs.scroll === "preserve",
|
|
804
|
+
onChange: (e) => prefsStore.update({ scroll: e.target.checked ? "preserve" : "bottom" })
|
|
805
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
806
|
+
className: "fm-plugin-check-label",
|
|
807
|
+
children: t("settings.scrollPreserve")
|
|
808
|
+
})]
|
|
809
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
810
|
+
className: "fm-plugin-field-hint",
|
|
811
|
+
children: t("settings.scrollPreserve.hint")
|
|
812
|
+
})]
|
|
813
|
+
}),
|
|
814
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
815
|
+
className: "fm-plugin-field",
|
|
816
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
817
|
+
className: "fm-plugin-check",
|
|
818
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
819
|
+
type: "checkbox",
|
|
820
|
+
checked: prefs.autoFocus,
|
|
821
|
+
onChange: (e) => prefsStore.update({ autoFocus: e.target.checked })
|
|
822
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
823
|
+
className: "fm-plugin-check-label",
|
|
824
|
+
children: t("settings.autoFocus")
|
|
825
|
+
})]
|
|
826
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
827
|
+
className: "fm-plugin-field-hint",
|
|
828
|
+
children: t("settings.autoFocus.hint")
|
|
829
|
+
})]
|
|
830
|
+
}),
|
|
831
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
832
|
+
className: "fm-plugin-field",
|
|
833
|
+
children: [
|
|
834
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
835
|
+
className: "fm-plugin-field-head",
|
|
836
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
837
|
+
className: "fm-plugin-field-label",
|
|
838
|
+
children: t("settings.width")
|
|
839
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
840
|
+
className: "fm-plugin-field-value",
|
|
841
|
+
children: [prefs.width, "px"]
|
|
842
|
+
})]
|
|
843
|
+
}),
|
|
844
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
845
|
+
type: "range",
|
|
846
|
+
className: "fm-plugin-range",
|
|
847
|
+
min: 480,
|
|
848
|
+
max: 1200,
|
|
849
|
+
step: 40,
|
|
850
|
+
value: prefs.width,
|
|
851
|
+
onChange: (e) => prefsStore.update({ width: Number(e.target.value) })
|
|
852
|
+
}),
|
|
853
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
854
|
+
className: "fm-plugin-field-hint",
|
|
855
|
+
children: t("settings.width.hint")
|
|
856
|
+
})
|
|
857
|
+
]
|
|
858
|
+
})
|
|
859
|
+
]
|
|
860
|
+
}) : null]
|
|
622
861
|
});
|
|
623
862
|
}
|
|
624
863
|
//#endregion
|
|
@@ -640,18 +879,42 @@ window.__ModuleLoader__.load({
|
|
|
640
879
|
.fm-error{color:var(--dsw-alias-state-error-primary,#d23);font-size:13px;white-space:pre-wrap}
|
|
641
880
|
.fm-running{color:var(--dsw-alias-label-secondary,#888);font-size:13px;margin:6px 0 16px}
|
|
642
881
|
.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:
|
|
644
|
-
.fm-nav
|
|
645
|
-
.fm-nav-dot
|
|
646
|
-
.fm-nav-dot
|
|
647
|
-
.fm-nav-
|
|
882
|
+
.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}
|
|
883
|
+
.fm-nav:hover{transform:translateY(-50%) translateX(-6px)}
|
|
884
|
+
.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}
|
|
885
|
+
.fm-nav-dot::after{content:'';position:absolute;inset:-5px -8px}
|
|
886
|
+
.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}
|
|
887
|
+
.fm-nav:hover .fm-nav-dot:not(.fm-nav-dot-active) .fm-nav-dot-core{transform:scale(1.15);opacity:.9}
|
|
888
|
+
.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)}
|
|
889
|
+
.fm-nav-dot-active .fm-nav-dot-core{background:var(--dsw-static-deepseek-500,#4176e6);height:22px;opacity:1}
|
|
890
|
+
.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
891
|
.fm-nav-dot:hover .fm-nav-tip{display:block}
|
|
649
892
|
.fm-tobottom-wrap{position:absolute;left:50%;transform:translateX(-50%);bottom:18px;z-index:2;pointer-events:none}
|
|
650
893
|
.fm-tobottom{pointer-events:auto;border-radius:100px;min-width:34px;height:34px;padding:0 10px}
|
|
651
|
-
.fm-
|
|
652
|
-
.fm-
|
|
653
|
-
.fm-
|
|
654
|
-
.fm-
|
|
894
|
+
.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}
|
|
895
|
+
.fm-reply-toast-dot{width:8px;height:8px;border-radius:999px;background:var(--dsw-static-deepseek-500,#4176e6);flex:0 0 auto}
|
|
896
|
+
.fm-reply-toast-text{white-space:nowrap}
|
|
897
|
+
.fm-plugin-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}
|
|
898
|
+
.fm-plugin-card:hover{border-color:var(--dsw-alias-label-dimmed)}
|
|
899
|
+
.fm-plugin-card-open{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}
|
|
900
|
+
.fm-plugin-card-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}
|
|
901
|
+
.fm-plugin-card-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
|
|
902
|
+
.fm-plugin-card-headtext{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}
|
|
903
|
+
.fm-plugin-card-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}
|
|
904
|
+
.fm-plugin-card-desc{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}
|
|
905
|
+
.fm-plugin-card-chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}
|
|
906
|
+
.fm-plugin-card-chevron-open{transform:rotate(180deg)}
|
|
907
|
+
.fm-plugin-card-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:12px}
|
|
908
|
+
.fm-plugin-field{flex-direction:column;gap:6px;padding:12px 0;display:flex}
|
|
909
|
+
.fm-plugin-field+.fm-plugin-field{border-top:1px solid var(--dsw-alias-border-l2)}
|
|
910
|
+
.fm-plugin-field-head{align-items:center;gap:8px;display:flex}
|
|
911
|
+
.fm-plugin-field-label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}
|
|
912
|
+
.fm-plugin-field-value{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5}
|
|
913
|
+
.fm-plugin-field-hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}
|
|
914
|
+
.fm-plugin-check{align-items:center;gap:8px;display:flex;cursor:pointer;user-select:none}
|
|
915
|
+
.fm-plugin-check input[type="checkbox"]{width:16px;height:16px;margin:0;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#4176e6)}
|
|
916
|
+
.fm-plugin-check-label{color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5}
|
|
917
|
+
.fm-plugin-range{width:100%;max-width:280px;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#4176e6)}
|
|
655
918
|
`;
|
|
656
919
|
//#endregion
|
|
657
920
|
//#region src/client/locales.ts
|
|
@@ -662,12 +925,24 @@ window.__ModuleLoader__.load({
|
|
|
662
925
|
"exit": "退出专注 (Esc)",
|
|
663
926
|
"backToLatest": "回到最新",
|
|
664
927
|
"settings.label": "专注模式",
|
|
928
|
+
"settings.description": "全屏阅读遮罩的偏好设置",
|
|
929
|
+
"settings.expand": "展开",
|
|
930
|
+
"settings.collapse": "收起",
|
|
665
931
|
"settings.navbar": "显示右侧导航条",
|
|
932
|
+
"settings.navbar.hint": "在右侧显示用户消息导航圆点",
|
|
666
933
|
"settings.scrollPreserve": "打开时保留聊天位置",
|
|
934
|
+
"settings.scrollPreserve.hint": "打开时定位到当前阅读位置;关闭则定位到最新",
|
|
935
|
+
"settings.autoFocus": "回复结束后自动进入专注模式",
|
|
936
|
+
"settings.autoFocus.hint": "回复正常完成后自动打开;AI 提问 / 审批等待时弹出提醒",
|
|
667
937
|
"settings.width": "文字区宽度",
|
|
938
|
+
"settings.width.hint": "专注模式阅读列宽度(480–1200px)",
|
|
668
939
|
"empty": "暂无打开的会话",
|
|
669
940
|
"loading": "正在读取会话…",
|
|
670
941
|
"running": "· 正在工作 …",
|
|
942
|
+
"reply.ready": "新回复已生成",
|
|
943
|
+
"reply.view": "查看",
|
|
944
|
+
"reply.waiting": "AI 正在等待你的回复",
|
|
945
|
+
"reply.respond": "去回复",
|
|
671
946
|
"sum.context": "载入了 {n} 项上下文",
|
|
672
947
|
"sum.commands": "运行了 {n} 个命令",
|
|
673
948
|
"sum.edits": "编辑了 {n} 个文件",
|
|
@@ -690,12 +965,24 @@ window.__ModuleLoader__.load({
|
|
|
690
965
|
"exit": "Exit focus (Esc)",
|
|
691
966
|
"backToLatest": "Back to latest",
|
|
692
967
|
"settings.label": "Focus Mode",
|
|
968
|
+
"settings.description": "Preferences for the full-screen reading overlay",
|
|
969
|
+
"settings.expand": "Expand",
|
|
970
|
+
"settings.collapse": "Collapse",
|
|
693
971
|
"settings.navbar": "Show right-side navbar",
|
|
972
|
+
"settings.navbar.hint": "Show navigation dots for user messages on the right",
|
|
694
973
|
"settings.scrollPreserve": "Preserve chat position on open",
|
|
974
|
+
"settings.scrollPreserve.hint": "Open at your current reading position; off opens at the latest",
|
|
975
|
+
"settings.autoFocus": "Auto-enter focus mode when a reply completes",
|
|
976
|
+
"settings.autoFocus.hint": "Auto-open after a normal completion; toast while AI waits for you",
|
|
695
977
|
"settings.width": "Text width",
|
|
978
|
+
"settings.width.hint": "Reading column width (480–1200px)",
|
|
696
979
|
"empty": "No open session",
|
|
697
980
|
"loading": "Reading session…",
|
|
698
981
|
"running": "· Working …",
|
|
982
|
+
"reply.ready": "New reply ready",
|
|
983
|
+
"reply.view": "View",
|
|
984
|
+
"reply.waiting": "AI is waiting for your reply",
|
|
985
|
+
"reply.respond": "Reply",
|
|
699
986
|
"sum.context": "loaded {n} context items",
|
|
700
987
|
"sum.commands": "ran {n} commands",
|
|
701
988
|
"sum.edits": "edited {n} files",
|
|
@@ -717,7 +1004,8 @@ window.__ModuleLoader__.load({
|
|
|
717
1004
|
/**
|
|
718
1005
|
* Focus Mode, browser half: a full-screen reading overlay in `shell.overlay`
|
|
719
1006
|
* (additive, never replaces shipped UI), a "专注" button in the session header
|
|
720
|
-
* action row, and a
|
|
1007
|
+
* action row, and a collapsible plugin card in the Plugins settings tab
|
|
1008
|
+
* (`settings.plugin.item`). Rendering reuses the official
|
|
721
1009
|
* `@deepseek-ai/dsh-client-ui-primitives` Markdown/MessageText/Tooltip/Button
|
|
722
1010
|
* components and the conversation service's image resolver.
|
|
723
1011
|
*/
|
|
@@ -750,6 +1038,59 @@ window.__ModuleLoader__.load({
|
|
|
750
1038
|
const workspaces = ctx.workspaces;
|
|
751
1039
|
const conversation = ctx.get("conversation");
|
|
752
1040
|
const chatFileMentions = ctx.get("chatFileMentions");
|
|
1041
|
+
ctx.effect(() => {
|
|
1042
|
+
let currentId = void 0;
|
|
1043
|
+
let unsubSession = null;
|
|
1044
|
+
let prevRunning = false;
|
|
1045
|
+
let pendingSettle = false;
|
|
1046
|
+
const watch = (id) => {
|
|
1047
|
+
if (unsubSession) {
|
|
1048
|
+
unsubSession();
|
|
1049
|
+
unsubSession = null;
|
|
1050
|
+
}
|
|
1051
|
+
prevRunning = false;
|
|
1052
|
+
pendingSettle = false;
|
|
1053
|
+
if (id == null) return;
|
|
1054
|
+
const binding = sessions.binding(id);
|
|
1055
|
+
if (!binding) return;
|
|
1056
|
+
const face = binding.session;
|
|
1057
|
+
const onSnap = () => {
|
|
1058
|
+
const snap = face.getSnapshot();
|
|
1059
|
+
if (!snap) return;
|
|
1060
|
+
const running = !!snap.running;
|
|
1061
|
+
if (prevRunning && !running) pendingSettle = true;
|
|
1062
|
+
if (running) pendingSettle = false;
|
|
1063
|
+
if (pendingSettle && !running) {
|
|
1064
|
+
const outcome = detectSettledCompletion(snap);
|
|
1065
|
+
if (outcome.settled) {
|
|
1066
|
+
pendingSettle = false;
|
|
1067
|
+
if (outcome.completed && prefsStore.get().autoFocus) if (focusStore.get()) focusStore.notifyDone();
|
|
1068
|
+
else {
|
|
1069
|
+
focusStore.setAutoAnchor(outcome.anchorSeq);
|
|
1070
|
+
focusStore.set(true);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
prevRunning = running;
|
|
1075
|
+
};
|
|
1076
|
+
onSnap();
|
|
1077
|
+
unsubSession = face.subscribe(onSnap);
|
|
1078
|
+
};
|
|
1079
|
+
const onList = () => {
|
|
1080
|
+
const ls = sessions.list.getSnapshot();
|
|
1081
|
+
const next = ls ? ls.current : void 0;
|
|
1082
|
+
if (next !== currentId) {
|
|
1083
|
+
currentId = next;
|
|
1084
|
+
watch(next);
|
|
1085
|
+
}
|
|
1086
|
+
};
|
|
1087
|
+
onList();
|
|
1088
|
+
const unsubList = sessions.list.subscribe(onList);
|
|
1089
|
+
return () => {
|
|
1090
|
+
if (unsubList) unsubList();
|
|
1091
|
+
if (unsubSession) unsubSession();
|
|
1092
|
+
};
|
|
1093
|
+
});
|
|
753
1094
|
ctx.slots.inject("shell.overlay", () => ctx.slots.register({
|
|
754
1095
|
name: "shell.overlay",
|
|
755
1096
|
id: "focus-mode-overlay",
|
|
@@ -768,12 +1109,12 @@ window.__ModuleLoader__.load({
|
|
|
768
1109
|
order: 1e3,
|
|
769
1110
|
label: () => t("toggle")
|
|
770
1111
|
}, () => (0, react.createElement)(FocusToggle, { t })));
|
|
771
|
-
ctx.slots.inject("settings.
|
|
772
|
-
name: "settings.
|
|
773
|
-
id: "focus-
|
|
774
|
-
order:
|
|
1112
|
+
ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({
|
|
1113
|
+
name: "settings.plugin.item",
|
|
1114
|
+
id: "dsh-focus-overlay",
|
|
1115
|
+
order: 1e3,
|
|
775
1116
|
label: () => t("settings.label")
|
|
776
|
-
}, () => (0, react.createElement)(
|
|
1117
|
+
}, () => (0, react.createElement)(FocusSettingsCard, { t })));
|
|
777
1118
|
}
|
|
778
1119
|
};
|
|
779
1120
|
return module.exports;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-focus-overlay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|