@climber47/dsh-step-clock 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 climber47
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 ADDED
@@ -0,0 +1,84 @@
1
+ # dsh-step-clock
2
+
3
+ [中文](README.zh.md) | English
4
+
5
+ A live per-step elapsed-time bar for the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) web GUI. It answers one question the built-in turn timer does not: **how long has the step that is running right now been running?**
6
+
7
+ ```
8
+ ● 正在执行 bash,已运行 1 分 12 秒 第 12 步 [bash] 1:12
9
+ ```
10
+
11
+ and, once that step finishes, keeps the result on screen:
12
+
13
+ ```
14
+ ● 上一步(第 12 步)已完成,用时 3 分 45 秒 3:45
15
+ ```
16
+
17
+ The bar sits in the composer dock — the strip just above the input box, where the todo and goal bars live — with a second seat below the composer so a tall goal bar cannot push it out of view.
18
+
19
+ ## Why
20
+
21
+ The harness already shows a turn-level clock at the bottom of the conversation, but it only appears after 15 seconds and it measures the whole turn. When you are watching a long `bash` call you cannot tell whether it started two seconds ago or has been stuck for four minutes, and the turn clock cannot tell you either.
22
+
23
+ This plugin measures **the current step**, from the instant its activity actually started:
24
+
25
+ - **Tool steps** are anchored to the tool call's own start time. An in-flight call exists in the live snapshot only while it is in flight, so `0:47` means the call itself has been running 47 seconds — not an estimate derived from the turn.
26
+ - **Thinking steps** fall back to the step boundary, the closest honest anchor for model time.
27
+ - The clock ticks from `0:00` with **no delay threshold**.
28
+
29
+ ## What it says
30
+
31
+ The bar states the situation in plain language rather than showing a bare number:
32
+
33
+ | State | Wording |
34
+ | --- | --- |
35
+ | Running a tool | `正在执行 <tool>,已运行 47 秒` |
36
+ | Several tools at once | `正在执行 bash,另有 2 个工具并行,本步已运行 5 秒` |
37
+ | Model streaming | `模型正在思考,本步已耗时 23 秒` |
38
+ | Submitted, no output yet | `已提交,正在等待模型响应,已等待 3 秒` |
39
+ | Finished step | `上一步(第 12 步)已完成,用时 3 分 45 秒` |
40
+ | Nothing running | `空闲,等待下一步` |
41
+
42
+ Alongside it: the step number, a chip of the running tool names (up to 4, then `+N`, hover for all), and a compact `m:ss` clock at the right edge.
43
+
44
+ Durations read naturally — `47 秒`, `1 分 12 秒`, `2 分钟`, `1 小时 3 分` — so `0:03` is never ambiguous. The wording is Chinese, matching the language of the session this was built for.
45
+
46
+ ### Why the finished step stays
47
+
48
+ A step is frequently over in well under a second, so a bar that exists only *during* a step is easy to miss. Holding the last step's duration until the next step replaces it means a slow step — a `bash` call that ran for four minutes, say — can be identified after the fact. The record is kept only when the step's own `start` and `end` timestamps are both present; when they are not, the bar reports itself idle rather than inventing a duration.
49
+
50
+ ## Install
51
+
52
+ ```sh
53
+ dsh plugin --profile web add @climber47/dsh-step-clock
54
+ ```
55
+
56
+ Then restart `dsh web`. The bar appears above the composer during the next running step.
57
+
58
+ ## How it works
59
+
60
+ A dsh bundle, mounted as one inserted row:
61
+
62
+ - `package.json` declares `dsh.bundle.patch`, which is what makes the package installable, and `dsh.client` (`platform: web`), which is what serves the browser half.
63
+ - `cordis.patch.yml` inserts the `step-clock` row.
64
+ - `lib/index.js` is the (intentionally empty) host half. A bundle's row resolves the package root, so the package must be importable; this plugin has no host behaviour.
65
+ - `lib/client.js` is the browser half, in the `window.__ModuleLoader__.load({ id, factory })` registration shape a client bundle must have. React is taken from the module loader via `require('react')` rather than bundled.
66
+ - It registers two additive entries — `conversation.input.dock` above the composer and `conversation.composer.dock` below it — both `replaceRisk: none`, so every shipped todo / goal / queue / stats entry is untouched. It owns its stylesheet through `ctx.styles.insert`, disposed with its fiber.
67
+ - The shipped bundle is generated from `src/client/` by `npm run build`, so the readable source and the artifact cannot drift; `npm test` rebuilds and drives the bundle through its real loader contract.
68
+
69
+ It reads only facts the engine already publishes — the Chat timeline snapshot and the live running-call list — and polls nothing itself.
70
+
71
+ ## Requirements
72
+
73
+ - dsh `>=0.1.5-rc.1`
74
+ - React 18 (a peer dependency, provided by the harness shell)
75
+
76
+ ## Contributing
77
+
78
+ `contrib/awesome-dsh-plugin-entry.yml` is the single registry entry this package
79
+ submits to the curated list, kept here so the entry and the code stay in one
80
+ place. It is not part of the npm package.
81
+
82
+ ## License
83
+
84
+ MIT
package/README.zh.md ADDED
@@ -0,0 +1,78 @@
1
+ # dsh-step-clock
2
+
3
+ 中文 | [English](README.md)
4
+
5
+ 一个给 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 网页版用的**单步耗时实时秒表**。它回答内建计时器回答不了的那个问题:**正在跑的这一小步,已经跑了多久?**
6
+
7
+ ```
8
+ ● 正在执行 bash,已运行 1 分 12 秒 第 12 步 [bash] 1:12
9
+ ```
10
+
11
+ 那一步跑完之后,它会继续把结果留在屏幕上:
12
+
13
+ ```
14
+ ● 上一步(第 12 步)已完成,用时 3 分 45 秒 3:45
15
+ ```
16
+
17
+ 状态条位于输入框上方的那一条(和 todo、goal 条同排),并在输入框**下方**另有一处,这样目标条再高也挤不掉它。
18
+
19
+ ## 为什么需要它
20
+
21
+ Harness 本身已在对话底部显示一个轮次(turn)级时钟,但它**要等 15 秒才出现**,而且计的是**整轮**的时间。当你盯着一个长时间的 `bash` 调用时,你无法判断它是刚开始两秒,还是已经卡了四分钟——那个轮次时钟也回答不了。
22
+
23
+ 本插件计量的是**当前这一小步**,并且从它真正开始活动的时刻起算:
24
+
25
+ - **工具步骤**锚定在该工具调用自身的开始时间上。运行中的调用只在其运行期间存在于实时快照里,所以 `0:47` 表示**这次调用本身**已跑了 47 秒,而不是从轮次推算出来的估计值。
26
+ - **思考步骤**回退到步骤边界,这是模型耗时最诚实的锚点。
27
+ - 秒表**从 `0:00` 就开始跳,没有任何延迟门槛**。
28
+
29
+ ## 它会说什么
30
+
31
+ 它用直白的中文句子描述当前状况,而不是丢一个光秃秃的数字:
32
+
33
+ | 状态 | 文案 |
34
+ | --- | --- |
35
+ | 正在执行工具 | `正在执行 <工具名>,已运行 47 秒` |
36
+ | 多个工具并行 | `正在执行 bash,另有 2 个工具并行,本步已运行 5 秒` |
37
+ | 模型流式输出中 | `模型正在思考,本步已耗时 23 秒` |
38
+ | 已提交、还没输出 | `已提交,正在等待模型响应,已等待 3 秒` |
39
+ | 上一步已完成 | `上一步(第 12 步)已完成,用时 3 分 45 秒` |
40
+ | 什么都没跑 | `空闲,等待下一步` |
41
+
42
+ 旁边还有:步号、正在执行的工具名标签(最多 4 个,超出 `+N`,悬停看全部)、以及最右侧一个紧凑的 `m:ss` 数字。
43
+
44
+ 时长按中文习惯换算——`47 秒`、`1 分 12 秒`、`2 分钟`、`1 小时 3 分`——所以`0:03` 不会产生"3 秒还是 3 分钟"的歧义。
45
+
46
+ ### 为什么完成后还要留着
47
+
48
+ 一个步骤经常在一秒内就结束了,只在"运行期间"存在的状态条极易被错过。把上一步的用时留到下一步开始前,你就能**事后**发现某个慢步骤——比如一个跑了四分钟的 `bash`。只有在该步骤自己的 `start` 与 `end` 时间戳**都存在**时才记录;不存在时它会老老实实显示"空闲",而不是编一个看起来像真的耗时。
49
+
50
+ ## 安装
51
+
52
+ ```sh
53
+ dsh plugin --profile web add @climber47/dsh-step-clock
54
+ ```
55
+
56
+ 然后重启 `dsh web`。下一次有步骤运行时,状态条就会出现在输入框上方。
57
+
58
+ ## 实现方式
59
+
60
+ 这是一个 dsh bundle,以单条 insert 行挂载:
61
+
62
+ - `package.json` 声明 `dsh.bundle.patch`(这是它能被安装的关键)与 `dsh.client`(`platform: web`,这是浏览器半边被加载的关键)。
63
+ - `cordis.patch.yml` 插入 `step-clock` 行。
64
+ - `lib/index.js` 是(有意为空的)宿主半边。bundle 的 insert 行会解析包根,所以包必须可被导入;本插件没有宿主行为。
65
+ - `lib/client.js` 是浏览器半边,采用客户端 bundle 必须的 `window.__ModuleLoader__.load({ id, factory })` 注册形状。React 通过 `require('react')` 从模块加载器取得,不打包进产物。
66
+ - 它注册**两个纯新增**条目——输入框上方的 `conversation.input.dock` 与下方的 `conversation.composer.dock`,两者都是 `replaceRisk: none`,不会动自带的 todo / goal / queue / stats 条目。样式通过 `ctx.styles.insert` 归属自身,随 fiber 一起销毁。
67
+ - 发布的 bundle 由 `npm run build` 从 `src/client/` 生成,因此可读源码与产物不会漂移;`npm test` 会先重新构建,再把 bundle 喂进它真实的加载器契约里跑行为测试。
68
+
69
+ 它只读取引擎**已经发布**的事实——Chat 时间线快照与运行中调用列表——自身不做任何轮询。
70
+
71
+ ## 环境要求
72
+
73
+ - dsh `>=0.1.5-rc.1`
74
+ - React 18(peer 依赖,由 harness 外壳提供)
75
+
76
+ ## 许可
77
+
78
+ MIT
@@ -0,0 +1,8 @@
1
+ # dsh-step-clock bundle patch: inserts its plugin row into the web plugin roster.
2
+ #
3
+ # A single row mounts the package. This plugin has no host behaviour — the
4
+ # browser half (exports "./client") is declared by `dsh.client` in package.json
5
+ # and served from this package's lib/client.js by the client module host.
6
+ - insert:
7
+ - id: step-clock
8
+ name: '@climber47/dsh-step-clock'
package/lib/client.js ADDED
@@ -0,0 +1,389 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@climber47/dsh-step-clock",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react = require("react");
8
+ const React = react;
9
+ //#region src/client/styles.js
10
+ /**
11
+ * @climber47/dsh-step-clock — browser half styles.
12
+ *
13
+ * Kept beside the component so the bundle's injected stylesheet has a readable
14
+ * source. Colours and geometry come from dsh theme tokens and the composer
15
+ * layout variables, with fallbacks so a missing token degrades instead of
16
+ * breaking the bar. The `--dsh-composer-*` variables are declared on the
17
+ * conversation root, which is an ancestor of both dock seats.
18
+ *
19
+ * @module @climber47/dsh-step-clock/client/styles
20
+ */
21
+
22
+ const CSS = [
23
+ // The dock row is full viewport width; this wrapper centres a composer-width
24
+ // column inside it, mirroring the shipped GoalBar dock convention.
25
+ '.dsh-stepclock-dock{box-sizing:border-box;width:calc(100% - var(--dsh-composer-side-clearance,16px) - var(--dsh-composer-side-clearance,16px) - var(--dsh-composer-dock-inset,8px) - var(--dsh-composer-dock-inset,8px) - var(--dsh-composer-dock-inset,8px) - var(--dsh-composer-dock-inset,8px));margin:0 auto;}',
26
+ '.dsh-stepclock-root{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;box-sizing:border-box;width:100%;max-width:calc(var(--dsh-composer-card-max-width,780px) - 4 * var(--dsh-composer-dock-inset,8px));margin:0 auto;padding:6px 12px;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-specific-tip,var(--dsw-alias-bg-layer-2));color:var(--dsw-alias-label-secondary);font-size:13px;line-height:18px;}',
27
+ // A filled dot marks real activity; a hollow one marks an idle bar.
28
+ '.dsh-stepclock-dot{flex:none;align-self:center;width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-brand-primary);}',
29
+ '.dsh-stepclock-dot-idle{flex:none;align-self:center;width:8px;height:8px;border-radius:50%;border:1px solid var(--dsw-alias-border-l2);}',
30
+ '.dsh-stepclock-say{flex:1 1 auto;min-width:0;color:var(--dsw-alias-label-primary);}',
31
+ '.dsh-stepclock-where{flex:none;opacity:.6;font-size:12px;}',
32
+ '.dsh-stepclock-time{flex:none;color:var(--dsw-alias-brand-primary);font-weight:600;font-size:15px;}',
33
+ '.dsh-stepclock-chip{flex:none;max-width:22em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:1px 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:999px;background:var(--dsw-alias-bg-layer-1);font-size:12px;line-height:16px;}',
34
+ '.dsh-stepclock-mono{font-variant-numeric:tabular-nums;font-feature-settings:"tnum";}',
35
+ ].join('')
36
+ //#endregion
37
+ //#region src/client/index.js
38
+ /**
39
+ * @climber47/dsh-step-clock — browser half (readable source).
40
+ *
41
+ * The shipped file is `lib/client.js`, which wraps this component in the
42
+ * `window.__ModuleLoader__.load({ id, factory })` registration shape a dsh
43
+ * client bundle must have. This file is the same logic kept readable so a
44
+ * reader can review what the package does without unwrapping the bundle.
45
+ *
46
+ * What it does
47
+ * ------------
48
+ * Renders one ambient entry in the composer dock while an agent step is
49
+ * running, in plain language:
50
+ *
51
+ * 正在执行 bash,已运行 1 分 12 秒 第 12 步 [bash] 1:12
52
+ *
53
+ * and keeps the finished step on screen once it ends:
54
+ *
55
+ * 上一步(第 12 步)已完成,用时 3 分 45 秒 3:45
56
+ *
57
+ * Why the second line matters
58
+ * ---------------------------
59
+ * A step is often over in under a second, so a bar that only exists *during*
60
+ * a step is easy to miss entirely. Holding the last step's duration until the
61
+ * next step replaces it means a slow step can be identified after the fact.
62
+ * The record is only kept when the step's own `start` and `end` timestamps are
63
+ * both available; when they are not, the bar says it is idle rather than
64
+ * inventing a duration.
65
+ *
66
+ * It reads the live facts the engine already publishes rather than polling
67
+ * anything itself:
68
+ *
69
+ * - the open Turn and its open Step, from the Chat timeline snapshot, give
70
+ * the step number and the step's start time;
71
+ * - `legacy.runningCalls` holds one entry per in-flight Tool call and exists
72
+ * only while that call is in flight, so its `time` is the exact moment the
73
+ * call started — this is what makes "bash has been running 47s" precise
74
+ * instead of an estimate;
75
+ * - `legacy.partial` tells whether the model is currently streaming, which
76
+ * separates "thinking" from "waiting for the model".
77
+ *
78
+ * The clock ticks from 0s with no delay threshold.
79
+ *
80
+ * @module @climber47/dsh-step-clock/client
81
+ */
82
+
83
+
84
+ /** Tool names named inline before collapsing the remainder into `+N`. */
85
+ const MAX_NAMES = 4
86
+
87
+ /**
88
+ * Last-finished-step records, keyed by session so two open conversations never
89
+ * clash. Module-level so the record survives re-renders.
90
+ */
91
+ const RECORDS = new Map()
92
+
93
+ /** Floor a duration to whole seconds. */
94
+ function secondsOf(ms) {
95
+ return Math.max(0, Math.floor(ms / 1000))
96
+ }
97
+
98
+ /**
99
+ * Natural-language duration: `47 秒` / `1 分 12 秒` / `1 小时 3 分`.
100
+ * An exact minute drops the redundant `0 秒`; hours imply minutes.
101
+ * @param ms - duration in milliseconds.
102
+ * @returns the display string.
103
+ */
104
+ function humanDuration(ms) {
105
+ const total = secondsOf(ms)
106
+ if (total < 60) return total + ' 秒'
107
+ const minutes = Math.floor(total / 60)
108
+ if (minutes < 60) {
109
+ const rest = total % 60
110
+ return rest === 0 ? minutes + ' 分钟' : minutes + ' 分 ' + rest + ' 秒'
111
+ }
112
+ const hours = Math.floor(minutes / 60)
113
+ const restMinutes = minutes % 60
114
+ return restMinutes === 0 ? hours + ' 小时' : hours + ' 小时 ' + restMinutes + ' 分'
115
+ }
116
+
117
+ /**
118
+ * Compact clock for the right edge: `m:ss`, widening to `h:mm:ss` past an hour.
119
+ * @param ms - duration in milliseconds.
120
+ * @returns the display string.
121
+ */
122
+ function clockOf(ms) {
123
+ const total = secondsOf(ms)
124
+ const minutes = Math.floor(total / 60)
125
+ const seconds = total % 60
126
+ const pad = function (value) {
127
+ return (value < 10 ? '0' : '') + value
128
+ }
129
+ if (minutes < 60) return minutes + ':' + pad(seconds)
130
+ return Math.floor(minutes / 60) + ':' + pad(minutes % 60) + ':' + pad(seconds)
131
+ }
132
+
133
+ /** Newest Turn still open in the loaded timeline, or null. */
134
+ function latestOpenTurn(timeline) {
135
+ if (timeline === undefined || timeline === null) return null
136
+ const order = timeline.turnOrder
137
+ const turns = timeline.turns
138
+ if (order === undefined || turns === undefined || typeof turns.get !== 'function') return null
139
+ for (let index = order.length - 1; index >= 0; index -= 1) {
140
+ const turn = turns.get(order[index])
141
+ if (turn !== undefined && turn !== null && turn.status === 'open') return turn
142
+ }
143
+ return null
144
+ }
145
+
146
+ /** Newest Step of a Turn, when it is still open. */
147
+ function openStepOf(turn) {
148
+ const steps = turn.steps
149
+ if (steps === undefined || steps.length === 0) return null
150
+ const step = steps[steps.length - 1]
151
+ if (step === undefined || step === null || step.status !== 'open') return null
152
+ return step
153
+ }
154
+
155
+ /** Newest Turn in the loaded timeline whatever its status — the idle record source. */
156
+ function latestTurn(timeline) {
157
+ if (timeline === undefined || timeline === null) return null
158
+ const order = timeline.turnOrder
159
+ const turns = timeline.turns
160
+ if (order === undefined || turns === undefined || typeof turns.get !== 'function' || order.length === 0) return null
161
+ return turns.get(order[order.length - 1])
162
+ }
163
+
164
+ /** Read or create this session's record slot. */
165
+ function recordFor(sessionKey) {
166
+ const existing = RECORDS.get(sessionKey)
167
+ if (existing !== undefined) return existing
168
+ const fresh = { final: null, pending: null, seenTurn: null, seenStep: null }
169
+ RECORDS.set(sessionKey, fresh)
170
+ return fresh
171
+ }
172
+
173
+ /**
174
+ * The dock entry component.
175
+ *
176
+ * Receives only standard slot props: `useChat` selects from the live Chat
177
+ * snapshot (driving re-render on every engine update) and `timer` supplies a
178
+ * disposable interval, so nothing here creates a process-wide side effect.
179
+ */
180
+ function StepClock(props) {
181
+ const useChat = props.useChat
182
+ const timer = props.timer
183
+ const label = props.label === undefined ? '' : props.label
184
+ const sessionKey = props.sessionId === undefined ? 'default' : String(props.sessionId)
185
+ // Wall clock, injectable so the duration maths is testable without freezing time.
186
+ const now = props.now === undefined ? Date.now() : props.now
187
+ const hold = React.useState(0)
188
+ const tick = hold[0]
189
+ const bump = hold[1]
190
+ const timeline = useChat(function (snapshot) {
191
+ return snapshot.timeline
192
+ })
193
+ const legacy = useChat(function (snapshot) {
194
+ return snapshot.legacy
195
+ })
196
+
197
+ const turn = latestOpenTurn(timeline)
198
+ const step = turn === null ? null : openStepOf(turn)
199
+ const stepNumber = step === null ? 0 : step.step
200
+ const stepStart =
201
+ step !== null && step.start !== undefined && step.start !== null && typeof step.start.time === 'number'
202
+ ? step.start.time
203
+ : null
204
+
205
+ const runningCalls =
206
+ legacy === undefined || legacy === null || legacy.runningCalls === undefined || legacy.runningCalls === null
207
+ ? []
208
+ : legacy.runningCalls
209
+ const partial = legacy === undefined || legacy === null ? null : legacy.partial
210
+
211
+ let firstCallTime = null
212
+ const names = []
213
+ let callCount = 0
214
+ for (let index = 0; index < runningCalls.length; index += 1) {
215
+ const call = runningCalls[index]
216
+ if (call === undefined || call === null) continue
217
+ if (call.step !== stepNumber) continue
218
+ callCount += 1
219
+ if (typeof call.time === 'number' && (firstCallTime === null || call.time < firstCallTime)) firstCallTime = call.time
220
+ if (typeof call.name === 'string' && call.name !== '' && names.indexOf(call.name) < 0) names.push(call.name)
221
+ }
222
+
223
+ const thinking = stepNumber > 0 && partial !== null && partial !== undefined && partial.step === stepNumber
224
+
225
+ // A running Tool call is the most precise anchor there is: it starts when the
226
+ // call is logged and disappears when the result lands. Thinking falls back to
227
+ // the step boundary, the closest honest anchor for model time.
228
+ let anchor = firstCallTime
229
+ if (anchor === null && thinking && stepStart !== null) anchor = stepStart
230
+ if (anchor === null) anchor = stepStart
231
+
232
+ // Always ticking: an active clock needs it, and the idle record has to reveal
233
+ // itself once its duration is worth reporting.
234
+ React.useEffect(function () {
235
+ const dispose = timer.interval(function () {
236
+ bump(function (value) {
237
+ return value + 1
238
+ })
239
+ }, 500)
240
+ return function () {
241
+ if (typeof dispose === 'function') dispose()
242
+ }
243
+ }, [])
244
+
245
+ const live = turn !== null && step !== null
246
+
247
+ // Record the step of any closed Turn.
248
+ //
249
+ // Keyed on (turn, step) rather than on the turn alone: the engine reopens a
250
+ // finished Turn when follow-up work arrives, so a Turn can close more than
251
+ // once, and a turn-keyed guard would silently stop updating after the first
252
+ // close. Recording also runs while a Turn is live, which is what lets a
253
+ // reopened Turn's newly finished step land without waiting for it to close
254
+ // again.
255
+ //
256
+ // Only a step whose own `start` AND `end` are both present is recorded; an
257
+ // absent pair means the loaded window does not carry the evidence, and the
258
+ // bar reports itself idle rather than inventing a duration.
259
+ const record = recordFor(sessionKey)
260
+ const candidate = latestTurn(timeline)
261
+ if (candidate !== undefined && candidate !== null) {
262
+ const steps = candidate.steps
263
+ const last = steps === undefined || steps === null || steps.length === 0 ? null : steps[steps.length - 1]
264
+ if (last !== undefined && last !== null && last.status !== 'open') {
265
+ const began = last.start === undefined || last.start === null ? null : last.start.time
266
+ const ended = last.end === undefined || last.end === null ? null : last.end.time
267
+ if (typeof began === 'number' && typeof ended === 'number' && ended >= began) {
268
+ if (record.seenTurn !== candidate.turn || record.seenStep !== last.step) {
269
+ record.final = record.pending
270
+ record.pending = { turn: candidate.turn, step: last.step, ms: ended - began, closedAt: now }
271
+ record.seenTurn = candidate.turn
272
+ record.seenStep = last.step
273
+ }
274
+ }
275
+ }
276
+ }
277
+
278
+ let say = ''
279
+ let where = ''
280
+ let clock = ''
281
+ let tone = 'idle'
282
+ let chips = []
283
+ const shown = record.pending === null ? record.final : record.pending
284
+
285
+ if (live) {
286
+ tone = 'active'
287
+ const elapsedMs = anchor === null ? 0 : now - anchor
288
+ if (callCount > 0) {
289
+ chips = names.length > MAX_NAMES ? names.slice(0, MAX_NAMES).concat('+' + (names.length - MAX_NAMES)) : names
290
+ const lead = names.length > 0 ? '正在执行 ' + names[0] : '正在执行工具'
291
+ say =
292
+ callCount > 1
293
+ ? lead + ',另有 ' + (callCount - 1) + ' 个工具并行,本步已运行 ' + humanDuration(elapsedMs)
294
+ : lead + ',已运行 ' + humanDuration(elapsedMs)
295
+ where = '第 ' + stepNumber + ' 步'
296
+ } else if (thinking) {
297
+ say = '模型正在思考,本步已耗时 ' + humanDuration(elapsedMs)
298
+ where = '第 ' + stepNumber + ' 步'
299
+ } else {
300
+ say = '已提交,正在等待模型响应,已等待 ' + humanDuration(elapsedMs)
301
+ where = '第 ' + stepNumber + ' 步'
302
+ }
303
+ clock = clockOf(elapsedMs)
304
+ } else if (shown !== null) {
305
+ // No step is running (yet). Keep reporting the previous step, so the gap
306
+ // between a Turn closing and the next step starting — and a fresh page load
307
+ // whose newest Turn is already closed — both stay informative.
308
+ tone = 'done'
309
+ say = '上一步(第 ' + shown.step + ' 步)已完成,用时 ' + humanDuration(shown.ms)
310
+ clock = clockOf(shown.ms)
311
+ } else {
312
+ say = '空闲,等待下一步'
313
+ }
314
+
315
+ const children = []
316
+ children.push(
317
+ React.createElement('span', {
318
+ key: 'dot',
319
+ className: tone === 'idle' ? 'dsh-stepclock-dot-idle' : 'dsh-stepclock-dot',
320
+ }),
321
+ )
322
+ children.push(React.createElement('span', { key: 'say', className: 'dsh-stepclock-say' }, say))
323
+ if (where !== '') children.push(React.createElement('span', { key: 'where', className: 'dsh-stepclock-where' }, where))
324
+ if (chips.length > 0) {
325
+ children.push(
326
+ React.createElement(
327
+ 'span',
328
+ { key: 'chips', className: 'dsh-stepclock-chip', title: names.join(' + ') },
329
+ chips.join(' · '),
330
+ ),
331
+ )
332
+ }
333
+ if (clock !== '') {
334
+ children.push(React.createElement('span', { key: 'clock', className: 'dsh-stepclock-time dsh-stepclock-mono' }, clock))
335
+ }
336
+
337
+ return React.createElement(
338
+ 'div',
339
+ { className: 'dsh-stepclock-dock' },
340
+ React.createElement(
341
+ 'div',
342
+ { className: 'dsh-stepclock-root', role: 'status', 'aria-live': 'off', title: label + ' ' + say },
343
+ children,
344
+ ),
345
+ )
346
+ }
347
+
348
+ /**
349
+ * Services this plugin waits for before applying.
350
+ *
351
+ * Declared rather than assumed: `styles` and `slots` are owned by the renderer
352
+ * module, and Cordis parks this plugin until they exist instead of calling
353
+ * `apply` with a half-built context. `timer` supplies the ticking interval.
354
+ */
355
+ const inject = ['slots', 'styles', 'timer']
356
+
357
+ /**
358
+ * Register the dock entry.
359
+ *
360
+ * A single additive entry in `conversation.input.dock` (the strip above the
361
+ * composer, beside the todo / goal / queue bars). `replaceRisk: none`: a fresh
362
+ * `id` is added beside the shipped entries rather than replacing any of them.
363
+ */
364
+ function apply(ctx) {
365
+ ctx.effect(function () {
366
+ return ctx.styles.insert(CSS)
367
+ })
368
+ const mount = function (slot, id, order, label) {
369
+ ctx.slots.inject(slot, function () {
370
+ return ctx.slots.register({ name: slot, id: id, order: order }, function (props) {
371
+ return React.createElement(StepClock, {
372
+ useChat: props.useChat,
373
+ timer: ctx.timer,
374
+ sessionId: props.sessionId,
375
+ label: label,
376
+ })
377
+ })
378
+ })
379
+ }
380
+ mount('conversation.input.dock', 'step-clock', 15, '上')
381
+ }
382
+ //#endregion
383
+ exports.StepClock = StepClock;
384
+ exports.CSS = CSS;
385
+ exports.apply = apply;
386
+ exports.inject = inject;
387
+ return module.exports;
388
+ }
389
+ });
package/lib/index.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @climber47/dsh-step-clock — host half.
3
+ *
4
+ * This plugin is browser-only: every piece of behaviour is the dock entry in
5
+ * the client half (`src/client/index.js`, served from `lib/client.js` as the
6
+ * package's `./client` export and declared through `dsh.client` in
7
+ * package.json). The host half therefore contributes nothing at runtime.
8
+ *
9
+ * It still exists, and is still loaded, because a dsh bundle's inserted row
10
+ * resolves the package root: a package that could not be imported would not
11
+ * mount. Both exports below are the empty, valid shape for that contract.
12
+ *
13
+ * @module @climber47/dsh-step-clock
14
+ */
15
+
16
+ /** No services are required: the host half registers nothing. */
17
+ export const inject = []
18
+
19
+ /**
20
+ * Mount the (empty) host half.
21
+ *
22
+ * The client half is loaded independently by the client module host, which
23
+ * scans installed packages for a `dsh.client` declaration; it does not depend
24
+ * on anything this function does.
25
+ */
26
+ export function apply() {}
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@climber47/dsh-step-clock",
3
+ "version": "0.1.0",
4
+ "description": "Live per-step elapsed-time clock for the DeepSeek Harness web GUI: shows which step is running, which tools it is running, and how long it has been going.",
5
+ "keywords": [
6
+ "deepseek",
7
+ "harness",
8
+ "dsh",
9
+ "dsh-plugin",
10
+ "ui"
11
+ ],
12
+ "type": "module",
13
+ "engines": {
14
+ "node": "^22.19.0 || >=24.0.0"
15
+ },
16
+ "main": "lib/index.js",
17
+ "scripts": {
18
+ "build": "node scripts/build-client.mjs",
19
+ "test": "node scripts/build-client.mjs && node --test \"tests/**/*.mjs\""
20
+ },
21
+ "exports": {
22
+ ".": {
23
+ "default": "./lib/index.js"
24
+ },
25
+ "./client": {
26
+ "default": "./lib/client.js"
27
+ },
28
+ "./src/*": "./src/*",
29
+ "./package.json": "./package.json"
30
+ },
31
+ "files": [
32
+ "lib/index.js",
33
+ "lib/client.js",
34
+ "src",
35
+ "cordis.patch.yml",
36
+ "README.md",
37
+ "README.zh.md",
38
+ "LICENSE"
39
+ ],
40
+ "license": "MIT",
41
+ "dsh": {
42
+ "engines": {
43
+ "dsh": ">=0.1.5-rc.1"
44
+ },
45
+ "bundle": {
46
+ "patch": "./cordis.patch.yml"
47
+ },
48
+ "client": {
49
+ "inject": [
50
+ "@deepseek-ai/dsh-client-ui-renderer"
51
+ ],
52
+ "platform": "web"
53
+ }
54
+ },
55
+ "peerDependencies": {
56
+ "react": "^18.2.0"
57
+ },
58
+ "repository": {
59
+ "type": "git",
60
+ "url": "git+https://github.com/WsTe47/dsh-step-clock.git"
61
+ },
62
+ "bugs": {
63
+ "url": "https://github.com/WsTe47/dsh-step-clock/issues"
64
+ },
65
+ "homepage": "https://github.com/WsTe47/dsh-step-clock#readme",
66
+ "author": "climber47"
67
+ }
@@ -0,0 +1,345 @@
1
+ /**
2
+ * @climber47/dsh-step-clock — browser half (readable source).
3
+ *
4
+ * The shipped file is `lib/client.js`, which wraps this component in the
5
+ * `window.__ModuleLoader__.load({ id, factory })` registration shape a dsh
6
+ * client bundle must have. This file is the same logic kept readable so a
7
+ * reader can review what the package does without unwrapping the bundle.
8
+ *
9
+ * What it does
10
+ * ------------
11
+ * Renders one ambient entry in the composer dock while an agent step is
12
+ * running, in plain language:
13
+ *
14
+ * 正在执行 bash,已运行 1 分 12 秒 第 12 步 [bash] 1:12
15
+ *
16
+ * and keeps the finished step on screen once it ends:
17
+ *
18
+ * 上一步(第 12 步)已完成,用时 3 分 45 秒 3:45
19
+ *
20
+ * Why the second line matters
21
+ * ---------------------------
22
+ * A step is often over in under a second, so a bar that only exists *during*
23
+ * a step is easy to miss entirely. Holding the last step's duration until the
24
+ * next step replaces it means a slow step can be identified after the fact.
25
+ * The record is only kept when the step's own `start` and `end` timestamps are
26
+ * both available; when they are not, the bar says it is idle rather than
27
+ * inventing a duration.
28
+ *
29
+ * It reads the live facts the engine already publishes rather than polling
30
+ * anything itself:
31
+ *
32
+ * - the open Turn and its open Step, from the Chat timeline snapshot, give
33
+ * the step number and the step's start time;
34
+ * - `legacy.runningCalls` holds one entry per in-flight Tool call and exists
35
+ * only while that call is in flight, so its `time` is the exact moment the
36
+ * call started — this is what makes "bash has been running 47s" precise
37
+ * instead of an estimate;
38
+ * - `legacy.partial` tells whether the model is currently streaming, which
39
+ * separates "thinking" from "waiting for the model".
40
+ *
41
+ * The clock ticks from 0s with no delay threshold.
42
+ *
43
+ * @module @climber47/dsh-step-clock/client
44
+ */
45
+
46
+ import { CSS } from './styles.js'
47
+
48
+ /** Tool names named inline before collapsing the remainder into `+N`. */
49
+ const MAX_NAMES = 4
50
+
51
+ /**
52
+ * Last-finished-step records, keyed by session so two open conversations never
53
+ * clash. Module-level so the record survives re-renders.
54
+ */
55
+ const RECORDS = new Map()
56
+
57
+ /** Floor a duration to whole seconds. */
58
+ function secondsOf(ms) {
59
+ return Math.max(0, Math.floor(ms / 1000))
60
+ }
61
+
62
+ /**
63
+ * Natural-language duration: `47 秒` / `1 分 12 秒` / `1 小时 3 分`.
64
+ * An exact minute drops the redundant `0 秒`; hours imply minutes.
65
+ * @param ms - duration in milliseconds.
66
+ * @returns the display string.
67
+ */
68
+ function humanDuration(ms) {
69
+ const total = secondsOf(ms)
70
+ if (total < 60) return total + ' 秒'
71
+ const minutes = Math.floor(total / 60)
72
+ if (minutes < 60) {
73
+ const rest = total % 60
74
+ return rest === 0 ? minutes + ' 分钟' : minutes + ' 分 ' + rest + ' 秒'
75
+ }
76
+ const hours = Math.floor(minutes / 60)
77
+ const restMinutes = minutes % 60
78
+ return restMinutes === 0 ? hours + ' 小时' : hours + ' 小时 ' + restMinutes + ' 分'
79
+ }
80
+
81
+ /**
82
+ * Compact clock for the right edge: `m:ss`, widening to `h:mm:ss` past an hour.
83
+ * @param ms - duration in milliseconds.
84
+ * @returns the display string.
85
+ */
86
+ function clockOf(ms) {
87
+ const total = secondsOf(ms)
88
+ const minutes = Math.floor(total / 60)
89
+ const seconds = total % 60
90
+ const pad = function (value) {
91
+ return (value < 10 ? '0' : '') + value
92
+ }
93
+ if (minutes < 60) return minutes + ':' + pad(seconds)
94
+ return Math.floor(minutes / 60) + ':' + pad(minutes % 60) + ':' + pad(seconds)
95
+ }
96
+
97
+ /** Newest Turn still open in the loaded timeline, or null. */
98
+ function latestOpenTurn(timeline) {
99
+ if (timeline === undefined || timeline === null) return null
100
+ const order = timeline.turnOrder
101
+ const turns = timeline.turns
102
+ if (order === undefined || turns === undefined || typeof turns.get !== 'function') return null
103
+ for (let index = order.length - 1; index >= 0; index -= 1) {
104
+ const turn = turns.get(order[index])
105
+ if (turn !== undefined && turn !== null && turn.status === 'open') return turn
106
+ }
107
+ return null
108
+ }
109
+
110
+ /** Newest Step of a Turn, when it is still open. */
111
+ function openStepOf(turn) {
112
+ const steps = turn.steps
113
+ if (steps === undefined || steps.length === 0) return null
114
+ const step = steps[steps.length - 1]
115
+ if (step === undefined || step === null || step.status !== 'open') return null
116
+ return step
117
+ }
118
+
119
+ /** Newest Turn in the loaded timeline whatever its status — the idle record source. */
120
+ function latestTurn(timeline) {
121
+ if (timeline === undefined || timeline === null) return null
122
+ const order = timeline.turnOrder
123
+ const turns = timeline.turns
124
+ if (order === undefined || turns === undefined || typeof turns.get !== 'function' || order.length === 0) return null
125
+ return turns.get(order[order.length - 1])
126
+ }
127
+
128
+ /** Read or create this session's record slot. */
129
+ function recordFor(sessionKey) {
130
+ const existing = RECORDS.get(sessionKey)
131
+ if (existing !== undefined) return existing
132
+ const fresh = { final: null, pending: null, seenTurn: null, seenStep: null }
133
+ RECORDS.set(sessionKey, fresh)
134
+ return fresh
135
+ }
136
+
137
+ /**
138
+ * The dock entry component.
139
+ *
140
+ * Receives only standard slot props: `useChat` selects from the live Chat
141
+ * snapshot (driving re-render on every engine update) and `timer` supplies a
142
+ * disposable interval, so nothing here creates a process-wide side effect.
143
+ */
144
+ export function StepClock(props) {
145
+ const useChat = props.useChat
146
+ const timer = props.timer
147
+ const label = props.label === undefined ? '' : props.label
148
+ const sessionKey = props.sessionId === undefined ? 'default' : String(props.sessionId)
149
+ // Wall clock, injectable so the duration maths is testable without freezing time.
150
+ const now = props.now === undefined ? Date.now() : props.now
151
+ const hold = React.useState(0)
152
+ const tick = hold[0]
153
+ const bump = hold[1]
154
+ const timeline = useChat(function (snapshot) {
155
+ return snapshot.timeline
156
+ })
157
+ const legacy = useChat(function (snapshot) {
158
+ return snapshot.legacy
159
+ })
160
+
161
+ const turn = latestOpenTurn(timeline)
162
+ const step = turn === null ? null : openStepOf(turn)
163
+ const stepNumber = step === null ? 0 : step.step
164
+ const stepStart =
165
+ step !== null && step.start !== undefined && step.start !== null && typeof step.start.time === 'number'
166
+ ? step.start.time
167
+ : null
168
+
169
+ const runningCalls =
170
+ legacy === undefined || legacy === null || legacy.runningCalls === undefined || legacy.runningCalls === null
171
+ ? []
172
+ : legacy.runningCalls
173
+ const partial = legacy === undefined || legacy === null ? null : legacy.partial
174
+
175
+ let firstCallTime = null
176
+ const names = []
177
+ let callCount = 0
178
+ for (let index = 0; index < runningCalls.length; index += 1) {
179
+ const call = runningCalls[index]
180
+ if (call === undefined || call === null) continue
181
+ if (call.step !== stepNumber) continue
182
+ callCount += 1
183
+ if (typeof call.time === 'number' && (firstCallTime === null || call.time < firstCallTime)) firstCallTime = call.time
184
+ if (typeof call.name === 'string' && call.name !== '' && names.indexOf(call.name) < 0) names.push(call.name)
185
+ }
186
+
187
+ const thinking = stepNumber > 0 && partial !== null && partial !== undefined && partial.step === stepNumber
188
+
189
+ // A running Tool call is the most precise anchor there is: it starts when the
190
+ // call is logged and disappears when the result lands. Thinking falls back to
191
+ // the step boundary, the closest honest anchor for model time.
192
+ let anchor = firstCallTime
193
+ if (anchor === null && thinking && stepStart !== null) anchor = stepStart
194
+ if (anchor === null) anchor = stepStart
195
+
196
+ // Always ticking: an active clock needs it, and the idle record has to reveal
197
+ // itself once its duration is worth reporting.
198
+ React.useEffect(function () {
199
+ const dispose = timer.interval(function () {
200
+ bump(function (value) {
201
+ return value + 1
202
+ })
203
+ }, 500)
204
+ return function () {
205
+ if (typeof dispose === 'function') dispose()
206
+ }
207
+ }, [])
208
+
209
+ const live = turn !== null && step !== null
210
+
211
+ // Record the step of any closed Turn.
212
+ //
213
+ // Keyed on (turn, step) rather than on the turn alone: the engine reopens a
214
+ // finished Turn when follow-up work arrives, so a Turn can close more than
215
+ // once, and a turn-keyed guard would silently stop updating after the first
216
+ // close. Recording also runs while a Turn is live, which is what lets a
217
+ // reopened Turn's newly finished step land without waiting for it to close
218
+ // again.
219
+ //
220
+ // Only a step whose own `start` AND `end` are both present is recorded; an
221
+ // absent pair means the loaded window does not carry the evidence, and the
222
+ // bar reports itself idle rather than inventing a duration.
223
+ const record = recordFor(sessionKey)
224
+ const candidate = latestTurn(timeline)
225
+ if (candidate !== undefined && candidate !== null) {
226
+ const steps = candidate.steps
227
+ const last = steps === undefined || steps === null || steps.length === 0 ? null : steps[steps.length - 1]
228
+ if (last !== undefined && last !== null && last.status !== 'open') {
229
+ const began = last.start === undefined || last.start === null ? null : last.start.time
230
+ const ended = last.end === undefined || last.end === null ? null : last.end.time
231
+ if (typeof began === 'number' && typeof ended === 'number' && ended >= began) {
232
+ if (record.seenTurn !== candidate.turn || record.seenStep !== last.step) {
233
+ record.final = record.pending
234
+ record.pending = { turn: candidate.turn, step: last.step, ms: ended - began, closedAt: now }
235
+ record.seenTurn = candidate.turn
236
+ record.seenStep = last.step
237
+ }
238
+ }
239
+ }
240
+ }
241
+
242
+ let say = ''
243
+ let where = ''
244
+ let clock = ''
245
+ let tone = 'idle'
246
+ let chips = []
247
+ const shown = record.pending === null ? record.final : record.pending
248
+
249
+ if (live) {
250
+ tone = 'active'
251
+ const elapsedMs = anchor === null ? 0 : now - anchor
252
+ if (callCount > 0) {
253
+ chips = names.length > MAX_NAMES ? names.slice(0, MAX_NAMES).concat('+' + (names.length - MAX_NAMES)) : names
254
+ const lead = names.length > 0 ? '正在执行 ' + names[0] : '正在执行工具'
255
+ say =
256
+ callCount > 1
257
+ ? lead + ',另有 ' + (callCount - 1) + ' 个工具并行,本步已运行 ' + humanDuration(elapsedMs)
258
+ : lead + ',已运行 ' + humanDuration(elapsedMs)
259
+ where = '第 ' + stepNumber + ' 步'
260
+ } else if (thinking) {
261
+ say = '模型正在思考,本步已耗时 ' + humanDuration(elapsedMs)
262
+ where = '第 ' + stepNumber + ' 步'
263
+ } else {
264
+ say = '已提交,正在等待模型响应,已等待 ' + humanDuration(elapsedMs)
265
+ where = '第 ' + stepNumber + ' 步'
266
+ }
267
+ clock = clockOf(elapsedMs)
268
+ } else if (shown !== null) {
269
+ // No step is running (yet). Keep reporting the previous step, so the gap
270
+ // between a Turn closing and the next step starting — and a fresh page load
271
+ // whose newest Turn is already closed — both stay informative.
272
+ tone = 'done'
273
+ say = '上一步(第 ' + shown.step + ' 步)已完成,用时 ' + humanDuration(shown.ms)
274
+ clock = clockOf(shown.ms)
275
+ } else {
276
+ say = '空闲,等待下一步'
277
+ }
278
+
279
+ const children = []
280
+ children.push(
281
+ React.createElement('span', {
282
+ key: 'dot',
283
+ className: tone === 'idle' ? 'dsh-stepclock-dot-idle' : 'dsh-stepclock-dot',
284
+ }),
285
+ )
286
+ children.push(React.createElement('span', { key: 'say', className: 'dsh-stepclock-say' }, say))
287
+ if (where !== '') children.push(React.createElement('span', { key: 'where', className: 'dsh-stepclock-where' }, where))
288
+ if (chips.length > 0) {
289
+ children.push(
290
+ React.createElement(
291
+ 'span',
292
+ { key: 'chips', className: 'dsh-stepclock-chip', title: names.join(' + ') },
293
+ chips.join(' · '),
294
+ ),
295
+ )
296
+ }
297
+ if (clock !== '') {
298
+ children.push(React.createElement('span', { key: 'clock', className: 'dsh-stepclock-time dsh-stepclock-mono' }, clock))
299
+ }
300
+
301
+ return React.createElement(
302
+ 'div',
303
+ { className: 'dsh-stepclock-dock' },
304
+ React.createElement(
305
+ 'div',
306
+ { className: 'dsh-stepclock-root', role: 'status', 'aria-live': 'off', title: label + ' ' + say },
307
+ children,
308
+ ),
309
+ )
310
+ }
311
+
312
+ /**
313
+ * Services this plugin waits for before applying.
314
+ *
315
+ * Declared rather than assumed: `styles` and `slots` are owned by the renderer
316
+ * module, and Cordis parks this plugin until they exist instead of calling
317
+ * `apply` with a half-built context. `timer` supplies the ticking interval.
318
+ */
319
+ export const inject = ['slots', 'styles', 'timer']
320
+
321
+ /**
322
+ * Register the dock entry.
323
+ *
324
+ * A single additive entry in `conversation.input.dock` (the strip above the
325
+ * composer, beside the todo / goal / queue bars). `replaceRisk: none`: a fresh
326
+ * `id` is added beside the shipped entries rather than replacing any of them.
327
+ */
328
+ export function apply(ctx) {
329
+ ctx.effect(function () {
330
+ return ctx.styles.insert(CSS)
331
+ })
332
+ const mount = function (slot, id, order, label) {
333
+ ctx.slots.inject(slot, function () {
334
+ return ctx.slots.register({ name: slot, id: id, order: order }, function (props) {
335
+ return React.createElement(StepClock, {
336
+ useChat: props.useChat,
337
+ timer: ctx.timer,
338
+ sessionId: props.sessionId,
339
+ label: label,
340
+ })
341
+ })
342
+ })
343
+ }
344
+ mount('conversation.input.dock', 'step-clock', 15, '上')
345
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @climber47/dsh-step-clock — browser half styles.
3
+ *
4
+ * Kept beside the component so the bundle's injected stylesheet has a readable
5
+ * source. Colours and geometry come from dsh theme tokens and the composer
6
+ * layout variables, with fallbacks so a missing token degrades instead of
7
+ * breaking the bar. The `--dsh-composer-*` variables are declared on the
8
+ * conversation root, which is an ancestor of both dock seats.
9
+ *
10
+ * @module @climber47/dsh-step-clock/client/styles
11
+ */
12
+
13
+ export const CSS = [
14
+ // The dock row is full viewport width; this wrapper centres a composer-width
15
+ // column inside it, mirroring the shipped GoalBar dock convention.
16
+ '.dsh-stepclock-dock{box-sizing:border-box;width:calc(100% - var(--dsh-composer-side-clearance,16px) - var(--dsh-composer-side-clearance,16px) - var(--dsh-composer-dock-inset,8px) - var(--dsh-composer-dock-inset,8px) - var(--dsh-composer-dock-inset,8px) - var(--dsh-composer-dock-inset,8px));margin:0 auto;}',
17
+ '.dsh-stepclock-root{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;box-sizing:border-box;width:100%;max-width:calc(var(--dsh-composer-card-max-width,780px) - 4 * var(--dsh-composer-dock-inset,8px));margin:0 auto;padding:6px 12px;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-specific-tip,var(--dsw-alias-bg-layer-2));color:var(--dsw-alias-label-secondary);font-size:13px;line-height:18px;}',
18
+ // A filled dot marks real activity; a hollow one marks an idle bar.
19
+ '.dsh-stepclock-dot{flex:none;align-self:center;width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-brand-primary);}',
20
+ '.dsh-stepclock-dot-idle{flex:none;align-self:center;width:8px;height:8px;border-radius:50%;border:1px solid var(--dsw-alias-border-l2);}',
21
+ '.dsh-stepclock-say{flex:1 1 auto;min-width:0;color:var(--dsw-alias-label-primary);}',
22
+ '.dsh-stepclock-where{flex:none;opacity:.6;font-size:12px;}',
23
+ '.dsh-stepclock-time{flex:none;color:var(--dsw-alias-brand-primary);font-weight:600;font-size:15px;}',
24
+ '.dsh-stepclock-chip{flex:none;max-width:22em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:1px 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:999px;background:var(--dsw-alias-bg-layer-1);font-size:12px;line-height:16px;}',
25
+ '.dsh-stepclock-mono{font-variant-numeric:tabular-nums;font-feature-settings:"tnum";}',
26
+ ].join('')