dsh-notch 0.3.1 → 0.3.6

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.
Files changed (102) hide show
  1. package/README.en.md +58 -47
  2. package/README.md +60 -55
  3. package/cordis.patch.yml +3 -0
  4. package/desktop/README.md +29 -0
  5. package/desktop/notch-lifecycle.mjs +120 -0
  6. package/desktop/restart-probe.mjs +61 -0
  7. package/docs/desktop-017rc2.md +23 -0
  8. package/docs/elastic-edge-dock.md +127 -0
  9. package/docs/motion-audit.md +1 -1
  10. package/docs/releases/v0.3.2.md +38 -0
  11. package/docs/releases/v0.3.3.md +9 -0
  12. package/docs/subagent-compatibility.md +51 -0
  13. package/docs/verification/elastic-algorithm-timing.json +337 -0
  14. package/docs/verification/elastic-attention.json +44 -0
  15. package/docs/verification/elastic-frame-timing.json +240 -0
  16. package/docs/verification/elastic-tension.json +59 -0
  17. package/docs/verification/elastic-tip-taper.json +181 -0
  18. package/dshx.yml +2 -0
  19. package/lib/client.js +112 -0
  20. package/lib/client.js.map +1 -0
  21. package/lib/index.mjs +990 -0
  22. package/lib/index.mjs.map +1 -0
  23. package/package.json +66 -10
  24. package/src/board.ts +178 -35
  25. package/src/client/index.ts +104 -0
  26. package/src/dsh-notch.ts +19 -2
  27. package/src/http.ts +17 -2
  28. package/src/session-state.ts +15 -1
  29. package/src/store.ts +3 -3
  30. package/src/types.ts +3 -0
  31. package/BASELINE.md +0 -13
  32. package/DESIGN.md +0 -34
  33. package/cordis.yml +0 -5
  34. package/macos/Package.swift +0 -17
  35. package/macos/STATUS-MOTION.md +0 -49
  36. package/macos/Sources/Client.swift +0 -139
  37. package/macos/Sources/IdleRobot.swift +0 -423
  38. package/macos/Sources/NotchMarkdown.swift +0 -266
  39. package/macos/Sources/Panel.swift +0 -153
  40. package/macos/Sources/Resources/Idle/balance.json +0 -1
  41. package/macos/Sources/Resources/Idle/blink.json +0 -1
  42. package/macos/Sources/Resources/Idle/cube-in.json +0 -1
  43. package/macos/Sources/Resources/Idle/dance.json +0 -1
  44. package/macos/Sources/Resources/Idle/hop.json +0 -1
  45. package/macos/Sources/Resources/Idle/nod.json +0 -1
  46. package/macos/Sources/Resources/Idle/satellite-in.json +0 -1
  47. package/macos/Sources/Resources/Idle/satellite-out.json +0 -1
  48. package/macos/Sources/Resources/Idle/scan.json +0 -1
  49. package/macos/Sources/Resources/Idle/sleep.json +0 -1
  50. package/macos/Sources/Resources/Idle/sneeze.json +0 -1
  51. package/macos/Sources/Resources/Idle/stretch.json +0 -1
  52. package/macos/Sources/Resources/Idle/tilt.json +0 -1
  53. package/macos/Sources/ReviewStudio.swift +0 -337
  54. package/macos/Sources/RootView.swift +0 -1093
  55. package/macos/Sources/StatusOrbit.swift +0 -642
  56. package/macos/Sources/main.swift +0 -153
  57. package/macos/Tests/ExpandedHeightProbe.swift +0 -108
  58. package/macos/Tests/GeometryProbe.swift +0 -98
  59. package/macos/Tests/IdleProbe.swift +0 -294
  60. package/macos/Tests/MotionProbe.swift +0 -106
  61. package/macos/Tests/OutcomeProbe.swift +0 -133
  62. package/macos/Tests/ScrollbarProbe.swift +0 -91
  63. package/macos/Tests/expanded-height.sh +0 -13
  64. package/macos/Tests/geometry.sh +0 -14
  65. package/macos/Tests/idle.sh +0 -14
  66. package/macos/Tests/motion.sh +0 -14
  67. package/macos/Tests/outcome.sh +0 -14
  68. package/macos/Tests/scrollbar.sh +0 -13
  69. package/tests/answer-lifecycle.test.mjs +0 -23
  70. package/tests/board.test.mjs +0 -7
  71. package/tests/browse-sync.test.mjs +0 -20
  72. package/tests/mirror.test.mjs +0 -4
  73. package/tests/peer-range.test.mjs +0 -21
  74. package/tests/session-state.test.mjs +0 -54
  75. package/tests/unread-hold.test.mjs +0 -90
  76. package/tools/desktop-shell/README.md +0 -56
  77. package/tools/desktop-shell/child-log.mjs +0 -6
  78. package/tools/desktop-shell/child-log.test.mjs +0 -14
  79. package/tools/desktop-shell/package-recovery.py +0 -78
  80. package/tools/desktop-shell/recovery-probe.cjs +0 -50
  81. package/tools/desktop-shell/renderer-recovery.mjs +0 -95
  82. package/tools/desktop-shell/renderer-recovery.test.mjs +0 -24
  83. package/tools/diagnostics/completion-watch.py +0 -101
  84. package/tools/diagnostics/completion-watch.test.py +0 -17
  85. package/tools/diagnostics/renderer-diagnostics.mjs +0 -56
  86. package/tools/diagnostics/renderer-diagnostics.test.mjs +0 -32
  87. package/tools/idle/LICENSE.open-bot-motion +0 -21
  88. package/tools/idle/export.mjs +0 -40
  89. package/tools/idle/index.html +0 -4
  90. package/tools/idle/motions.js +0 -83
  91. package/tools/idle/original.js +0 -1673
  92. package/tools/idle/renderer.js +0 -1673
  93. package/tools/recording/Client.swift +0 -76
  94. package/tools/recording/DemoCatalog.swift +0 -174
  95. package/tools/recording/README.md +0 -23
  96. package/tools/recording/build.sh +0 -41
  97. package/tools/recording/main.swift +0 -100
  98. package/tools/review/ReviewStudio.swift +0 -236
  99. package/tools/review/audit.sh +0 -19
  100. package/tools/review/build.sh +0 -7
  101. package/tools/review/main.swift +0 -158
  102. package/tools/review/render-audit.swift +0 -32
@@ -0,0 +1,104 @@
1
+ import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
2
+ import type {} from '@deepseek-ai/dsh-client-ui-session/client'
3
+ import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
4
+ import type { SessionId } from '@deepseek-ai/dsh-session/types'
5
+ /**
6
+ * Headless follower: polls the Host for notch focus wishes and selects the
7
+ * session in every open DSH page (app webview or browser tab).
8
+ *
9
+ * First poll only arms (ignores a wish already sitting there) so a reload
10
+ * does not yank the current session. A later click bumps `at` and opens.
11
+ */
12
+ import type { Context as ClientContext } from '@deepseek-ai/cordis'
13
+
14
+ export const name = 'dsh-notch-client'
15
+ export const inject = ['sessions', 'uiWorkspace', 'uiSession']
16
+
17
+ interface PendingFocusWire {
18
+ ok?: boolean
19
+ focus?: { sessionId?: string; at?: number } | null
20
+ }
21
+
22
+ const POLL_MS = 400
23
+
24
+ export function apply(ctx: ClientContext): void {
25
+ const sessions = ctx.get('sessions') as unknown as ISessions
26
+ const clientId = crypto.randomUUID()
27
+ let lastAt = 0
28
+ let armed = false
29
+ let stopped = false
30
+ const controller = new AbortController()
31
+
32
+ const open = async (sessionId: string): Promise<void> => {
33
+ try {
34
+ ctx.uiWorkspace.openSession(sessionId as SessionId)
35
+ return
36
+ } catch {
37
+ /* list may be stale; refresh once and retry */
38
+ }
39
+ try {
40
+ await sessions.refresh()
41
+ if (stopped) return
42
+ ctx.uiWorkspace.openSession(sessionId as SessionId)
43
+ } catch {
44
+ /* session not in this page's list */
45
+ }
46
+ }
47
+
48
+ const poll = async (): Promise<void> => {
49
+ try {
50
+ const snapshot = sessions.list.getSnapshot()
51
+ if (snapshot.phase === 'ready') {
52
+ // parentId also records independent user forks; only origin classifies
53
+ // a delegated child, matching the official workspace sidebar.
54
+ const rows = snapshot.ids.map(id => snapshot.byId[id]).filter(row => row && row.origin !== 'subagent').map(row => ({ id: row.id, title: row.displayTitle, completed: ctx.uiSession.sessionStatus.getSnapshot().get(row.id)?.completionUnread === true, running: row.running, updatedAt: row.updatedAt }))
55
+ const focused = document.hasFocus()
56
+ const current = rows.find(row => row.id === Object.values(snapshot.byId).find(item => (item.retainedBy?.mainView ?? 0) > 0)?.id)
57
+ // updatedAt is the user's prompt timestamp, not the completion time.
58
+ // Capture this observation before sending it, so a delayed request
59
+ // cannot acknowledge a turn that finishes after the user looked.
60
+ const viewed = focused && current && !current.running
61
+ ? { id: current.id, at: Date.now() } : undefined
62
+ await fetch('/dsh-notch/sidebar', { signal: controller.signal, method: 'POST', headers: { 'content-type': 'application/json', 'x-dsh-notch-client': '1' }, body: JSON.stringify({ clientId, projectionVersion: 3, focused, viewed, rows }) })
63
+ }
64
+ if (stopped) return
65
+ const response = await fetch('/dsh-notch/pending-focus', { cache: 'no-store', signal: controller.signal })
66
+ if (!response.ok) return
67
+ const data = (await response.json()) as PendingFocusWire
68
+ if (stopped) return
69
+ const focus = data.focus
70
+ if (!armed) {
71
+ armed = true
72
+ if (focus && typeof focus.at === 'number') lastAt = focus.at
73
+ return
74
+ }
75
+ if (!focus || typeof focus.at !== 'number' || focus.at <= lastAt) return
76
+ if (typeof focus.sessionId !== 'string' || focus.sessionId === '') return
77
+ lastAt = focus.at
78
+ await open(focus.sessionId)
79
+ try {
80
+ if (stopped) return
81
+ window.focus()
82
+ } catch {
83
+ /* native helper already fronts DSH.app */
84
+ }
85
+ } catch {
86
+ /* Host unreachable mid-poll; the next tick retries. */
87
+ }
88
+ }
89
+
90
+ const tick = (): void => {
91
+ if (stopped) return
92
+ void poll().finally(() => {
93
+ if (!stopped) timer = window.setTimeout(tick, POLL_MS)
94
+ })
95
+ }
96
+ let timer: ReturnType<typeof window.setTimeout> | undefined
97
+ tick()
98
+
99
+ ctx.effect(() => () => {
100
+ stopped = true
101
+ if (timer !== undefined) window.clearTimeout(timer)
102
+ controller.abort()
103
+ }, 'dsh-notch: client sync')
104
+ }
package/src/dsh-notch.ts CHANGED
@@ -1,16 +1,23 @@
1
1
  import type { Context } from '@deepseek-ai/cordis'
2
+ import Schema from '@deepseek-ai/schemastery'
3
+ import { createWriteStream, existsSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { superviseNotch } from '../desktop/notch-lifecycle.mjs'
2
6
  import type { ApprovalOutcome, ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
3
7
  import type { AskUserQuestionAnswer, AskUserQuestionRequest } from '@deepseek-ai/dsh-user-questions'
4
8
  import type {} from '@deepseek-ai/dsh-host-webserver'
5
9
  import type {} from '@deepseek-ai/dsh-session'
6
10
  import { Board } from './board.ts'
7
11
  import { attachHttp } from './http.ts'
8
- import { loadOrCreateToken, writeRuntime } from './store.ts'
12
+ import { DIR, RUNTIME, loadOrCreateToken, writeRuntime } from './store.ts'
9
13
 
10
14
  export const name = 'dsh-notch'
11
15
  export const inject = ['sessions', 'webServer', 'approval', 'userQuestions', 'agents']
16
+ export const Config = Schema.object({
17
+ helperPath: Schema.string().default('').description('Optional installed native Notch executable; managed for this Host lifetime.'),
18
+ })
12
19
 
13
- export function apply(ctx: Context) {
20
+ export function apply(ctx: Context, config: { helperPath?: string } = {}) {
14
21
  console.log('[my-plugins/dsh-notch] loaded')
15
22
  const board = new Board(ctx)
16
23
  const token = loadOrCreateToken()
@@ -21,6 +28,15 @@ export function apply(ctx: Context) {
21
28
  pid: process.pid,
22
29
  writtenAt: Date.now(),
23
30
  })
31
+ if (config.helperPath) {
32
+ if (!existsSync(config.helperPath)) throw new Error('Configured Notch helper does not exist')
33
+ ctx.effect(() => {
34
+ const log = createWriteStream(join(DIR, 'helper.log'), { flags: 'a', mode: 0o600 })
35
+ const helper = superviseNotch({ bin: config.helperPath, pidPath: join(DIR, 'helper.pid'), log,
36
+ env: { ...process.env, DSH_NOTCH_RUNTIME_FILE: RUNTIME } })
37
+ return () => { helper.stop(); log.end() }
38
+ }, 'dsh-notch: native helper')
39
+ }
24
40
 
25
41
  ctx.effect(() => attachHttp(ctx, board, token, origin), 'dsh-notch: http')
26
42
 
@@ -30,6 +46,7 @@ export function apply(ctx: Context) {
30
46
  ctx.effect(() => ctx.on('session/disposed', notify), 'dsh-notch: disposed')
31
47
  ctx.effect(() => ctx.on('session/event', (session, event) => {
32
48
  const type = String(event.type)
49
+ if (type === 'turn/end') board.noteTurnEnd(session, event.time)
33
50
  if (type === 'turn/start' || type === 'turn/end' || type === 'session/title') {
34
51
  notify()
35
52
  }
package/src/http.ts CHANGED
@@ -41,6 +41,17 @@ export function browserTrusted(req: IncomingMessage): boolean {
41
41
  }
42
42
  }
43
43
 
44
+ /** Accept same-origin Web writes and requests forwarded by the Desktop shell. */
45
+ export function sidebarTrusted(req: IncomingMessage, origin: string): boolean {
46
+ if (!browserTrusted(req) || !req.headers['content-type']?.startsWith('application/json')) return false
47
+ if (req.headers.origin !== undefined) return true
48
+ // Desktop validates dsh-app://app, then removes Origin and fetch metadata.
49
+ // A script-only header still prevents a foreign page from submitting a form.
50
+ return req.headers['x-dsh-notch-client'] === '1'
51
+ && req.headers['sec-fetch-site'] === undefined
52
+ && req.headers.host === new URL(origin).host
53
+ }
54
+
44
55
  function send(res: ServerResponse, status: number, body: unknown): void {
45
56
  const json = JSON.stringify(body)
46
57
  res.writeHead(status, {
@@ -100,8 +111,7 @@ export function attachHttp(ctx: Context, board: Board, token: string, origin: st
100
111
  }
101
112
 
102
113
  if (method === 'POST' && path === `${PREFIX}/sidebar`) {
103
- // JSON, explicit same origin, and browser fetch metadata prevent cross-site writes.
104
- if (!browserTrusted(req) || !req.headers.origin || !req.headers['content-type']?.startsWith('application/json')) {
114
+ if (!sidebarTrusted(req, origin)) {
105
115
  send(res, 403, { ok: false, error: 'forbidden' })
106
116
  return
107
117
  }
@@ -120,6 +130,11 @@ export function attachHttp(ctx: Context, board: Board, token: string, origin: st
120
130
  return
121
131
  }
122
132
 
133
+ if (method === 'GET' && path === `${PREFIX}/diagnostics`) {
134
+ send(res, 200, board.diagnostics())
135
+ return
136
+ }
137
+
123
138
  if (method === 'GET' && path === `${PREFIX}/events`) {
124
139
  const id = randomUUID()
125
140
  res.writeHead(200, {
@@ -27,5 +27,19 @@ export function foldSession(session: Session): FoldedSession {
27
27
  }
28
28
 
29
29
  export function isChildSession(session: Session): boolean {
30
- return session.header.parentSession !== undefined || session.header.origin === 'subagent'
30
+ // parentSession is also seed lineage for an independent user fork.
31
+ // Use the same presentation discriminator as Harness's workspace sidebar.
32
+ return session.header.origin === 'subagent'
33
+ }
34
+
35
+ /** Follow only delegation edges; user forks remain separate conversations. */
36
+ export function conversationOwner(session: Session, sessions: ReadonlyMap<string, Session>): Session | undefined {
37
+ let current: Session | undefined = session
38
+ const visited = new Set<string>()
39
+ while (current && isChildSession(current)) {
40
+ if (visited.has(current.id)) return undefined
41
+ visited.add(current.id)
42
+ current = current.header.parentSession === undefined ? undefined : sessions.get(current.header.parentSession)
43
+ }
44
+ return current
31
45
  }
package/src/store.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
- import { homedir } from 'node:os'
2
+ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
3
3
  import { join } from 'node:path'
4
4
  import { randomBytes } from 'node:crypto'
5
5
 
6
- const DIR = join(homedir(), '.dsh', 'dsh-notch')
7
- const RUNTIME = join(DIR, 'runtime.json')
6
+ export const DIR = join(resolveDshHome(), 'dsh-notch')
7
+ export const RUNTIME = join(DIR, 'runtime.json')
8
8
  const SEEN = join(DIR, 'seen.json')
9
9
 
10
10
  export interface RuntimeFile {
package/src/types.ts CHANGED
@@ -47,8 +47,11 @@ export interface NotchRow {
47
47
 
48
48
  export interface NotchSnapshot {
49
49
  ok: true
50
+ stateRevision?: number
50
51
  generatedAt: number
51
52
  sidebarSyncedAt?: number
53
+ /** Browser mirror generation observed in a recent heartbeat; absent for legacy clients. */
54
+ sidebarProjectionVersion?: number
52
55
  origin: string
53
56
  rows: NotchRow[]
54
57
  }
package/BASELINE.md DELETED
@@ -1,13 +0,0 @@
1
- # 2026-09-10 baseline
2
-
3
- Snapshot of the existing native Notch and Host plugin, before robot idle experiments.
4
-
5
- Verification on macOS:
6
- - Host tests: 8 passed (tsx 4.22.4).
7
- - Native motion probe: FAILURES=0.
8
- - Native geometry probe: FAILURES=0; compact one/two/three lamp heights 44/72/100 pt.
9
- - No live Host restart or model calls during this snapshot.
10
-
11
- Existing manual acceptance covered first-click response, keyboard editing, long content, retry, multi-question navigation and queued questions. Automated tests do not replace live DSH acceptance after future installation.
12
-
13
- The idle robot is a separate experiment and is not part of this baseline.
package/DESIGN.md DELETED
@@ -1,34 +0,0 @@
1
- # Notch 动作与状态转场
2
-
3
- 本次范围:运行转为审核、成功或失败;分别覆盖只有一个任务,以及两个任务中一个改变状态。保留用户最近同步的玻璃外壳、排版和机器人资源;候选效果先在独立原生预览中验收,不安装到 DSH。
4
-
5
- ## 动效规则
6
-
7
- 由当前蓝色弧线出发,头部沿路径前进、尾部跟随,途中连续变色。黄色审核和绿色成功向上,红色失败向下。到达目标后沿圆周画线,再出现实心圆及符号。有其他任务运行时,线条从目标色连续回到蓝色运行弧;最后一个任务结束或等待审核时,笔画收在目标圈,源槽随之收起。不得用整圈平移或同时淡入两个圆代替笔画移动。
8
-
9
- 沿用已接受的 0.95 秒笔画时间轴和 19 pt 圆直径,颜色、字形与坐标使用现有定义。自动展开审核面板等待笔画结束,主动交互不因动画被禁用;重复轮询不重播,旧回调不能覆盖新状态。减少动态效果直接显示最终状态。
10
-
11
- ## 设计依据与验证
12
-
13
- | Active concept | 决策 | 实现位置 | 验证 |
14
- | --- | --- | --- | --- |
15
- | motion-language | 三种结果共用有方向的笔画语义;保留当前弧线作为起点 | StatusFlight / StatusOrbitView | 原速六场景原生预览与逐帧截图 |
16
- | motion-contract | 同一时间轴约束路程、颜色、填充、布局;只在有剩余工作时返回 | BoardModel / OrbitLayout / OrbitMotionFrame | OutcomeProbe、MotionProbe、取消和重复事件检查 |
17
-
18
- 视觉沿用纯黑紧凑外壳、细线与圆角字形;本轮不增加装饰。主要识别特征是线条本身连续转移。原生 AppKit/SwiftUI 使用原生渲染证据,浏览器 motion validator 不适用。
19
-
20
- ## 本轮验证
21
-
22
- 黄色已使用 StatusFlight 的 decision 结果类别,沿红绿共享的路径、时间轴和填充顺序绘制。单任务中源数字与黑底在线条离开时淡出,避免收缩时压住结果圆。自动展开推迟到审核笔画完成,取消审核会取消待展开请求。
23
-
24
- OutcomeProbe 六场景与重复事件、快速取消、连续结果检查通过;项目原 MotionProbe(含减少动态效果静止检查)通过。原生渲染器采样六场景的逐帧图像并人工检查,证据位于 `本地 motion proof 输出目录`。六场景本地预览已展示;运行中 DSH 未安装此候选,用户对自然程度的验收仍以预览反馈为准。
25
-
26
- ## 决策回复回程(本轮)
27
-
28
- 覆盖完整的 `蓝 1 → 黄 → 蓝 1` 与 `蓝 2 → 蓝 1+黄 → 蓝 2`。单灯保留已接受的清空填充、从顶部画蓝环的回程;双灯由黄圈抽出笔画,沿既有路径的返回段汇入蓝环,再把数字从 1 渐变到 2。黄灯、线条与外壳使用同一进度,源填充消失后才收起槽位。若面板正在折叠,回程从折叠完成时开始,使线条可见。回复很快时接在去程后播放;取消且没有恢复运行时不制造蓝灯;轮询不重播,旧回调不覆盖新回程。
29
-
30
- Active motion-language 绑定:回复恢复同一任务的可见因果 → DecisionReturn / StatusOrbitView → 两条往返原速预览。Active motion-contract 绑定:数字在汇入时更新、路径及旋转速度连续、折叠不吞动画 → BoardModel / DecisionReturnFrame → OutcomeProbe 和确定时间逐帧渲染。沿用当前颜色、19 pt 灯、0.95–0.98 秒时间轴、系统减少动态效果设置。
31
-
32
- ## 双语录屏
33
-
34
- 录屏标题使用中文在上、英文在下,只呈现同一个转换名称。六格与单景均保留固定标题区,英文不挤占动画空间。36 个场景由同一份生产源构建,录屏工具仅替换传输层并发送定向待机动作,不重写状态曲线。
package/cordis.yml DELETED
@@ -1,5 +0,0 @@
1
- # portable overlay: relative name only. --patch resolves against the profile
2
- # directory, so cold-boot through `dshx start` / `dshx verify-boot`, not raw pnpm dsh.
3
- - insert:
4
- - id: dsh-notch
5
- name: './src/dsh-notch.ts'
@@ -1,17 +0,0 @@
1
- // swift-tools-version: 6.0
2
- import PackageDescription
3
-
4
- let package = Package(
5
- name: "DshNotch",
6
- platforms: [.macOS(.v14)],
7
- products: [
8
- .executable(name: "dsh-notch", targets: ["DshNotch"]),
9
- ],
10
- targets: [
11
- .executableTarget(
12
- name: "DshNotch",
13
- path: "Sources",
14
- resources: [.copy("Resources/Idle")]
15
- ),
16
- ]
17
- )
@@ -1,49 +0,0 @@
1
- # Notch status motion
2
-
3
- Request: the existing rotating blue stroke moves continuously up into a green completion count, or down into a red failure count. It returns to the blue running count only if work remains. A fluid crossing curve takes precedence over a literal figure eight. Native SwiftUI helper; same black edge-attached shell, colors and rounded system numerals; no model calls for acceptance.
4
-
5
- Motion purpose: continuity between running and outcome. Single local stroke, 0.95 seconds; counts stay readable, clicks remain available. Success above running, failure below. No pop, ripple or separate flying droplet. Initial history must not replay completion. New events are queued/coalesced by outcome; no overlapping flights. Reduced Motion skips travel and displays current counts immediately. Last task finishes at its destination instead of inventing a running task.
6
-
7
- | Active reference | Decision | Artifact | Verification |
8
- | --- | --- | --- | --- |
9
- | motion-language | one migrating stroke, directional status semantics; static counts survive | StatusOrbitView | sampled native frames and guided live simulation |
10
- | motion-contract | explicit start/end, queued events, reduced motion | StatusFlight and OrbitMotionFrame | phase/path checks; repeated-event and final-task checks |
11
-
12
- Taste: preserve compact black shell, one thin status stroke and rounded numeric type. The memorable element is continuous status transfer, not decoration. Geometry stays inside shell bounds. Native rendering replaces browser motion validation because this is AppKit/SwiftUI.
13
-
14
- Acceptance: inspect success and failure separately with the user; animation builds/tests alone do not establish naturalness. Existing hover and screen-height regressions must remain passing.
15
-
16
- ## Final brush implementation and Apple references
17
-
18
- User correction: the moving object is a short painted stroke with a moving head and trailing tail, not a translated ring. Success/failure settle as stationary solid disks. The route begins on the current rotating arc, follows its circle to a tangent exit, draws towards the outcome, traces its boundary, fills it, then rejoins the source arc if the flight started with work remaining. Counts have fixed spatial slots. A single monotonic arc-length timeline avoids velocity resets at path-segment boundaries. RGB interpolation stays continuous. The solid disks and numeral backgrounds occlude a returning trail so it cannot paint through numbers.
19
-
20
- The upward-success/downward-failure mapping and brush metaphor are this product's choices, not an Apple-provided animation. They apply Apple's guidance on purposeful, brief, consistent feedback and preserving user control:
21
- - https://developer.apple.com/design/human-interface-guidelines/motion
22
- - https://developer.apple.com/design/human-interface-guidelines/feedback
23
- - https://developer.apple.com/design/human-interface-guidelines/accessibility
24
-
25
- Motion never blocks click routing. An animation's return decision is fixed at its start so subsequent results cannot teleport its head; subsequent outcomes are queued. Reduced Motion renders the final disks/counts and a static running arc. No DSH model calls occur in the acceptance harness.
26
-
27
- ## Return colour and compact layout correction
28
-
29
- The visible return trail uses a spatial result-to-blue gradient: the portion nearest the solid disk retains its result colour until it emerges, then blends into blue at the source rim. This prevents a hidden head from recolouring the entire visible tail too early. Compact height uses actual visible lamps: 44/72/100 points for one/two/three lamps, with the native panel geometry transition. Empty outcome slots are not reserved. Rendered corridor pixels and actual window heights are verified in this revision.
30
-
31
- ## Idle robot (0.2.0)
32
-
33
- Native vector frames sampled from OpenBotMotion plus nine original local pose timelines. Canvas interpolates 30 fps samples at display cadence. No browser process or model invocation. Neutral pose separates actions. Rare dance uses original speed; first/last geometry and colors blend back to neutral.
34
-
35
- Task arrival freezes current pose and contracts it into the running ring over 300 ms. Clearing all results expands a central dot into the robot over 400 ms, eyes appearing last. Reversal retains current progress and geometry, and resumes the interrupted idle clip rather than snapping to neutral. Timer generations reject stale callbacks.
36
-
37
- Checks: 10 resources present, all vector frames inside native drawing bounds, immediate idle cancellation, reverse continuity, and existing native motion/geometry probes. Native helper installed separately from the unchanged Host.
38
-
39
- ## 审核共享笔画(0.3.0)
40
-
41
- 运行任务进入审核时新增 decision 笔画,沿当前蓝弧切线向上、途中变黄,描出目标圆后填充黄色并显示感叹号。只剩这个任务时收掉源槽;仍有任务时笔画回到蓝弧并更新运行数。审核、成功、失败共用 0.95 秒时序。审核自动展开等待笔画结束;问题已取消时不会由旧回调展开。
42
-
43
- 最后一个任务的蓝色数字和黑色遮罩随笔画离开源头淡出,避免结果圈靠近时出现蓝字重影或黑块遮挡。`sh macos/Tests/outcome.sh` 覆盖三种结果的单/双任务场景、重复事件、取消与排队;三个既有原生脚本已补齐 Markdown 编译依赖。
44
-
45
- ### 决策回复回程(0.3.0)
46
-
47
- `DecisionReturn` 独立持有回复的 presentation clock,`BoardModel` 从同一行的 needsAction → busy 边沿启动它。单任务保留 `DecisionMorph` 的原位画环;有剩余工作时使用 `OrbitBrushRoute` 已有返回段,从黄圈抽出笔画接回蓝环。数字在笔画进入目标圆周后渐变;最终笔尖角速度匹配持续旋转速度。黄填充清空后才同步收槽,避免两颗实心灯叠在一起。
48
-
49
- 快速回复会排在黄色去程后,重复快照不重启,旧回调按 UUID 拒绝。面板正在折叠时,回程时钟等 0.4 秒再开始;取消且未恢复该任务不会把其他工作计数加一。此处演示和测试均使用本地快照,不调用 DSH 模型。正式 helper 与离线演示使用相同状态动画源码。
@@ -1,139 +0,0 @@
1
- import Foundation
2
-
3
- struct RuntimeFile: Decodable {
4
- var origin: String
5
- var token: String
6
- }
7
-
8
- struct NotchOption: Decodable, Identifiable {
9
- var label: String
10
- var description: String?
11
- var id: String { label }
12
- }
13
-
14
- struct NotchQuestion: Decodable, Identifiable {
15
- var id: String
16
- var question: String
17
- var detail: String?
18
- var header: String?
19
- var options: [NotchOption]?
20
- var multiSelect: Bool?
21
- }
22
-
23
- struct NotchApproval: Decodable {
24
- var id: String
25
- var toolName: String
26
- var reason: String?
27
- }
28
-
29
- struct NotchAsk: Decodable {
30
- var id: String
31
- var questions: [NotchQuestion]
32
- }
33
-
34
- struct NotchLastTurn: Decodable {
35
- var at: Double
36
- var kind: String
37
- var failed: Bool
38
- }
39
-
40
- struct NotchRow: Decodable, Identifiable {
41
- var id: String
42
- var title: String
43
- var child: Bool
44
- var busy: Bool
45
- var unread: Bool
46
- var lastTurn: NotchLastTurn?
47
- var approval: NotchApproval?
48
- var ask: NotchAsk?
49
-
50
- var needsAction: Bool { approval != nil || ask != nil }
51
- /// A red lamp is a finished unsuccessful turn, never a session that is still running.
52
- var isFailedResult: Bool { lastTurn?.failed == true && !busy && !needsAction }
53
- }
54
-
55
- struct NotchSnapshot: Decodable {
56
- var ok: Bool
57
- var generatedAt: Double
58
- var origin: String
59
- var rows: [NotchRow]
60
- }
61
-
62
- enum NotchClientError: Error {
63
- case noRuntime
64
- case http(Int)
65
- }
66
-
67
- final class NotchClient: @unchecked Sendable {
68
- private var origin = ""
69
- private var token = ""
70
-
71
- func reloadRuntime() throws {
72
- let url = ProcessInfo.processInfo.environment["DSH_NOTCH_RUNTIME_FILE"].map { URL(fileURLWithPath: $0) }
73
- ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".dsh/dsh-notch/runtime.json")
74
- let data = try Data(contentsOf: url)
75
- let file = try JSONDecoder().decode(RuntimeFile.self, from: data)
76
- origin = file.origin
77
- token = file.token
78
- }
79
-
80
- func status() async throws -> NotchSnapshot {
81
- try reloadRuntime()
82
- return try await get("/dsh-notch/status")
83
- }
84
-
85
- func approve(id: String, outcome: String) async throws {
86
- try await post("/dsh-notch/approve", body: ["id": id, "outcome": outcome])
87
- }
88
-
89
- @MainActor
90
- func answer(id: String, answers: [[String: Any]]) async throws {
91
- try await postJSON("/dsh-notch/answer", payload: ["id": id, "answers": answers])
92
- }
93
-
94
- func seen(sessionId: String) async throws {
95
- try await post("/dsh-notch/seen", body: ["sessionId": sessionId])
96
- }
97
-
98
- func seenAll() async throws {
99
- try await postJSON("/dsh-notch/seen", payload: ["all": true])
100
- }
101
-
102
- func focus(sessionId: String) async throws {
103
- try await post("/dsh-notch/focus", body: ["sessionId": sessionId])
104
- }
105
-
106
- private func get(_ path: String) async throws -> NotchSnapshot {
107
- var request = try makeRequest(path)
108
- request.httpMethod = "GET"
109
- let (data, response) = try await URLSession.shared.data(for: request)
110
- try throwIfBad(response)
111
- return try JSONDecoder().decode(NotchSnapshot.self, from: data)
112
- }
113
-
114
- private func post(_ path: String, body: [String: String]) async throws {
115
- try await postJSON(path, payload: body)
116
- }
117
-
118
- private func postJSON(_ path: String, payload: [String: Any]) async throws {
119
- var request = try makeRequest(path)
120
- request.httpMethod = "POST"
121
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
122
- request.httpBody = try JSONSerialization.data(withJSONObject: payload)
123
- let (_, response) = try await URLSession.shared.data(for: request)
124
- try throwIfBad(response)
125
- }
126
-
127
- private func makeRequest(_ path: String) throws -> URLRequest {
128
- if origin.isEmpty { try reloadRuntime() }
129
- guard let url = URL(string: origin + path) else { throw NotchClientError.noRuntime }
130
- var request = URLRequest(url: url, timeoutInterval: 8)
131
- request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
132
- return request
133
- }
134
-
135
- private func throwIfBad(_ response: URLResponse) throws {
136
- let status = (response as? HTTPURLResponse)?.statusCode ?? 0
137
- if status < 200 || status >= 300 { throw NotchClientError.http(status) }
138
- }
139
- }