dsh-workspace-status 0.0.0-stage → 0.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/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-10-25
4
+
5
+ ### Added
6
+
7
+ - First public release: coloured status marks on workspace rows (waiting, running,
8
+ recently finished) with locale-aware labels and a settings toggle.
9
+ - Declares `dsh.catalog` so plugin markets place it under UI with a real summary.
10
+
11
+ All notable changes to this package are documented here. The format follows
12
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
13
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
+
15
+ ## [Unreleased]
16
+
17
+ ## [0.1.0] - 2026-10-25
18
+
19
+ ### Added
20
+
21
+ - Workspace rows in the sidebar show a status mark aggregated from their
22
+ sessions: amber dot while a session waits for the user (approval, plan
23
+ review, answer), blue spinning ring while a session or subagent runs, green
24
+ dot for a recently finished unread session (one-hour notice window).
25
+ - One mark per workspace with the shipped session-row precedence: waiting
26
+ outranks running, which outranks the finished reminder.
27
+ - Hidden screen-reader description with per-state counts and workspace names.
28
+ - Archived sessions ignored; ungrouped sessions mark the Ungrouped bucket.
29
+ - Zero-fork implementation: an additive `sidebar.footer.action` entry that
30
+ publishes row CSS keyed on `data-row-key="workspace:<id>"`, with no DOM
31
+ writes, observers or host services.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-workspace-status contributors
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,157 @@
1
- # Temporary Holding Version
1
+ # dsh-workspace-status
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Colored status marks on the **workspace rows** of the DeepSeek Harness sidebar:
4
+ waiting for you, running, and just finished.
5
+
6
+ Harness draws a status dot on every conversation row, but nothing on the
7
+ workspace (folder) row above it. Collapse a group, or scroll it out of view, and
8
+ the folder row cannot tell you that an agent inside is stuck waiting for an
9
+ approval or still working. This plugin gives the folder row the same kind of
10
+ mark, aggregated from its sessions:
11
+
12
+ | Mark | Meaning | Color |
13
+ |---|---|---|
14
+ | solid dot | at least one session is waiting for you — approval, plan review, or an answer | amber (`--dsw-alias-state-warn-primary`) |
15
+ | stroked ring, spinning | at least one session is running, including a running subagent | blue (`--dsw-static-deepseek-450`) |
16
+ | solid dot | at least one session finished recently and has not been opened since | green (`--dsw-alias-state-success-primary`) |
17
+
18
+ The mark sits in the empty right-hand end of the workspace row — the same place
19
+ a session row puts its timestamp — and does not displace the title. It appears
20
+ for expanded and collapsed groups alike, and its state is described to
21
+ screen-reader users in a hidden sentence ("Workspace activity: 2 running, 1
22
+ waiting for you — api, web").
23
+
24
+ ## Preview
25
+
26
+ ![Four sidebar rows: api waiting (amber dot), web running (blue ring), docs recently finished (green dot), sandbox idle (no mark)](docs/preview-mock.png)
27
+
28
+ This is the plugin's own stylesheet rendered over a local reproduction of the
29
+ sidebar row DOM and theme tokens — a design preview, not a live screenshot. The
30
+ shipped app draws the same marks on the real rows.
31
+
32
+ ## Rules
33
+
34
+ - **Precedence matches the shipped session row:** waiting for the user beats
35
+ running, which beats the finished reminder. A workspace therefore shows one
36
+ mark at a time; the hidden description still reports every count.
37
+ - **Subagents count as their parent's activity.** A running subagent keeps the
38
+ parent session's workspace marked even when the parent turn has settled.
39
+ Subagent rows never produce a mark of their own — they are not workspace
40
+ members.
41
+ - **Archived sessions are ignored**, the same way the default browser view
42
+ hides them.
43
+ - **The finished mark is a notice, not a flag.** It lapses one hour after the
44
+ session's last update, or one hour after the plugin watched it stop if that
45
+ is later. The shipped per-row green dot persists until opened; a folder row
46
+ has no timestamp to contradict it, so a permanent green dot would outlive the
47
+ news. A finished session that was never seen running is judged by its own
48
+ updated time only.
49
+ - **Ungrouped sessions** mark the "Ungrouped" bucket row, which behaves like a
50
+ workspace row in the sidebar.
51
+ - **No mark when everything is idle.** The plugin renders nothing at all in
52
+ that case — no stylesheet, no hidden text.
53
+
54
+ The active workspace (the one containing the open conversation) is already
55
+ marked natively: Harness paints the folder icon of the active chain blue. This
56
+ plugin does not duplicate that.
57
+
58
+ ## Install
59
+
60
+ From npm:
61
+
62
+ ```sh
63
+ dsh plugin --profile <profile> add dsh-workspace-status
64
+ ```
65
+
66
+ From a local checkout (development):
67
+
68
+ ```sh
69
+ dsh plugin --profile desktop add /absolute/path/to/dsh-workspace-status
70
+ ```
71
+
72
+ `dsh plugin add` links the checkout and appends this package to
73
+ `dsh.profile.bundles` because it declares `dsh.bundle`. Use the profile that
74
+ runs your client — `desktop` for the macOS/Windows app, `web` for `dsh web`.
75
+ Or use sidebar → **Plugins** → **Add plugin** and pass the absolute path to this
76
+ directory. The host half contributes nothing, but the profile composition still
77
+ changes, so **restart the profile's Host** (quit and reopen the desktop app, or
78
+ restart `dsh web`) after installing. Then reload the page if the client half
79
+ does not appear.
80
+
81
+ Manual mount without the CLI: copy the package into the profile's
82
+ `node_modules`, add `"dsh-workspace-status"` to `dsh.profile.bundles` in the
83
+ profile's `package.json`, and insert the row from `cordis.patch.yml` into the
84
+ profile's patch layer.
85
+
86
+ ## Verify
87
+
88
+ 1. Start a session in a workspace and collapse that group while it runs — a
89
+ blue ring appears at the end of the folder row.
90
+ 2. Trigger an approval (or a plan review / question) in a session of another
91
+ workspace — that folder row shows an amber dot even while expanded.
92
+ 3. Let a session finish without opening it — a green dot appears, and lapses an
93
+ hour later.
94
+ 4. Expand the group: the mark stays on the folder row; the session rows keep
95
+ their own dots unchanged.
96
+ 5. With every session idle, no extra element appears in the sidebar.
97
+
98
+ The client half writes nothing into the sidebar DOM — it reacts to the public
99
+ Workspace/Session feeds and emits CSS keyed on the row the browser already
100
+ renders. If the mark is missing, check the browser console for a failed client
101
+ bundle load first; nothing else in the sidebar is affected.
102
+
103
+ ## How it works
104
+
105
+ - The mount is an entry in the additive `sidebar.footer.action` seat. It renders
106
+ only a `<style>` element and a visually hidden `<span>`; it is not a control
107
+ and adds nothing visible at the foot.
108
+ - Aggregation reads the same public data the browser uses: `useWorkspaces`
109
+ (`items`, `archivedSessionIds`), `useSessions` (`byId`) and
110
+ `useSessionStatus` (`running`, `pendingInteraction`, `completionUnread`).
111
+ - The visible mark is the workspace row's trailing pseudo-element, emitted as a
112
+ rule targeting `[data-row-key="workspace:<id>"]::after`.
113
+ - Removing the plugin removes the mount, which removes the stylesheet; no state
114
+ is written anywhere.
115
+
116
+ ### The one internal dependency
117
+
118
+ `data-row-key="workspace:<id>"` is an internal detail of
119
+ `@deepseek-ai/dsh-client-ui-workspace`, not a public slot contract. A future
120
+ Harness release could rename it, in which case the marks would silently stop
121
+ appearing until this package is updated. The failure mode is deliberately
122
+ **"no indicator"**, not "broken sidebar": the plugin never touches the DOM, so a
123
+ stale selector can only produce a missing dot.
124
+
125
+ ## Compatibility
126
+
127
+ - DeepSeek Harness `>=0.2.1-rc.2 <0.3.0-0` (developed and verified against
128
+ `0.2.1-rc.3`); Web and Desktop clients.
129
+ - Node `>=22.18.0` for the build-free package and its tests.
130
+ - No runtime dependencies, no host services, no settings, no model-visible
131
+ tools or prompt changes.
132
+
133
+ ## Development
134
+
135
+ ```sh
136
+ npm test
137
+ ```
138
+
139
+ The suite loads the classic-script client envelope in a sandbox, drives the slot
140
+ component with fake standard hooks, and asserts the emitted stylesheet and
141
+ hidden description across waiting/running/finished, subagent, archived,
142
+ ungrouped, expiry and selector-escaping cases. A manifest test checks the bundle
143
+ patch, export map, locale parity and the loader envelope.
144
+
145
+ There is no build step: `index.js` and `client.js` are the published artifacts.
146
+
147
+ ## Remove
148
+
149
+ ```sh
150
+ dsh plugin --profile <profile> remove dsh-workspace-status
151
+ ```
152
+
153
+ Restart the Host. The sidebar returns to stock; nothing else is left behind.
154
+
155
+ ## License
156
+
157
+ MIT
package/client.js ADDED
@@ -0,0 +1,510 @@
1
+ /**
2
+ * Browser half of workspace-status.
3
+ *
4
+ * The sidebar draws a status dot on every Session row but nothing on the
5
+ * Workspace (folder) row above it, so a collapsed group — or a group scrolled
6
+ * out of view — hides whether any agent inside it is waiting, running, or
7
+ * just finished. Workspace rows declare no slot, so this package does not
8
+ * render markup into them: it publishes one stylesheet aimed at the row the
9
+ * Workspace browser already renders (`data-row-key="workspace:<id>"`) and
10
+ * draws the mark as that row's trailing pseudo-element. React keeps owning
11
+ * the row; nothing is written to the document and nothing is observed.
12
+ *
13
+ * The entry mounts in the additive `sidebar.footer.action` seat, which is
14
+ * mounted for the whole browsing region even when no Session exists. It shows
15
+ * no control of its own: it publishes the row rules plus a visually hidden
16
+ * description of the same state for readers who cannot see the mark.
17
+ *
18
+ * Data comes from the same public feeds the browser itself reads:
19
+ * `useWorkspaces` (items + archive set), `useSessions` (summaries, including
20
+ * subagent rows) and `useSessionStatus` (running / pendingInteraction /
21
+ * completionUnread). The precedence mirrors the shipped Session row:
22
+ * waiting for the user outranks running, which outranks the finished
23
+ * reminder; a subagent keeps its parent's workspace busy.
24
+ */
25
+
26
+ window.__ModuleLoader__.load({
27
+ id: 'dsh-workspace-status',
28
+ factory(require) {
29
+ const React = require('react')
30
+
31
+ /** Additive seat beside Settings at the sidebar foot. */
32
+ const SLOT = 'sidebar.footer.action'
33
+ /** Stable list id; reusing it would shadow this entry rather than add one. */
34
+ const ENTRY_ID = 'workspace-status'
35
+ /** The stylesheet this mount owns; unmounting the entry removes it. */
36
+ const STYLE_ID = 'dsh-workspace-status/rows.css'
37
+ /** Last among the shipped footer actions. */
38
+ const ENTRY_ORDER = 950
39
+
40
+ /**
41
+ * How long a finished-but-unread Session keeps its Workspace marked.
42
+ *
43
+ * The browser's finished reminder persists until the Session is opened.
44
+ * A folder row owns no timestamp that could age it out, so it would keep a
45
+ * green dot on a workspace whose work stopped days ago. This mark is a
46
+ * notice: it lapses once the Session's last update stops reading in
47
+ * minutes. A Session watched stopping keeps the mark for the same window
48
+ * even when its last prompt is older than that.
49
+ */
50
+ const FINISHED_WINDOW_MS = 60 * 60 * 1000
51
+
52
+ /** How many workspace names the hidden description lists. */
53
+ const MAX_NAMES = 4
54
+
55
+ /** The visually hidden description's class. */
56
+ const SR_CLASS = 'dsws-sr'
57
+
58
+ /**
59
+ * The hidden description's own rule. The visible mark carries no markup of
60
+ * its own (the generated row rules draw it), so this sheet only has to
61
+ * keep the description out of the layout.
62
+ */
63
+ const BASE_CSS = [
64
+ `.${SR_CLASS}{position:absolute;width:1px;height:1px;margin:-1px;padding:0;`,
65
+ 'overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}',
66
+ ].join('')
67
+
68
+ /**
69
+ * Every user-facing string lives here rather than in a locale service: the
70
+ * only copy this package produces is a hidden description, so the built-in
71
+ * dictionaries are enough and the plugin keeps working without the locale
72
+ * face. The `locale/*.json` files mirror them for tooling.
73
+ */
74
+ const DICTIONARIES = {
75
+ en: {
76
+ 'activity.summary': 'Workspace activity: {clauses} — {names}',
77
+ 'activity.waiting': '{n} waiting for you',
78
+ 'activity.running': '{n} running',
79
+ 'activity.done': '{n} recently finished',
80
+ 'activity.unnamed': 'Ungrouped',
81
+ 'activity.more': 'and {n} more',
82
+ 'activity.listSeparator': ', ',
83
+ },
84
+ zh: {
85
+ 'activity.summary': '工作区状态:{clauses} — {names}',
86
+ 'activity.waiting': '{n} 个等待你处理',
87
+ 'activity.running': '{n} 个运行中',
88
+ 'activity.done': '{n} 个刚刚完成',
89
+ 'activity.unnamed': '未分组',
90
+ 'activity.more': '等 {n} 个',
91
+ 'activity.listSeparator': '、',
92
+ },
93
+ }
94
+
95
+ /**
96
+ * Resolve the host's language without assuming the locale service exists.
97
+ * @returns 'zh' for Chinese hosts, otherwise 'en'.
98
+ */
99
+ function currentLanguage() {
100
+ const candidates = []
101
+ if (typeof document !== 'undefined') {
102
+ const lang = document?.documentElement?.lang
103
+ if (typeof lang === 'string' && lang !== '') candidates.push(lang)
104
+ }
105
+ if (typeof navigator !== 'undefined' && typeof navigator.language === 'string') {
106
+ candidates.push(navigator.language)
107
+ }
108
+ for (const value of candidates) {
109
+ if (/^zh/i.test(value)) return 'zh'
110
+ }
111
+ return 'en'
112
+ }
113
+
114
+ /** Interpolate `{name}` placeholders in one template. */
115
+ function format(template, params) {
116
+ return template.replace(/\{(\w+)\}/g, (match, name) => (
117
+ params !== undefined && Object.prototype.hasOwnProperty.call(params, name)
118
+ ? String(params[name])
119
+ : match
120
+ ))
121
+ }
122
+
123
+ /** One localized message, falling back to English and then the key. */
124
+ function message(key, params) {
125
+ const language = currentLanguage()
126
+ const dictionary = DICTIONARIES[language] ?? DICTIONARIES.en
127
+ const template = dictionary[key] ?? DICTIONARIES.en[key] ?? key
128
+ return format(template, params)
129
+ }
130
+
131
+ /**
132
+ * Escape one group key for a double-quoted CSS attribute selector.
133
+ *
134
+ * A control character has no literal form inside a CSS string: an unescaped
135
+ * newline ends the string token and the whole rule is dropped as invalid,
136
+ * so the row would lose its mark silently. Each one is written as a hex
137
+ * escape with a trailing space, which stops a following hex digit being
138
+ * absorbed. Host Workspace ids are UUIDs, so this is a correctness guard,
139
+ * not a path expected in practice.
140
+ * @param value - raw group key.
141
+ * @returns the key escaped for use inside quotes.
142
+ */
143
+ function quote(value) {
144
+ let escaped = ''
145
+ for (const char of String(value)) {
146
+ const code = char.codePointAt(0) ?? 0
147
+ if (char === '\\' || char === '"') escaped += `\\${char}`
148
+ else if (code < 0x20 || code === 0x7f) escaped += `\\${code.toString(16)} `
149
+ else escaped += char
150
+ }
151
+ return escaped
152
+ }
153
+
154
+ /**
155
+ * The running mark, carried as an alpha mask rather than a border.
156
+ *
157
+ * A CSS border ring cannot read as a circle at this size: its corners are
158
+ * mitred, so the gap between the two ends looks like a clipped square. The
159
+ * mask keeps a round 2px-equivalent stroke with round caps and a faint
160
+ * track, matching the geometry of the product's own running indicator.
161
+ */
162
+ const RING = "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'%3E%3Ccircle cx='5' cy='5' r='4' fill='none' stroke='%23000' stroke-width='1.5' stroke-linecap='round' stroke-opacity='.25'/%3E%3Ccircle cx='5' cy='5' r='4' fill='none' stroke='%23000' stroke-width='1.5' stroke-linecap='round' stroke-dasharray='5 20'/%3E%3C/svg%3E\")"
163
+
164
+ /**
165
+ * One Workspace's tally before the states are split.
166
+ * @typedef {{ key: string, title: string, waiting: number, running: number, done: number }} GroupTally
167
+ */
168
+
169
+ /**
170
+ * Whether a Session's own timestamp is recent enough to still deserve a
171
+ * finished mark. A missing or zero timestamp cannot be corroborated, so it
172
+ * never earns one (subagent rows carry a zero timestamp).
173
+ * @param updatedAt - the Session summary's epoch-millisecond timestamp.
174
+ * @param now - render-time clock.
175
+ * @returns true while the timestamp reads within the notice window.
176
+ */
177
+ function isFresh(updatedAt, now) {
178
+ if (typeof updatedAt !== 'number' || !Number.isFinite(updatedAt) || updatedAt <= 0) return false
179
+ return now - updatedAt <= FINISHED_WINDOW_MS
180
+ }
181
+
182
+ /**
183
+ * What this mount has watched happen, keyed by Session id.
184
+ *
185
+ * `running` holds every Session seen running since the mount; `stoppedAt`
186
+ * holds the time a Session was watched stop while still carrying the unread
187
+ * mark, and only for Sessions first seen running. Together they let the
188
+ * finished mark expire on its own rather than wait for the browser to clear
189
+ * it — and they cover a Session that ran for hours, whose `updatedAt` is
190
+ * the time of its last prompt, not the time it stopped.
191
+ * @typedef {{ running: Set<string>, stoppedAt: Map<string, number> }} Watch
192
+ */
193
+
194
+ /** Drop one Session from the watch (archived or gone). */
195
+ function forget(watch, sessionId) {
196
+ watch.running.delete(sessionId)
197
+ watch.stoppedAt.delete(sessionId)
198
+ }
199
+
200
+ /** Whether any direct subagent child of one Session is currently running. */
201
+ function runsChild(sessionId, children, sessions, statuses) {
202
+ const kids = children.get(sessionId)
203
+ if (kids === undefined) return false
204
+ for (const childId of kids) {
205
+ const status = statuses?.get?.(childId)
206
+ // The status feed is authoritative where it knows the child; the
207
+ // summary's own running flag is the fallback, matching the browser's
208
+ // precedence.
209
+ const running = status?.running === undefined
210
+ ? sessions[childId]?.running === true
211
+ : status.running === true
212
+ if (running) return true
213
+ }
214
+ return false
215
+ }
216
+
217
+ /**
218
+ * Which Workspace groups own a waiting, running, or just-finished Session.
219
+ *
220
+ * Precedence matches the shipped Session row: waiting for the user outranks
221
+ * running, which outranks the finished reminder. Each Session therefore
222
+ * lands in exactly one tally, so the counts in the hidden description add
223
+ * up to the number of marked Sessions. Archived Sessions are skipped, the
224
+ * same way the default browser view hides them. A running subagent keeps
225
+ * its parent's Workspace busy even when the parent turn already settled;
226
+ * subagent rows are not workspace members, so they never create a mark of
227
+ * their own.
228
+ *
229
+ * @param items - Workspace rows from the Workspace snapshot.
230
+ * @param byId - Session summaries keyed by id.
231
+ * @param statuses - Session UI status map (running / pendingInteraction / completionUnread).
232
+ * @param archivedSessionIds - registry-global archive set.
233
+ * @param now - render-time clock, epoch milliseconds.
234
+ * @param watch - this mount's running/stopped observations.
235
+ * @returns marked group keys by state, counts, and display titles.
236
+ */
237
+ function deriveActivity(items, byId, statuses, archivedSessionIds, now, watch) {
238
+ const sessions = byId !== null && typeof byId === 'object' ? byId : {}
239
+ const archived = new Set(Array.isArray(archivedSessionIds) ? archivedSessionIds : [])
240
+ const statusMap = statuses !== null && typeof statuses?.get === 'function' ? statuses : undefined
241
+
242
+ /** Session id → owning Workspace key (ungrouped bucket is `''`). */
243
+ const workspaceOf = new Map()
244
+ /** Workspace key → display title. */
245
+ const titles = new Map()
246
+ for (const workspace of Array.isArray(items) ? items : []) {
247
+ if (workspace === null || typeof workspace !== 'object') continue
248
+ const key = typeof workspace.workspaceId === 'string' ? workspace.workspaceId : ''
249
+ titles.set(key, typeof workspace.title === 'string' ? workspace.title : '')
250
+ for (const sessionId of Array.isArray(workspace.sessionIds) ? workspace.sessionIds : []) {
251
+ workspaceOf.set(sessionId, key)
252
+ }
253
+ }
254
+
255
+ /** Parent Session id → direct subagent child ids. */
256
+ const children = new Map()
257
+ for (const sessionId of Object.keys(sessions)) {
258
+ const row = sessions[sessionId]
259
+ if (row?.origin !== 'subagent' || typeof row.parentId !== 'string') continue
260
+ const list = children.get(row.parentId)
261
+ if (list === undefined) children.set(row.parentId, [sessionId])
262
+ else list.push(sessionId)
263
+ }
264
+
265
+ /** @type {Map<string, GroupTally>} */
266
+ const groups = new Map()
267
+ const tally = (key) => {
268
+ let group = groups.get(key)
269
+ if (group === undefined) {
270
+ group = { key, title: titles.get(key) ?? '', waiting: 0, running: 0, done: 0 }
271
+ groups.set(key, group)
272
+ }
273
+ return group
274
+ }
275
+
276
+ for (const sessionId of Object.keys(sessions).sort()) {
277
+ const row = sessions[sessionId]
278
+ if (row === null || row === undefined) continue
279
+ if (row.origin === 'subagent') continue
280
+ if (archived.has(sessionId)) {
281
+ forget(watch, sessionId)
282
+ continue
283
+ }
284
+
285
+ const status = statusMap?.get?.(sessionId)
286
+ const running = status?.running === true
287
+ || (status?.running === undefined && row.running === true)
288
+ || runsChild(sessionId, children, sessions, statusMap)
289
+ const waiting = status?.pendingInteraction !== undefined && status?.pendingInteraction !== null
290
+
291
+ if (running) {
292
+ watch.running.add(sessionId)
293
+ watch.stoppedAt.delete(sessionId)
294
+ } else if (watch.running.delete(sessionId) && status?.completionUnread === true) {
295
+ watch.stoppedAt.set(sessionId, now)
296
+ }
297
+
298
+ const recent = isFresh(row.updatedAt, now)
299
+ || (watch.stoppedAt.get(sessionId) !== undefined
300
+ && now - watch.stoppedAt.get(sessionId) <= FINISHED_WINDOW_MS)
301
+ const key = workspaceOf.has(sessionId) ? workspaceOf.get(sessionId) : ''
302
+
303
+ if (waiting) {
304
+ tally(key).waiting += 1
305
+ } else if (running) {
306
+ tally(key).running += 1
307
+ } else if (status?.completionUnread === true && recent) {
308
+ tally(key).done += 1
309
+ }
310
+ }
311
+
312
+ // Prune observations that can no longer produce a mark.
313
+ for (const sessionId of watch.running) {
314
+ if (sessions[sessionId] === undefined) watch.running.delete(sessionId)
315
+ }
316
+ for (const [sessionId, stoppedAt] of watch.stoppedAt) {
317
+ if (sessions[sessionId] === undefined || now - stoppedAt > FINISHED_WINDOW_MS) {
318
+ watch.stoppedAt.delete(sessionId)
319
+ }
320
+ }
321
+
322
+ const waitingKeys = []
323
+ const runningKeys = []
324
+ const doneKeys = []
325
+ const names = new Map()
326
+ const totals = { waiting: 0, running: 0, done: 0 }
327
+ let marked = 0
328
+ for (const group of groups.values()) {
329
+ const total = group.waiting + group.running + group.done
330
+ if (total === 0) continue
331
+ if (group.waiting > 0) waitingKeys.push(group.key)
332
+ else if (group.running > 0) runningKeys.push(group.key)
333
+ else doneKeys.push(group.key)
334
+ totals.waiting += group.waiting
335
+ totals.running += group.running
336
+ totals.done += group.done
337
+ marked += total
338
+ names.set(group.key, group.title)
339
+ }
340
+ waitingKeys.sort()
341
+ runningKeys.sort()
342
+ doneKeys.sort()
343
+
344
+ return { waiting: waitingKeys, running: runningKeys, done: doneKeys, totals, marked, names }
345
+ }
346
+
347
+ /**
348
+ * The selectors for one state's rows, each carrying its own pseudo-element.
349
+ *
350
+ * Every rule repeats the whole selector list. A comma-joined list applies
351
+ * the trailing `::after` to its last member only, so sharing one prefix
352
+ * would leave every row but one undecorated once two Workspaces share a
353
+ * state.
354
+ */
355
+ function selectors(keys, suffix) {
356
+ return keys.map((key) => `[data-row-key="workspace:${quote(key)}"]${suffix}`).join(',')
357
+ }
358
+
359
+ /**
360
+ * The stylesheet that draws each marked row's trailing mark.
361
+ *
362
+ * The mark is the row's `::after`, a flex child at the empty right-hand end
363
+ * beside the row actions, so it never displaces the title and needs no
364
+ * positioning. Waiting is a solid amber dot, running is the product's blue
365
+ * as a stroked ring, finished is the product's green dot. Colors come from
366
+ * the same theme tokens the Session rows use, so both levels agree and both
367
+ * themes are covered.
368
+ * @param activity - derived activity for the current render.
369
+ * @returns CSS text (no base rules).
370
+ */
371
+ function rowCss(activity) {
372
+ const parts = []
373
+
374
+ if (activity.waiting.length > 0) {
375
+ const waiting = selectors(activity.waiting, '::after')
376
+ parts.push(
377
+ `${waiting}{content:"";flex:none;box-sizing:border-box;width:6px;height:6px;`,
378
+ 'margin-inline-start:2px;border-radius:50%;',
379
+ 'background:var(--dsw-alias-state-warn-primary);',
380
+ 'box-shadow:0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-warn-primary) 16%, transparent)}',
381
+ )
382
+ }
383
+
384
+ if (activity.running.length > 0) {
385
+ const running = selectors(activity.running, '::after')
386
+ parts.push(
387
+ '@keyframes dsws-spin{to{transform:rotate(360deg)}}',
388
+ `${running}{content:"";flex:none;box-sizing:border-box;width:10px;height:10px;`,
389
+ 'margin-inline-start:2px;background-color:var(--dsw-static-deepseek-450);',
390
+ `-webkit-mask-image:${RING};mask-image:${RING};`,
391
+ '-webkit-mask-size:100% 100%;mask-size:100% 100%;',
392
+ '-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;',
393
+ 'animation:dsws-spin 1.5s linear infinite}',
394
+ `@media (prefers-reduced-motion:reduce){${running}{animation:none;opacity:.7}}`,
395
+ )
396
+ }
397
+
398
+ if (activity.done.length > 0) {
399
+ const done = selectors(activity.done, '::after')
400
+ parts.push(
401
+ `${done}{content:"";flex:none;box-sizing:border-box;width:6px;height:6px;`,
402
+ 'margin-inline-start:2px;border-radius:50%;',
403
+ 'background:var(--dsw-alias-state-success-primary);',
404
+ 'box-shadow:0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-success-primary) 14%, transparent)}',
405
+ )
406
+ }
407
+
408
+ return parts.join('')
409
+ }
410
+
411
+ /**
412
+ * The hidden description of the same state.
413
+ *
414
+ * Pseudo-elements are not reliably announced, so the aggregate travels as
415
+ * text: counts by state followed by the Workspace names, bounded so a large
416
+ * fleet does not turn into a paragraph.
417
+ * @param activity - derived activity for the current render.
418
+ * @returns one localized sentence.
419
+ */
420
+ function describe(activity) {
421
+ const separator = message('activity.listSeparator')
422
+ const clauses = []
423
+ if (activity.totals.waiting > 0) {
424
+ clauses.push(message('activity.waiting', { n: activity.totals.waiting }))
425
+ }
426
+ if (activity.totals.running > 0) {
427
+ clauses.push(message('activity.running', { n: activity.totals.running }))
428
+ }
429
+ if (activity.totals.done > 0) {
430
+ clauses.push(message('activity.done', { n: activity.totals.done }))
431
+ }
432
+ const orderedKeys = [...activity.waiting, ...activity.running, ...activity.done]
433
+ const names = orderedKeys.map((key) => {
434
+ const title = activity.names.get(key)
435
+ return title !== undefined && title !== '' ? title : message('activity.unnamed')
436
+ })
437
+ const listed = names.slice(0, MAX_NAMES)
438
+ const rest = names.length - listed.length
439
+ if (rest > 0) listed.push(message('activity.more', { n: rest }))
440
+ return message('activity.summary', {
441
+ clauses: clauses.join(separator),
442
+ names: listed.join(separator),
443
+ })
444
+ }
445
+
446
+ /**
447
+ * Read one standard selector hook, tolerating an assembly without it.
448
+ * @param hook - the standard hook, possibly absent.
449
+ * @param selector - projection over the hook's snapshot.
450
+ * @param fallback - snapshot used when the hook is absent.
451
+ * @returns the selected value.
452
+ */
453
+ function useStandard(hook, selector, fallback) {
454
+ return typeof hook === 'function' ? hook(selector) : selector(fallback)
455
+ }
456
+
457
+ const EMPTY_WORKSPACES = { items: [], archivedSessionIds: [] }
458
+ const EMPTY_SESSIONS = { byId: {} }
459
+ const EMPTY_STATUSES = new Map()
460
+
461
+ /**
462
+ * The slot entry. It shows no control of its own: it publishes the row
463
+ * rules, plus a description of the same state for readers who cannot see
464
+ * the mark.
465
+ * @param props - standard selector hooks.
466
+ * @returns the style + hidden description, or null when nothing is marked.
467
+ */
468
+ function WorkspaceStatus({ useWorkspaces, useSessions, useSessionStatus }) {
469
+ const items = useStandard(useWorkspaces, (state) => state?.items, EMPTY_WORKSPACES)
470
+ const archived = useStandard(useWorkspaces, (state) => state?.archivedSessionIds, EMPTY_WORKSPACES)
471
+ const byId = useStandard(useSessions, (state) => state?.byId, EMPTY_SESSIONS)
472
+ const statuses = useStandard(useSessionStatus, (state) => state, EMPTY_STATUSES)
473
+
474
+ const watchRef = React.useRef(undefined)
475
+ if (watchRef.current === undefined) {
476
+ watchRef.current = { running: new Set(), stoppedAt: new Map() }
477
+ }
478
+
479
+ const activity = React.useMemo(
480
+ // The window is measured at render time, the same way the browser reads
481
+ // its own clock: no timer, because any feed change re-renders anyway.
482
+ // Advancing the watch is idempotent, so a repeated render costs nothing.
483
+ () => deriveActivity(items, byId, statuses, archived, Date.now(), watchRef.current),
484
+ [items, byId, statuses, archived, watchRef],
485
+ )
486
+
487
+ if (activity.marked === 0) return null
488
+
489
+ return React.createElement(
490
+ React.Fragment,
491
+ null,
492
+ React.createElement('style', { 'data-plugin-css': STYLE_ID }, BASE_CSS + rowCss(activity)),
493
+ React.createElement('span', { className: SR_CLASS }, describe(activity)),
494
+ )
495
+ }
496
+
497
+ return {
498
+ name: 'workspace-status',
499
+ inject: ['slots'],
500
+ apply(ctx) {
501
+ ctx.slots.inject(SLOT, () => ctx.slots.register(
502
+ { name: SLOT, id: ENTRY_ID, order: ENTRY_ORDER },
503
+ WorkspaceStatus,
504
+ ))
505
+ },
506
+ /** Exposed for the package's own tests; the Loader ignores it. */
507
+ __test: { deriveActivity, rowCss, describe, quote, WorkspaceStatus, FINISHED_WINDOW_MS },
508
+ }
509
+ },
510
+ })
@@ -0,0 +1,6 @@
1
+ # The bundle layer this package contributes to a dsh profile.
2
+ # Rows inserted here become Loader entries once the profile lists this package
3
+ # in package.json -> dsh.profile.bundles (the Plugins page does both steps).
4
+ - insert:
5
+ - id: workspace-status
6
+ name: dsh-workspace-status
package/icon.svg ADDED
@@ -0,0 +1,8 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" role="img" aria-label="Workspace Status">
2
+ <rect x="2.25" y="2.25" width="19.5" height="19.5" rx="4.25" fill="#151A22"/>
3
+ <rect x="5" y="5.5" width="5" height="5" rx="1.3" fill="#3B4A5A"/>
4
+ <rect x="12.5" y="5.5" width="5" height="5" rx="1.3" fill="#3B4A5A"/>
5
+ <circle cx="7.5" cy="15.75" r="2.4" fill="none" stroke="#4D8DFF" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="4 9"/>
6
+ <circle cx="15" cy="15.75" r="1.8" fill="#F59E0B"/>
7
+ <circle cx="19.4" cy="15.75" r="1.8" fill="#22C55E"/>
8
+ </svg>
package/index.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Host half of workspace-status.
3
+ *
4
+ * The Client module owns the indicator: it reads Workspace and Session state
5
+ * through the slot's standard hooks and publishes the row rules from its own
6
+ * component, so the Host has no service to provide and no resource to register.
7
+ * This empty `apply` exists only to give the Loader a host-side row, exactly
8
+ * like any other bundle that is purely a browser feature.
9
+ */
10
+
11
+ /** Loader entry name; matches the patch row in `cordis.patch.yml`. */
12
+ export const name = 'workspace-status'
13
+
14
+ /** No Host services are required. */
15
+ export const inject = []
16
+
17
+ /** No Host-side behavior. */
18
+ export function apply() {}
package/locale/en.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "meta": {
3
+ "title": "Workspace Status",
4
+ "description": "Marks each workspace in the sidebar while one of its sessions is waiting for you, running, or recently finished."
5
+ },
6
+ "activity.summary": "Workspace activity: {clauses} — {names}",
7
+ "activity.waiting": "{n} waiting for you",
8
+ "activity.running": "{n} running",
9
+ "activity.done": "{n} recently finished",
10
+ "activity.unnamed": "Ungrouped",
11
+ "activity.more": "and {n} more",
12
+ "activity.listSeparator": ", "
13
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "meta": {
3
+ "title": "工作区状态",
4
+ "description": "侧边栏里,某个工作区有会话等待你处理、正在运行或刚刚完成时,在该工作区行上显示状态标记。"
5
+ },
6
+ "activity.summary": "工作区状态:{clauses} — {names}",
7
+ "activity.waiting": "{n} 个等待你处理",
8
+ "activity.running": "{n} 个运行中",
9
+ "activity.done": "{n} 个刚刚完成",
10
+ "activity.unnamed": "未分组",
11
+ "activity.more": "等 {n} 个",
12
+ "activity.listSeparator": "、"
13
+ }
package/package.json CHANGED
@@ -1,6 +1,77 @@
1
1
  {
2
2
  "name": "dsh-workspace-status",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Colored status marks on DeepSeek Harness sidebar workspaces: waiting for you, running, and recently finished.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "main": "index.js",
8
+ "scripts": {
9
+ "test": "node --test"
10
+ },
11
+ "keywords": [
12
+ "dsh",
13
+ "dsh-plugin",
14
+ "deepseek-harness",
15
+ "sidebar",
16
+ "workspace",
17
+ "status",
18
+ "indicator"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "exports": {
24
+ ".": "./index.js",
25
+ "./client": "./client.js",
26
+ "./cordis.patch.yml": "./cordis.patch.yml",
27
+ "./locale/*.json": "./locale/*.json",
28
+ "./icon": "./icon.svg",
29
+ "./package.json": "./package.json"
30
+ },
31
+ "files": [
32
+ "index.js",
33
+ "client.js",
34
+ "cordis.patch.yml",
35
+ "locale/*.json",
36
+ "icon.svg",
37
+ "README.md",
38
+ "CHANGELOG.md"
39
+ ],
40
+ "dsh": {
41
+ "bundle": {
42
+ "patch": "./cordis.patch.yml"
43
+ },
44
+ "client": {
45
+ "platform": "web",
46
+ "immediately": true,
47
+ "inject": [
48
+ "@deepseek-ai/dsh-client-locale",
49
+ "@deepseek-ai/dsh-client-ui-sidebar",
50
+ "@deepseek-ai/dsh-client-ui-workspace",
51
+ "@deepseek-ai/dsh-client-ui-session"
52
+ ]
53
+ },
54
+ "compatibility": {
55
+ "dsh": ">=0.2.1-rc.2 <0.3.0-0"
56
+ },
57
+ "catalog": {
58
+ "summary": {
59
+ "en": "Colored status marks on sidebar workspace rows: waiting for you, running, and recently finished, so the next workspace to open is obvious.",
60
+ "zh": "在侧边栏工作区行上显示彩色状态:等待你处理、运行中、刚刚完成,一眼看出下一个该打开的工作区。"
61
+ },
62
+ "category": "ui"
63
+ }
64
+ },
65
+ "engines": {
66
+ "dsh": ">=0.2.1-rc.2 <0.3.0-0",
67
+ "node": ">=22.18.0"
68
+ },
69
+ "repository": {
70
+ "type": "git",
71
+ "url": "git+https://github.com/Stijnus/dsh-workspace-status.git"
72
+ },
73
+ "homepage": "https://github.com/Stijnus/dsh-workspace-status#readme",
74
+ "bugs": {
75
+ "url": "https://github.com/Stijnus/dsh-workspace-status/issues"
76
+ }
77
+ }