dsh-tab-status-light 0.0.0-stage → 1.0.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 +95 -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,96 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-tab-status-light
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**把 AI 的狀態畫在瀏覽器分頁圖示上** —— 一個給 DeepSeek Harness Web UI 用的外掛。
|
|
4
|
+
|
|
5
|
+
> Puts the AI session state on the browser tab icon: gray blinking while it works,
|
|
6
|
+
> green when it finishes, yellow while it waits for you, red when it is interrupted.
|
|
7
|
+
|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
| 狀態 | 顏色 | 動畫 | 判定依據 |
|
|
11
|
+
| --- | --- | --- | --- |
|
|
12
|
+
| 進行中 | 灰 `#9ca3af` | 一閃一閃(每 600 ms 切換):**原本的鯨魚** ↔ 鯨魚+灰點,暗的那半完全沒有圓點 | `SessionStatus.running === true` |
|
|
13
|
+
| 完成 | 綠 `#22c55e` | 亮 10 秒後自動回到原本的鯨魚 | 這一輪**被看到在跑**,且最後一個 `turn/end` reason 不是中斷類 |
|
|
14
|
+
| 等你回答 | 黃 `#eab308` | 一直亮著(因為在等你) | `SessionStatus.pendingInteraction !== undefined`(提問 / 核准 / 計畫審查) |
|
|
15
|
+
| 中斷 | 紅 `#ef4444` | 亮 20 秒後自動回到原本的鯨魚 | 這一輪**被看到在跑**,且最後一個 `turn/end` reason 是 `aborted` / `blocked` / `error` / `interrupted` |
|
|
16
|
+
|
|
17
|
+
沒選 session(首頁)、該 session 還沒有任何一輪結束、或只是**切換到一個早就結束的 session** 時,
|
|
18
|
+
**不動**原本的鯨魚圖示 —— 綠/紅是「剛剛發生的事」的通知,不會從歷史重播。
|
|
19
|
+
「一小段時間沒事就收起來」的行為由 `SETTLE_MS`(綠 10 秒)與 `SETTLE_MS_RED`(紅 20 秒)控制;
|
|
20
|
+
任何新的一輪結束都會讓燈重新亮起。
|
|
21
|
+
|
|
22
|
+
## 安裝
|
|
23
|
+
|
|
24
|
+
外掛以 DSH bundle 的形式發佈(`dsh.bundle.patch`),用 harness 的 `plugin_manager` 安裝:
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
plugin_manager({ action: "install_bundle", target: "dsh-tab-status-light" })
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
或直接指到這個資料夾(開發時這樣最快,改檔後重新整理即生效):
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
plugin_manager({ action: "install_bundle", target: "/path/to/dsh-tab-status-light" })
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
安裝後重新整理頁面(`Cmd+R`)即可看到分頁圖示變化。
|
|
37
|
+
|
|
38
|
+
> 需要 harness 的 Web profile,且 `@deepseek-ai/dsh-client-ui-session` 與
|
|
39
|
+
> `@deepseek-ai/dsh-api-session-controller` 兩個 Client 套件可用(標準安裝即有)。
|
|
40
|
+
|
|
41
|
+
## 運作方式
|
|
42
|
+
|
|
43
|
+
- **純 Client 外掛、不佔用任何 slot**:讀 `ctx.sessions.list`(哪個 session 是主視窗)與
|
|
44
|
+
`ctx.uiSession.sessionStatus`(running / pendingInteraction),並訂閱該 session 的
|
|
45
|
+
event source 取得最新的 `turn/end` reason。
|
|
46
|
+
- 圖示是**即時重繪**的:用 `fetch()` 讀取原本的 `favicon.svg` / `favicon-dark.svg`,
|
|
47
|
+
在 `</svg>` 前插入一顆 `<circle>`,轉成 data URL 後換掉 `<link rel="icon">` 的 `href`
|
|
48
|
+
(Chrome 會跟著重畫分頁圖示)。unmount 時還原原始 href。
|
|
49
|
+
- 因此鯨魚圖案不會被複製一份到外掛裡,上游之後換 logo 也會自動跟上。
|
|
50
|
+
- 圓點幾何(在 50×50 viewBox 內):`cx=37, cy=37, r=11, stroke-width=4`;
|
|
51
|
+
外圈用分頁底色(深色主題 `#35363a`、淺色主題 `#ffffff`)把圓點跟鯨魚隔開。
|
|
52
|
+
- 閃爍由一個小型 Web Worker 計時驅動,**分頁切到背景時照樣閃**(繞開 Chrome 對背景
|
|
53
|
+
分頁 `setInterval` 的節流);不支援 Worker 時自動退回頁面計時器。
|
|
54
|
+
|
|
55
|
+
## 可調參數
|
|
56
|
+
|
|
57
|
+
都在 [`client.js`](client.js) 檔頭:
|
|
58
|
+
|
|
59
|
+
| 常數 | 預設 | 說明 |
|
|
60
|
+
| --- | --- | --- |
|
|
61
|
+
| `BADGE` | `{cx:37, cy:37, radius:11, ring:4}` | 圓點位置 / 半徑 / 外圈寬 |
|
|
62
|
+
| `BADGE_COLOR` | 灰 / 綠 / 黃 / 紅 | 四種狀態的顏色 |
|
|
63
|
+
| `BLINK_MS` | `600` | 灰燈閃爍間隔(每 600 ms 切換一次) |
|
|
64
|
+
| `SETTLE_MS` | `10000` | 綠燈亮多久後收回 |
|
|
65
|
+
| `SETTLE_MS_RED` | `20000` | 紅燈亮多久後收回 |
|
|
66
|
+
| `RED_TURN_END_KINDS` | `aborted` `blocked` `error` `interrupted` | 哪些結束原因算中斷 |
|
|
67
|
+
|
|
68
|
+
## 資源用量
|
|
69
|
+
|
|
70
|
+
閃爍中的 JS 成本約 **2 ms/秒**(單核約 0.2%);閒置時 0(沒有計時器、沒有重新掃描)。
|
|
71
|
+
|
|
72
|
+
## 已知限制
|
|
73
|
+
|
|
74
|
+
- 圖示由「主視窗」那個 session 決定;其他背景 session 的狀態不會顯示。
|
|
75
|
+
- 切換到一個「早就跑完」的 session 不會亮綠燈(只亮真的看到跑完的那一輪)。
|
|
76
|
+
- 分頁在背景時仍會閃爍;被瀏覽器**凍結**(Chrome Memory Saver / 省電模式)時則一定會停,
|
|
77
|
+
任何網頁都沒有繞過凍結的 API。想讓它一直閃,把這個網址加進
|
|
78
|
+
`chrome://settings/performance` →「永不讓這些網站進入休眠 / Always keep these sites active」。
|
|
79
|
+
- `navigator.wakeLock` 幫不上忙:它只防止螢幕休眠,且分頁一隱藏就會被釋放,
|
|
80
|
+
不能阻止分頁被凍結。唯一能「不被凍結」的偏方是讓分頁持續播放靜音音訊(需要一次點擊、
|
|
81
|
+
會出現音訊指示、也比較耗電),不值得。
|
|
82
|
+
- 分頁解凍 / 切回前景時,外掛會立刻重新同步並重畫,所以回到分頁看到的永遠是正確狀態。
|
|
83
|
+
- 用純 SVG data URL 換 href,不支援 SVG favicon 的瀏覽器(很舊的)會看不到變化。
|
|
84
|
+
|
|
85
|
+
## 開發
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
// 這個 repo 就是可直接安裝的 bundle 資料夾,沒有 build step
|
|
89
|
+
plugin_manager({ action: "install_bundle", target: "/absolute/path/to/dsh-tab-status-light" })
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
改完 `client.js` 後在瀏覽器重新載入頁面即可生效(Client 模組是即時從磁碟供應的)。
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
[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.0.0",
|
|
4
|
+
"description": "Shows the AI session state on the browser tab icon: gray pulsing while working, green when done, yellow while it waits for an answer, red when interrupted.",
|
|
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
|