dsh-tab-status-light 0.0.0-stage → 1.1.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/LICENSE +21 -0
- package/README.md +219 -2
- package/client.js +442 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +7 -0
- package/index.js +6 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +55 -4
- package/preview.png +0 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kyle Fu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,220 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-tab-status-light
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Put the AI session state on your browser tab icon** — a plugin for the [DeepSeek Harness](https://github.com/deepseek-ai) Web UI.
|
|
4
|
+
|
|
5
|
+
[English](#english) · [繁體中文](#繁體中文)
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## English
|
|
12
|
+
|
|
13
|
+
While a turn runs, the tab icon blinks gray; when the turn finishes it turns green;
|
|
14
|
+
while the session waits for you it turns yellow; when a turn is interrupted it turns red.
|
|
15
|
+
You can tell the state of the agent at a glance — even from another tab — without
|
|
16
|
+
switching back to the app.
|
|
17
|
+
|
|
18
|
+
| State | Color | Animation | Derived from |
|
|
19
|
+
| --- | --- | --- | --- |
|
|
20
|
+
| Working | gray `#9ca3af` | blinks every 600 ms: **the plain whale** ↔ whale + gray dot (the off half has no dot at all) | `SessionStatus.running === true` |
|
|
21
|
+
| Done | green `#22c55e` | lit for 10 s, then back to the plain whale | a turn **seen running here** whose last `turn/end` reason is not an interruption |
|
|
22
|
+
| Waiting for you | yellow `#eab308` | stays lit until you answer | `SessionStatus.pendingInteraction !== undefined` (question / approval / plan review) |
|
|
23
|
+
| Interrupted | red `#ef4444` | lit for 20 s, then back to the plain whale | a turn **seen running here** whose last `turn/end` reason is `aborted` / `blocked` / `error` / `interrupted` |
|
|
24
|
+
|
|
25
|
+
The shipped whale is left untouched when no session is selected, when the session has
|
|
26
|
+
not finished a turn yet, or when you merely **switch to a session that finished long ago**
|
|
27
|
+
— green/red are news about something that just happened, not a replay of history.
|
|
28
|
+
`SETTLE_MS` (green, 10 s) and `SETTLE_MS_RED` (red, 20 s) control how long a settled
|
|
29
|
+
badge stays; any new finished turn lights it again.
|
|
30
|
+
|
|
31
|
+
### Install
|
|
32
|
+
|
|
33
|
+
The plugin ships as a DSH bundle (`dsh.bundle.patch`). Install it with the harness
|
|
34
|
+
`plugin_manager`:
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
plugin_manager({ action: "install_bundle", target: "dsh-tab-status-light" })
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or from the CLI:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
dsh plugin --profile web add dsh-tab-status-light
|
|
44
|
+
# straight from GitHub instead of npm
|
|
45
|
+
dsh plugin --profile web add github:kyle2241345145351324321/dsh-tab-status-light
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Or point at a checkout (handy while developing — edits apply on page reload):
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
plugin_manager({ action: "install_bundle", target: "/path/to/dsh-tab-status-light" })
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Reload the page (`Cmd+R` / `Ctrl+R`) after installing.
|
|
55
|
+
|
|
56
|
+
> Requires the harness Web profile with the `@deepseek-ai/dsh-client-ui-session` and
|
|
57
|
+
> `@deepseek-ai/dsh-api-session-controller` client packages available (a standard
|
|
58
|
+
> install has both).
|
|
59
|
+
|
|
60
|
+
### How it works
|
|
61
|
+
|
|
62
|
+
- **Pure Client plugin, occupies no slot**: it reads `ctx.sessions.list` (which session
|
|
63
|
+
is the main view) and `ctx.uiSession.sessionStatus` (running / pending interaction),
|
|
64
|
+
and follows the session's event source for the newest `turn/end` reason.
|
|
65
|
+
- The icon is **redrawn from the live favicon**: it `fetch()`es the shipped
|
|
66
|
+
`favicon.svg` / `favicon-dark.svg`, splices one `<circle>` in before `</svg>`, and
|
|
67
|
+
swaps the `href` of `<link rel="icon">` to a data URL (Chrome repaints the tab icon).
|
|
68
|
+
The original `href` is restored on teardown.
|
|
69
|
+
- Because of that, the whale artwork is never copied into this plugin — upstream logo
|
|
70
|
+
changes carry over automatically.
|
|
71
|
+
- Badge geometry inside the shipped 50×50 viewBox: `cx=37, cy=37, r=11, stroke-width=4`;
|
|
72
|
+
the ring uses the tab background (dark `#35363a`, light `#ffffff`) to separate the dot
|
|
73
|
+
from the mark.
|
|
74
|
+
- Blinking is driven by a tiny Web Worker, so it **keeps blinking while the tab is in
|
|
75
|
+
the background** (Chrome throttles page timers in hidden tabs). It falls back to a
|
|
76
|
+
page timer where workers are unavailable.
|
|
77
|
+
- Both blink halves are precomputed data URLs, so every swap paints on the tick — no
|
|
78
|
+
file re-read, which is what made the pulse look uneven in earlier versions.
|
|
79
|
+
|
|
80
|
+
### Tuning
|
|
81
|
+
|
|
82
|
+
All at the top of [`client.js`](client.js):
|
|
83
|
+
|
|
84
|
+
| Constant | Default | Meaning |
|
|
85
|
+
| --- | --- | --- |
|
|
86
|
+
| `BADGE` | `{cx:37, cy:37, radius:11, ring:4}` | dot position / radius / ring width |
|
|
87
|
+
| `BADGE_COLOR` | gray / green / yellow / red | state colors |
|
|
88
|
+
| `BLINK_MS` | `600` | blink period while working |
|
|
89
|
+
| `SETTLE_MS` | `10000` | how long the green badge stays |
|
|
90
|
+
| `SETTLE_MS_RED` | `20000` | how long the red badge stays |
|
|
91
|
+
| `RED_TURN_END_KINDS` | `aborted` `blocked` `error` `interrupted` | turn endings that count as interruptions |
|
|
92
|
+
|
|
93
|
+
### Resource use
|
|
94
|
+
|
|
95
|
+
Blinking costs about **2 ms of JS per second** (≈0.2 % of one core); when idle it costs
|
|
96
|
+
nothing — no timers, no rescans.
|
|
97
|
+
|
|
98
|
+
### Known limits
|
|
99
|
+
|
|
100
|
+
- The icon reflects the **main view** session only; background sessions are not shown.
|
|
101
|
+
- Switching to a session that finished earlier does not flash green (only a turn watched
|
|
102
|
+
running lights up).
|
|
103
|
+
- Blinking continues in a background tab, but a **frozen** tab (Chrome Memory Saver /
|
|
104
|
+
battery saver) stops all page JS — no web API can bypass that. To keep it alive, add the
|
|
105
|
+
origin to `chrome://settings/performance` → "Always keep these sites active".
|
|
106
|
+
`navigator.wakeLock` does **not** help: it only keeps the screen awake and is released
|
|
107
|
+
as soon as the page is hidden.
|
|
108
|
+
- When a frozen or backgrounded tab wakes up, the plugin resyncs and repaints immediately,
|
|
109
|
+
so the icon is always correct when you look at it.
|
|
110
|
+
- It swaps SVG data URLs into `<link rel="icon">`; very old browsers without SVG favicon
|
|
111
|
+
support will show no change.
|
|
112
|
+
|
|
113
|
+
### Development
|
|
114
|
+
|
|
115
|
+
```js
|
|
116
|
+
// this repo *is* the installable bundle — there is no build step
|
|
117
|
+
plugin_manager({ action: "install_bundle", target: "/absolute/path/to/dsh-tab-status-light" })
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Edit `client.js`, then reload the page: the Client module is served from disk.
|
|
121
|
+
|
|
122
|
+
### License
|
|
123
|
+
|
|
124
|
+
[MIT](LICENSE)
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## 繁體中文
|
|
129
|
+
|
|
130
|
+
**把 AI 的狀態畫在瀏覽器分頁圖示上** —— 一個給 DeepSeek Harness Web UI 用的外掛。
|
|
131
|
+
|
|
132
|
+
AI 在做事時分頁圖示會**灰色一閃一閃**;這一輪完成變**綠**;在等你回答(提問/核准/計畫審查)
|
|
133
|
+
時變**黃**;這一輪被打斷變成**紅**。切到別的分頁也看得到現在是什麼狀態,不用切回來確認。
|
|
134
|
+
|
|
135
|
+
| 狀態 | 顏色 | 動畫 | 判定依據 |
|
|
136
|
+
| --- | --- | --- | --- |
|
|
137
|
+
| 進行中 | 灰 `#9ca3af` | 每 600 ms 切換:**純鯨魚** ↔ 鯨魚+灰點(暗的那半完全沒有圓點) | `SessionStatus.running === true` |
|
|
138
|
+
| 完成 | 綠 `#22c55e` | 亮 10 秒後回到純鯨魚 | 這一輪**被看到在跑**,且最後一個 `turn/end` 原因不是中斷類 |
|
|
139
|
+
| 等你回答 | 黃 `#eab308` | 一直亮著直到你回答 | `SessionStatus.pendingInteraction !== undefined`(提問/核准/計畫審查) |
|
|
140
|
+
| 中斷 | 紅 `#ef4444` | 亮 20 秒後回到純鯨魚 | 這一輪**被看到在跑**,且最後一個 `turn/end` 原因是 `aborted`/`blocked`/`error`/`interrupted` |
|
|
141
|
+
|
|
142
|
+
沒選 session、該 session 還沒跑完任何一輪、或只是**切到一個早就結束的 session** 時,
|
|
143
|
+
不會動原本的鯨魚圖示 —— 綠/紅是「剛剛發生的事」的通知,不會把歷史重播一次。
|
|
144
|
+
綠燈停留由 `SETTLE_MS`(10 秒)控制、紅燈由 `SETTLE_MS_RED`(20 秒)控制,
|
|
145
|
+
任何新的一輪結束都會讓燈重新亮起。
|
|
146
|
+
|
|
147
|
+
### 安裝
|
|
148
|
+
|
|
149
|
+
外掛以 DSH bundle 形式發佈(`dsh.bundle.patch`),用 harness 的 `plugin_manager` 安裝:
|
|
150
|
+
|
|
151
|
+
```js
|
|
152
|
+
plugin_manager({ action: "install_bundle", target: "dsh-tab-status-light" })
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
或用 CLI:
|
|
156
|
+
|
|
157
|
+
```sh
|
|
158
|
+
dsh plugin --profile web add dsh-tab-status-light
|
|
159
|
+
# 也可以直接從 GitHub 安裝(不走 npm)
|
|
160
|
+
dsh plugin --profile web add github:kyle2241345145351324321/dsh-tab-status-light
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
開發時直接指到資料夾(改檔後重新整理就生效):
|
|
164
|
+
|
|
165
|
+
```js
|
|
166
|
+
plugin_manager({ action: "install_bundle", target: "/path/to/dsh-tab-status-light" })
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
安裝後重新整理頁面(`Cmd+R`/`Ctrl+R`)。
|
|
170
|
+
|
|
171
|
+
> 需要 harness 的 Web profile,且具備 `@deepseek-ai/dsh-client-ui-session` 與
|
|
172
|
+
> `@deepseek-ai/dsh-api-session-controller` 這兩個 Client 套件(標準安裝即有)。
|
|
173
|
+
|
|
174
|
+
### 運作方式
|
|
175
|
+
|
|
176
|
+
- **純 Client 外掛、不佔用任何 slot**:讀 `ctx.sessions.list`(哪個 session 是主視窗)與
|
|
177
|
+
`ctx.uiSession.sessionStatus`(running/pendingInteraction),並訂閱該 session 的
|
|
178
|
+
event source 取得最新的 `turn/end` 原因。
|
|
179
|
+
- 圖示是**即時重繪**的:`fetch()` 原本的 `favicon.svg`/`favicon-dark.svg`,在 `</svg>`
|
|
180
|
+
前插入一顆 `<circle>`,把 `<link rel="icon">` 的 `href` 換成 data URL(Chrome 會跟著
|
|
181
|
+
重畫分頁圖示),unmount 時還原原始 `href`。
|
|
182
|
+
- 因此鯨魚圖案不會被複製一份到外掛裡,上游換 logo 也會自動跟上。
|
|
183
|
+
- 圓點幾何(50×50 viewBox 內):`cx=37, cy=37, r=11, stroke-width=4`;
|
|
184
|
+
外圈用分頁底色(深色 `#35363a`、淺色 `#ffffff`)把圓點跟鯨魚隔開。
|
|
185
|
+
- 閃爍由一個小型 Web Worker 計時,**分頁切到背景仍會閃**(Chrome 會節流隱藏分頁的
|
|
186
|
+
頁面計時器);不支援 Worker 時自動退回頁面計時器。
|
|
187
|
+
- 閃爍的兩幀都是預先算好的 data URL,所以每一次切換都在節拍上完成 —— 早期版本會回去
|
|
188
|
+
讀原始圖檔,那一幀就會慢半拍,看起來忽長忽短。
|
|
189
|
+
|
|
190
|
+
### 可調參數
|
|
191
|
+
|
|
192
|
+
都在 [`client.js`](client.js) 檔頭(`BADGE`、`BADGE_COLOR`、`BLINK_MS`、`SETTLE_MS`、
|
|
193
|
+
`SETTLE_MS_RED`、`RED_TURN_END_KINDS`),說明同上表。
|
|
194
|
+
|
|
195
|
+
### 資源用量
|
|
196
|
+
|
|
197
|
+
閃爍中約 **2 ms JS/秒**(單核約 0.2%);閒置時 0(沒有計時器、沒有重新掃描)。
|
|
198
|
+
|
|
199
|
+
### 已知限制
|
|
200
|
+
|
|
201
|
+
- 圖示只反映**主視窗**那個 session;其他背景 session 不會顯示。
|
|
202
|
+
- 切換到「早就跑完」的 session 不會亮綠燈(只亮真的看到跑完的那一輪)。
|
|
203
|
+
- 分頁在背景仍會閃;但被**凍結**(Chrome Memory Saver/省電模式)時任何網頁的 JS 都會停,
|
|
204
|
+
沒有 API 可以繞過。想讓它一直閃,把網址加進 `chrome://settings/performance` →
|
|
205
|
+
「永遠讓這些網站保持運作」。`navigator.wakeLock` **沒有用**:它只防螢幕休眠,分頁一隱藏就被釋放。
|
|
206
|
+
- 分頁解凍/切回前景時會立刻重新同步並重畫,所以看到的時候一定是正確狀態。
|
|
207
|
+
- 用 SVG data URL 換 `href`,不支援 SVG favicon 的舊瀏覽器看不到變化。
|
|
208
|
+
|
|
209
|
+
### 開發
|
|
210
|
+
|
|
211
|
+
```js
|
|
212
|
+
// 這個 repo 就是可直接安裝的 bundle,沒有 build step
|
|
213
|
+
plugin_manager({ action: "install_bundle", target: "/absolute/path/to/dsh-tab-status-light" })
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
改完 `client.js` 後在瀏覽器重新載入頁面即可生效(Client 模組是即時從磁碟供應的)。
|
|
217
|
+
|
|
218
|
+
### License
|
|
219
|
+
|
|
220
|
+
[MIT](LICENSE)
|
package/client.js
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tab status light.
|
|
3
|
+
*
|
|
4
|
+
* The browser tab icon is the page logo the user watches while a turn runs, so
|
|
5
|
+
* this bundle paints the AI session state onto it:
|
|
6
|
+
*
|
|
7
|
+
* - gray, blinking: the main session is running a turn. The dark half of the
|
|
8
|
+
* blink is the untouched shipped logo, so the tab pulses between "mark" and
|
|
9
|
+
* "mark + dot" instead of fading a dot in and out.
|
|
10
|
+
* - yellow: the session is waiting for the user (question, approval, plan review);
|
|
11
|
+
* stays lit until the user answers.
|
|
12
|
+
* - green: the last turn finished normally.
|
|
13
|
+
* - red: the last turn was aborted, blocked, errored, or crash-interrupted.
|
|
14
|
+
* - settled states fade back to the untouched logo after `SETTLE_MS` (green) or
|
|
15
|
+
* `SETTLE_MS_RED` (red) without new activity, so a finished tab stops advertising.
|
|
16
|
+
* - opening a session that merely *had* finished earlier shows nothing: a settled
|
|
17
|
+
* badge is only painted for a turn this plugin actually watched run.
|
|
18
|
+
*
|
|
19
|
+
* State comes from the Client services that already own it: the main view's
|
|
20
|
+
* identity from the Session list (`retainedBy.mainView`), running and pending
|
|
21
|
+
* interaction from `uiSession.sessionStatus`, and the closing turn's reason from
|
|
22
|
+
* the session event window's newest `turn/end`. A favicon has no slot, so the
|
|
23
|
+
* shipped `link[rel~="icon"]` elements are the one thing this plugin edits
|
|
24
|
+
* outside a component; their original `href` values are restored on teardown.
|
|
25
|
+
*/
|
|
26
|
+
window.__ModuleLoader__.load({
|
|
27
|
+
id: "dsh-tab-status-light",
|
|
28
|
+
factory: () => {
|
|
29
|
+
/** State -> badge color. Icon artwork is the one place literal colors are correct: an SVG favicon document cannot read page theme tokens. */
|
|
30
|
+
const BADGE_COLOR = {
|
|
31
|
+
working: "#9ca3af",
|
|
32
|
+
asking: "#eab308",
|
|
33
|
+
done: "#22c55e",
|
|
34
|
+
interrupted: "#ef4444"
|
|
35
|
+
};
|
|
36
|
+
/** Turn endings that must read as "interrupted" rather than "done". */
|
|
37
|
+
const RED_TURN_END_KINDS = new Set(["aborted", "blocked", "error", "interrupted"]);
|
|
38
|
+
/** Badge geometry inside the shipped 50x50 favicon viewBox (bottom-right corner). */
|
|
39
|
+
const BADGE = { cx: 37, cy: 37, radius: 11, ring: 4 };
|
|
40
|
+
/** Ring color per browser theme: separates the badge from the monochrome mark on either tab background. */
|
|
41
|
+
const RING = { dark: "#35363a", light: "#ffffff" };
|
|
42
|
+
/** Blink period: the icon alternates between the shipped logo and the logo with the gray dot. */
|
|
43
|
+
const BLINK_MS = 600;
|
|
44
|
+
/** How long a green (turn finished) badge stays before the tab returns to the plain logo. */
|
|
45
|
+
const SETTLE_MS = 10000;
|
|
46
|
+
/** Same for red: an interruption is louder news, so it stays on the tab longer. */
|
|
47
|
+
const SETTLE_MS_RED = 20000;
|
|
48
|
+
/**
|
|
49
|
+
* Blink half that shows the bare mark. It is painted from a cached data URL
|
|
50
|
+
* rather than restored to the shipped `href`: swapping back to the file URL
|
|
51
|
+
* makes Chrome re-read the icon file, which lands a beat late and made the
|
|
52
|
+
* blink look uneven.
|
|
53
|
+
*/
|
|
54
|
+
const PLAIN = "\u0000plain";
|
|
55
|
+
const SVG_CLOSE = "</svg>";
|
|
56
|
+
/**
|
|
57
|
+
* Blink driver source. Chrome throttles page timers in hidden tabs (down to one
|
|
58
|
+
* tick per minute under intensive throttling), which freezes the blink exactly
|
|
59
|
+
* when the user is looking at another tab — the case this light exists for. A
|
|
60
|
+
* dedicated Worker keeps its own timer outside that throttling.
|
|
61
|
+
*/
|
|
62
|
+
const TICKER_SOURCE = "let h=null;onmessage=(e)=>{const ms=e.data;if(h!==null){clearInterval(h);h=null}if(ms>0)h=setInterval(()=>postMessage(0),ms)};";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Interval that keeps ticking while the tab is in the background.
|
|
66
|
+
* @param ms - tick period.
|
|
67
|
+
* @param onTick - called once per tick.
|
|
68
|
+
* @returns a disposer.
|
|
69
|
+
*/
|
|
70
|
+
function startTicker(ms, onTick) {
|
|
71
|
+
try {
|
|
72
|
+
const url = URL.createObjectURL(new Blob([TICKER_SOURCE], { type: "text/javascript" }));
|
|
73
|
+
const worker = new Worker(url);
|
|
74
|
+
URL.revokeObjectURL(url);
|
|
75
|
+
worker.onmessage = () => onTick();
|
|
76
|
+
worker.postMessage(ms);
|
|
77
|
+
return () => {
|
|
78
|
+
worker.onmessage = null;
|
|
79
|
+
worker.postMessage(0);
|
|
80
|
+
worker.terminate();
|
|
81
|
+
};
|
|
82
|
+
} catch {
|
|
83
|
+
// No Worker/Blob in this environment: the page timer still works in the
|
|
84
|
+
// foreground, the browser just throttles it once the tab is hidden.
|
|
85
|
+
const timer = globalThis.setInterval(onTick, ms);
|
|
86
|
+
return () => globalThis.clearInterval(timer);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Wrap one SVG document in a base64 data URL. The favicon art is ASCII, so
|
|
92
|
+
* encoding is lossless.
|
|
93
|
+
* @param svg - complete SVG document text.
|
|
94
|
+
* @returns a `data:` URL the favicon link accepts.
|
|
95
|
+
*/
|
|
96
|
+
function toDataUrl(svg) {
|
|
97
|
+
return "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(svg)));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Badge markup for one state.
|
|
102
|
+
* @param state - one of `BADGE_COLOR`'s keys.
|
|
103
|
+
* @param ring - ring color for the surrounding browser theme.
|
|
104
|
+
* @returns the SVG circle element.
|
|
105
|
+
*/
|
|
106
|
+
function badgeMarkup(state, ring) {
|
|
107
|
+
return '<circle cx="' + BADGE.cx + '" cy="' + BADGE.cy + '" r="' + BADGE.radius +
|
|
108
|
+
'" fill="' + BADGE_COLOR[state] +
|
|
109
|
+
'" stroke="' + ring + '" stroke-width="' + BADGE.ring + '"/>';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Owns the tab icons: reads the shipped icon documents once, derives one
|
|
114
|
+
* data URL per theme/state, and restores the shipped icons when the plugin
|
|
115
|
+
* unloads.
|
|
116
|
+
*/
|
|
117
|
+
function createIconPainter() {
|
|
118
|
+
let frames = null;
|
|
119
|
+
let wantedState;
|
|
120
|
+
let disposed = false;
|
|
121
|
+
const cache = new Map();
|
|
122
|
+
|
|
123
|
+
const reading = (async () => {
|
|
124
|
+
const links = Array.from(document.head.querySelectorAll('link[rel~="icon"]'));
|
|
125
|
+
const loaded = [];
|
|
126
|
+
for (const link of links) {
|
|
127
|
+
const media = link.getAttribute("media") ?? "";
|
|
128
|
+
const frame = {
|
|
129
|
+
link,
|
|
130
|
+
original: link.getAttribute("href"),
|
|
131
|
+
theme: /dark/i.test(media) ? "dark" : "light",
|
|
132
|
+
art: null
|
|
133
|
+
};
|
|
134
|
+
try {
|
|
135
|
+
const response = await fetch(link.href);
|
|
136
|
+
if (response.ok) {
|
|
137
|
+
const text = await response.text();
|
|
138
|
+
const end = text.lastIndexOf(SVG_CLOSE);
|
|
139
|
+
if (text.includes("<svg") && end > 0) frame.art = text.slice(0, end);
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
// A failed read keeps the plain-badge fallback.
|
|
143
|
+
}
|
|
144
|
+
loaded.push(frame);
|
|
145
|
+
}
|
|
146
|
+
return loaded;
|
|
147
|
+
})().catch(() => []);
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Data URL for one frame's current state.
|
|
151
|
+
* @param frame - one shipped icon slot.
|
|
152
|
+
* @param state - badge state.
|
|
153
|
+
* @returns the icon URL.
|
|
154
|
+
*/
|
|
155
|
+
function hrefFor(frame, state) {
|
|
156
|
+
const key = frame.theme + "|" + (state === PLAIN ? "bare" : state) + "|" + (frame.art === null ? "plain" : "brand");
|
|
157
|
+
const cached = cache.get(key);
|
|
158
|
+
if (cached !== undefined) return cached;
|
|
159
|
+
const badge = state === PLAIN ? "" : badgeMarkup(state, RING[frame.theme]);
|
|
160
|
+
const svg = frame.art === null
|
|
161
|
+
? '<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50">' + badge + SVG_CLOSE
|
|
162
|
+
: frame.art + badge + SVG_CLOSE;
|
|
163
|
+
const href = toDataUrl(svg);
|
|
164
|
+
cache.set(key, href);
|
|
165
|
+
return href;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Write the wanted state onto every shipped icon link. */
|
|
169
|
+
function paint() {
|
|
170
|
+
if (disposed || frames === null) return;
|
|
171
|
+
for (const frame of frames) {
|
|
172
|
+
const href = wantedState === undefined
|
|
173
|
+
? frame.original
|
|
174
|
+
: hrefFor(frame, wantedState === PLAIN ? PLAIN : wantedState);
|
|
175
|
+
if (href === null || frame.link.getAttribute("href") === href) continue;
|
|
176
|
+
frame.link.setAttribute("href", href);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
/** Begin reading the shipped icons and paint the wanted state afterwards. */
|
|
182
|
+
start() {
|
|
183
|
+
reading.then((loaded) => {
|
|
184
|
+
if (disposed) return;
|
|
185
|
+
frames = loaded;
|
|
186
|
+
paint();
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
/**
|
|
190
|
+
* Request one visual state.
|
|
191
|
+
* @param state - badge state, or undefined for the untouched shipped icon.
|
|
192
|
+
*/
|
|
193
|
+
set(state) {
|
|
194
|
+
wantedState = state;
|
|
195
|
+
paint();
|
|
196
|
+
},
|
|
197
|
+
/** Restore every shipped icon and drop derived URLs. */
|
|
198
|
+
dispose() {
|
|
199
|
+
disposed = true;
|
|
200
|
+
if (frames !== null) {
|
|
201
|
+
for (const frame of frames) {
|
|
202
|
+
if (frame.original !== null) frame.link.setAttribute("href", frame.original);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
cache.clear();
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Tracks the main session's state and drives the painter.
|
|
212
|
+
* @param ctx - the Client root context.
|
|
213
|
+
* @param painter - icon painter owned by the same effect.
|
|
214
|
+
*/
|
|
215
|
+
function createStatusLight(ctx, painter) {
|
|
216
|
+
let mainId;
|
|
217
|
+
let binding;
|
|
218
|
+
let stopEvents;
|
|
219
|
+
let turnEndKind;
|
|
220
|
+
/** Sentinel that forces the next {@link apply} to repaint even when nothing moved. */
|
|
221
|
+
const STALE = "\u0000stale";
|
|
222
|
+
let appliedState;
|
|
223
|
+
let appliedActivity = -1;
|
|
224
|
+
/** Bumped whenever something actually happens (new turn end, session switch), so a faded badge can come back. */
|
|
225
|
+
let activity = 0;
|
|
226
|
+
let stopBlink;
|
|
227
|
+
let stopSettle;
|
|
228
|
+
/**
|
|
229
|
+
* Whether a turn has actually been seen running since this session was bound.
|
|
230
|
+
* A finished session that is merely opened must stay quiet: green/red are news
|
|
231
|
+
* about a turn the user watched, not a status replayed from history.
|
|
232
|
+
*/
|
|
233
|
+
let sawRunning = false;
|
|
234
|
+
|
|
235
|
+
/** Drop both timers (blink and settle). */
|
|
236
|
+
function stopTimers() {
|
|
237
|
+
if (stopBlink !== undefined) {
|
|
238
|
+
stopBlink();
|
|
239
|
+
stopBlink = undefined;
|
|
240
|
+
}
|
|
241
|
+
if (stopSettle !== undefined) {
|
|
242
|
+
stopSettle();
|
|
243
|
+
stopSettle = undefined;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Newest `turn/end` reason in the session event window.
|
|
249
|
+
* @param source - the Session's event source.
|
|
250
|
+
* @returns the reason kind, or undefined before any turn closed.
|
|
251
|
+
*/
|
|
252
|
+
function readTurnEndKind(source) {
|
|
253
|
+
const entries = source.getSnapshot().entries;
|
|
254
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
255
|
+
const entry = entries[index];
|
|
256
|
+
if (entry.type === "event" && entry.event.type === "turn/end") return entry.event.data.reason.kind;
|
|
257
|
+
}
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Session currently shown in the conversation view.
|
|
263
|
+
* @returns its identity, or undefined while no session is selected.
|
|
264
|
+
*/
|
|
265
|
+
function readMainId() {
|
|
266
|
+
const list = ctx.sessions.list.getSnapshot();
|
|
267
|
+
for (const id of list.ids) {
|
|
268
|
+
const row = list.byId[id];
|
|
269
|
+
if (row !== undefined && row.retainedBy !== undefined && row.retainedBy.mainView !== undefined) return id;
|
|
270
|
+
}
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The one state the icon should show right now. */
|
|
275
|
+
function readState() {
|
|
276
|
+
if (mainId === undefined) return undefined;
|
|
277
|
+
const status = ctx.uiSession.sessionStatus.getSnapshot().get(mainId);
|
|
278
|
+
if (status !== undefined) {
|
|
279
|
+
if (status.pendingInteraction !== undefined) return "asking";
|
|
280
|
+
if (status.running === true) return "working";
|
|
281
|
+
}
|
|
282
|
+
// Only a turn this plugin watched run may leave a settled badge behind.
|
|
283
|
+
if (turnEndKind === undefined || !sawRunning) return undefined;
|
|
284
|
+
return RED_TURN_END_KINDS.has(turnEndKind) ? "interrupted" : "done";
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Paint the current state. Re-paints only when the state or the activity
|
|
289
|
+
* token moved, so a settled badge that faded to the plain logo stays faded
|
|
290
|
+
* until something new happens.
|
|
291
|
+
*/
|
|
292
|
+
function apply() {
|
|
293
|
+
const state = readState();
|
|
294
|
+
if (state === appliedState && activity === appliedActivity) return;
|
|
295
|
+
appliedState = state;
|
|
296
|
+
appliedActivity = activity;
|
|
297
|
+
stopTimers();
|
|
298
|
+
if (state === undefined) {
|
|
299
|
+
painter.set(undefined);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (state === "working") {
|
|
303
|
+
// Blink between the untouched logo and the logo carrying the dot:
|
|
304
|
+
// the "off" half is the shipped mark, not a dimmed dot.
|
|
305
|
+
let lit = true;
|
|
306
|
+
painter.set(state);
|
|
307
|
+
stopBlink = startTicker(BLINK_MS, () => {
|
|
308
|
+
lit = !lit;
|
|
309
|
+
// Both halves are cached data URLs, so every swap paints on the tick.
|
|
310
|
+
painter.set(lit ? state : PLAIN);
|
|
311
|
+
});
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
painter.set(state);
|
|
315
|
+
if (state === "asking") return;
|
|
316
|
+
// A settled badge is news, not a permanent decoration: let the tab go back
|
|
317
|
+
// to the plain logo once nothing new has happened for a while. Interruptions
|
|
318
|
+
// stay longer than completions.
|
|
319
|
+
const timer = globalThis.setTimeout(() => {
|
|
320
|
+
stopSettle = undefined;
|
|
321
|
+
painter.set(undefined);
|
|
322
|
+
}, state === "interrupted" ? SETTLE_MS_RED : SETTLE_MS);
|
|
323
|
+
stopSettle = () => globalThis.clearTimeout(timer);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Observe the newest `turn/end` of the bound session.
|
|
328
|
+
* @param source - the binding's event source.
|
|
329
|
+
*/
|
|
330
|
+
function watchEvents(source) {
|
|
331
|
+
stopEvents = source.subscribe((window) => {
|
|
332
|
+
const change = window.change;
|
|
333
|
+
if (change.kind === "append") {
|
|
334
|
+
for (let index = change.entries.length - 1; index >= 0; index -= 1) {
|
|
335
|
+
const entry = change.entries[index];
|
|
336
|
+
if (entry.type === "event" && entry.event.type === "turn/end") {
|
|
337
|
+
turnEndKind = entry.event.data.reason.kind;
|
|
338
|
+
activity += 1;
|
|
339
|
+
apply();
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (change.kind === "replace") {
|
|
346
|
+
turnEndKind = readTurnEndKind(source);
|
|
347
|
+
apply();
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Re-bind to the main session when identity or generation changed, then paint. */
|
|
353
|
+
function sync() {
|
|
354
|
+
const nextId = readMainId();
|
|
355
|
+
const nextBinding = nextId === undefined ? undefined : ctx.sessions.binding(nextId);
|
|
356
|
+
/** A switch to another session is a switch; a new binding generation for the same session is not. */
|
|
357
|
+
const switched = nextId !== mainId;
|
|
358
|
+
if (switched || nextBinding !== binding) {
|
|
359
|
+
mainId = nextId;
|
|
360
|
+
binding = nextBinding;
|
|
361
|
+
if (stopEvents !== undefined) {
|
|
362
|
+
stopEvents();
|
|
363
|
+
stopEvents = undefined;
|
|
364
|
+
}
|
|
365
|
+
if (switched) {
|
|
366
|
+
activity += 1;
|
|
367
|
+
turnEndKind = undefined;
|
|
368
|
+
// Opening a session is not an event: its already-finished history must not
|
|
369
|
+
// flash a green/red badge. Only a turn seen running here may settle.
|
|
370
|
+
sawRunning = false;
|
|
371
|
+
}
|
|
372
|
+
if (nextBinding !== undefined) {
|
|
373
|
+
// Subscribe before the scan below: a window that replaced its entries
|
|
374
|
+
// before this moment would never deliver a change callback afterwards.
|
|
375
|
+
watchEvents(nextBinding.eventSource);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (mainId !== undefined) {
|
|
379
|
+
const status = ctx.uiSession.sessionStatus.getSnapshot().get(mainId);
|
|
380
|
+
if (status !== undefined && status.running === true) sawRunning = true;
|
|
381
|
+
}
|
|
382
|
+
// Always rescan: a turn that closed while the stream was silent (or while no
|
|
383
|
+
// subscription was in place yet) is only visible in the window itself.
|
|
384
|
+
turnEndKind = nextBinding === undefined ? undefined : readTurnEndKind(nextBinding.eventSource);
|
|
385
|
+
apply();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* A frozen, discarded or restored tab may have lost the blink tick or kept a
|
|
390
|
+
* stale frame: re-derive everything and repaint even when the computed state
|
|
391
|
+
* did not move.
|
|
392
|
+
*/
|
|
393
|
+
function wake() {
|
|
394
|
+
if (document.visibilityState === "hidden") return;
|
|
395
|
+
appliedState = STALE;
|
|
396
|
+
sync();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
/** Install the subscriptions; call from the owning effect. */
|
|
401
|
+
start() {
|
|
402
|
+
const stopList = ctx.sessions.list.subscribe(sync);
|
|
403
|
+
const stopStatus = ctx.uiSession.sessionStatus.subscribe(sync);
|
|
404
|
+
document.addEventListener("resume", wake);
|
|
405
|
+
document.addEventListener("pageshow", wake);
|
|
406
|
+
document.addEventListener("visibilitychange", wake);
|
|
407
|
+
sync();
|
|
408
|
+
return () => {
|
|
409
|
+
stopList();
|
|
410
|
+
stopStatus();
|
|
411
|
+
document.removeEventListener("resume", wake);
|
|
412
|
+
document.removeEventListener("pageshow", wake);
|
|
413
|
+
document.removeEventListener("visibilitychange", wake);
|
|
414
|
+
if (stopEvents !== undefined) stopEvents();
|
|
415
|
+
stopTimers();
|
|
416
|
+
stopEvents = undefined;
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
inject: ["sessions", "uiSession"],
|
|
424
|
+
/**
|
|
425
|
+
* Start the tab status light.
|
|
426
|
+
* @param ctx - the Client root context.
|
|
427
|
+
*/
|
|
428
|
+
apply(ctx) {
|
|
429
|
+
const painter = createIconPainter();
|
|
430
|
+
const light = createStatusLight(ctx, painter);
|
|
431
|
+
ctx.effect(() => {
|
|
432
|
+
painter.start();
|
|
433
|
+
const stopLight = light.start();
|
|
434
|
+
return () => {
|
|
435
|
+
stopLight();
|
|
436
|
+
painter.dispose();
|
|
437
|
+
};
|
|
438
|
+
}, "tab-status-light: main session state");
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
});
|
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Tab status light">
|
|
2
|
+
<rect width="64" height="64" rx="14" fill="#16181d"/>
|
|
3
|
+
<circle cx="32" cy="18" r="6" fill="#9ca3af"/>
|
|
4
|
+
<circle cx="17" cy="41" r="6" fill="#22c55e"/>
|
|
5
|
+
<circle cx="47" cy="41" r="6" fill="#eab308"/>
|
|
6
|
+
<circle cx="32" cy="52" r="6" fill="#ef4444"/>
|
|
7
|
+
</svg>
|
package/index.js
ADDED
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tab-status-light",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Show the AI session state on the browser tab icon — gray blinking while working, green when done, yellow while it waits for your answer, red when interrupted. 把 AI 狀態畫在分頁圖示上:進行中灰燈閃爍、完成綠、等你回答黃、中斷紅。",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Kyle Fu",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"deepseek",
|
|
9
|
+
"dsh",
|
|
10
|
+
"deepseek-harness",
|
|
11
|
+
"plugin",
|
|
12
|
+
"favicon",
|
|
13
|
+
"tab-icon",
|
|
14
|
+
"status"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/kyle2241345145351324321/dsh-tab-status-light#readme",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/kyle2241345145351324321/dsh-tab-status-light.git"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/kyle2241345145351324321/dsh-tab-status-light/issues"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": "./index.js",
|
|
27
|
+
"./client": "./client.js",
|
|
28
|
+
"./locale/*.json": "./locale/*.json",
|
|
29
|
+
"./icon": "./icon.svg"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"index.js",
|
|
33
|
+
"client.js",
|
|
34
|
+
"cordis.patch.yml",
|
|
35
|
+
"locale/*.json",
|
|
36
|
+
"icon.svg",
|
|
37
|
+
"preview.png",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"README.md"
|
|
40
|
+
],
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"dsh": {
|
|
45
|
+
"bundle": {
|
|
46
|
+
"patch": "./cordis.patch.yml"
|
|
47
|
+
},
|
|
48
|
+
"client": {
|
|
49
|
+
"platform": "web",
|
|
50
|
+
"immediately": true,
|
|
51
|
+
"inject": [
|
|
52
|
+
"@deepseek-ai/dsh-client-ui-session",
|
|
53
|
+
"@deepseek-ai/dsh-api-session-controller"
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
package/preview.png
ADDED
|
Binary file
|