dsh-zen-remote 1.1.11 → 1.1.13

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 (85) hide show
  1. package/AGENTS.md +24 -0
  2. package/README.md +7 -3
  3. package/README.zh-CN.md +7 -3
  4. package/docs/interface.md +5 -2
  5. package/dsh-push.mjs +25 -1
  6. package/lan-gate.mjs +16 -1
  7. package/lib/client.js +684 -43
  8. package/lib/lan-gate-server.cjs +107 -4
  9. package/lib/types/client/MobileHome.d.ts +7 -2
  10. package/lib/types/client/MobileHome.d.ts.map +1 -1
  11. package/lib/types/client/MobileSessionInfo.d.ts +2 -2
  12. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
  13. package/lib/types/client/compat/store.d.ts +62 -0
  14. package/lib/types/client/compat/store.d.ts.map +1 -0
  15. package/lib/types/client/compat/types.d.ts +84 -0
  16. package/lib/types/client/compat/types.d.ts.map +1 -0
  17. package/lib/types/client/debug.d.ts +1 -1
  18. package/lib/types/client/debug.d.ts.map +1 -1
  19. package/lib/types/client/effects/aionui-compat.d.ts +1 -1
  20. package/lib/types/client/effects/aionui-compat.d.ts.map +1 -1
  21. package/lib/types/client/effects/gestures.d.ts +1 -1
  22. package/lib/types/client/effects/gestures.d.ts.map +1 -1
  23. package/lib/types/client/effects/header-status.d.ts +1 -1
  24. package/lib/types/client/effects/header-status.d.ts.map +1 -1
  25. package/lib/types/client/effects/keyboard-avoid.d.ts +1 -1
  26. package/lib/types/client/effects/keyboard-avoid.d.ts.map +1 -1
  27. package/lib/types/client/effects/keyboard-guard.d.ts +1 -1
  28. package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
  29. package/lib/types/client/effects/modal-back.d.ts +1 -1
  30. package/lib/types/client/effects/modal-back.d.ts.map +1 -1
  31. package/lib/types/client/effects/model-sheet-extras.d.ts +33 -0
  32. package/lib/types/client/effects/model-sheet-extras.d.ts.map +1 -0
  33. package/lib/types/client/effects/native-trigger-overlay.d.ts +1 -1
  34. package/lib/types/client/effects/native-trigger-overlay.d.ts.map +1 -1
  35. package/lib/types/client/effects/phone-chrome.d.ts +1 -1
  36. package/lib/types/client/effects/phone-chrome.d.ts.map +1 -1
  37. package/lib/types/client/effects/turn-fold.d.ts +1 -1
  38. package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
  39. package/lib/types/client/effects/welcome-notice.d.ts +1 -1
  40. package/lib/types/client/effects/welcome-notice.d.ts.map +1 -1
  41. package/lib/types/client/effects/workbench-ref-close.d.ts +1 -1
  42. package/lib/types/client/effects/workbench-ref-close.d.ts.map +1 -1
  43. package/lib/types/client/index.d.ts +1 -1
  44. package/lib/types/client/index.d.ts.map +1 -1
  45. package/lib/types/client/nav-store.d.ts +2 -2
  46. package/lib/types/client/nav-store.d.ts.map +1 -1
  47. package/lib/types/client/session-dot.d.ts +11 -3
  48. package/lib/types/client/session-dot.d.ts.map +1 -1
  49. package/lib/types/client/styles/compat.css.d.ts +1 -1
  50. package/lib/types/client/styles/compat.css.d.ts.map +1 -1
  51. package/lib/types/client/styles/composer.css.d.ts +1 -1
  52. package/lib/types/client/styles/composer.css.d.ts.map +1 -1
  53. package/lib/types/client/styles/info.css.d.ts +1 -1
  54. package/lib/types/client/styles/info.css.d.ts.map +1 -1
  55. package/package.json +49 -26
  56. package/pwa/app.css +47 -7
  57. package/scripts/check-client-externals.mjs +48 -0
  58. package/scripts/check-compat-store.mjs +113 -0
  59. package/scripts/check-host-shadow.mjs +145 -0
  60. package/scripts/check-info-sheet-stacking.mjs +55 -0
  61. package/src/client/MobileAttachButton.tsx +1 -1
  62. package/src/client/MobileHome.tsx +33 -7
  63. package/src/client/MobileSessionInfo.tsx +6 -4
  64. package/src/client/chips-store.ts +1 -1
  65. package/src/client/compat/store.ts +205 -0
  66. package/src/client/compat/types.ts +115 -0
  67. package/src/client/debug.ts +1 -1
  68. package/src/client/effects/aionui-compat.ts +1 -1
  69. package/src/client/effects/gestures.ts +1 -1
  70. package/src/client/effects/header-status.ts +22 -3
  71. package/src/client/effects/keyboard-avoid.ts +1 -1
  72. package/src/client/effects/keyboard-guard.ts +1 -1
  73. package/src/client/effects/modal-back.ts +1 -1
  74. package/src/client/effects/model-sheet-extras.ts +159 -0
  75. package/src/client/effects/native-trigger-overlay.ts +1 -1
  76. package/src/client/effects/phone-chrome.ts +1 -1
  77. package/src/client/effects/turn-fold.ts +1 -1
  78. package/src/client/effects/welcome-notice.ts +1 -1
  79. package/src/client/effects/workbench-ref-close.ts +1 -1
  80. package/src/client/index.tsx +70 -6
  81. package/src/client/nav-store.ts +2 -2
  82. package/src/client/session-dot.ts +13 -4
  83. package/src/client/styles/compat.css.ts +43 -11
  84. package/src/client/styles/composer.css.ts +184 -9
  85. package/src/client/styles/info.css.ts +21 -0
@@ -7,8 +7,9 @@ import {
7
7
  IconDownloadOutline16,
8
8
  IconEditOutline16,
9
9
  } from '@deepseek-ai/dsh-client-ui-primitives'
10
- import { workspaceTitleOf } from '@deepseek-ai/dsh-client-runtime/client'
11
- import type { SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
10
+ import { workspaceTitleOf } from './compat/store.ts'
11
+ import { agentPresetOf } from './compat/types.ts'
12
+ import type { RenameResult, SessionId } from './compat/types.ts'
12
13
  import { NS } from './locales.ts'
13
14
  import { GO_HOME_EVENT, SESSION_INFO_EVENT } from './nav-store.ts'
14
15
  import { hasLayer, popLayer, pushLayer } from './history-nav.ts'
@@ -36,7 +37,7 @@ export type MobileSessionInfoProps =
36
37
  /** Bound ctx.sessions.open(id) — lands on the freshly forked session. */
37
38
  openSession: (id: SessionId) => void
38
39
  /** Bound ctx.sessions.binding(id)?.session.rename(title); undefined when the binding is gone. */
39
- renameSession: (sessionId: SessionId, title: string) => ReturnType<SessionFace['rename']> | undefined
40
+ renameSession: (sessionId: SessionId, title: string) => RenameResult | undefined
40
41
  /** Bound ctx.workspaces.archiveSession(sessionId). */
41
42
  archiveSession: (sessionId: SessionId) => Promise<void>
42
43
  /** Bound ctx.sessionLogDownload.download(sessionId) — owns its own progress/result modal. */
@@ -198,6 +199,7 @@ export function MobileSessionInfo({
198
199
  const cacheHit = usage === undefined ? null : cacheHitPercent(usage)
199
200
  const tokensEmpty = usage === undefined || (billedInputTokens(usage) === 0 && usage.outputTokens === 0)
200
201
  const cwd = row?.cwd === undefined || row.cwd === '' ? undefined : workspaceTitleOf(row.cwd)
202
+ const preset = agentPresetOf(row)
201
203
 
202
204
  const cells: Array<{ label: string; value: string; sub: string | undefined }> = [
203
205
  { label: t('infoStatTurns'), value: stats === undefined ? NA : String(stats.turns), sub: undefined },
@@ -279,7 +281,7 @@ export function MobileSessionInfo({
279
281
  </div>
280
282
 
281
283
  <div data-mobile-nav="info-badges">
282
- {row?.agentPreset !== undefined && <span data-mobile-nav="info-badge">{row.agentPreset}</span>}
284
+ {preset !== undefined && <span data-mobile-nav="info-badge">{preset}</span>}
283
285
  {subagentCount > 0 && (
284
286
  <span data-mobile-nav="info-badge">{t('infoSubagents', { count: subagentCount })}</span>
285
287
  )}
@@ -1,4 +1,4 @@
1
- import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
1
+ import { createSnapshotStore } from './compat/store.ts'
2
2
  import { useSyncExternalStore } from 'react'
3
3
 
4
4
  /**
@@ -0,0 +1,205 @@
1
+ // Compat: self-written store engine + workspace-title helper, replacing the
2
+ // three runtime values this plugin used to import from
3
+ // `@deepseek-ai/dsh-client-runtime/client` (createSnapshotStore in
4
+ // chips-store.ts, defineStore in nav-store.ts, workspaceTitleOf in
5
+ // MobileSessionInfo.tsx). DSH 0.1.2 dropped that package entirely: a browser
6
+ // `require` of it now throws and the plugin would not load at all. The store
7
+ // pair moved to `@deepseek-ai/dsh-client-store` and `workspaceTitleOf`
8
+ // exists nowhere, so instead of try/catching two package names at runtime
9
+ // (and re-coupling to whichever upstream renames next), the functions are
10
+ // written here — they are small, and their contract types come from
11
+ // `@deepseek-ai/dsh-client-ui-slots`, which both DSH versions ship.
12
+ //
13
+ // Both frameworks consume a store handle structurally — they call
14
+ // `handle.create(scopeKey?)` and read `getSnapshot / subscribe / actions /
15
+ // clearPersisted` off the instance, with no `instanceof` and no brand check
16
+ // (0.1.1 dsh-client-runtime/lib/client.js:171,302; 0.1.2
17
+ // dsh-client-ui-renderer/lib/client.js:1341,1350) — so these stand-ins are
18
+ // accepted as-is by the slot engine on either version.
19
+ import type {
20
+ ActionsDecl,
21
+ BakedActions,
22
+ StoreHandle,
23
+ StoreInstance,
24
+ StoreSpec,
25
+ } from '@deepseek-ai/dsh-client-ui-slots'
26
+
27
+ /** Writable snapshot source: read + subscribe + mutate/wholesale-set. */
28
+ export interface SnapshotStore<T> {
29
+ /** Read the current state. Same reference every time until an update. */
30
+ getSnapshot(): T
31
+ /**
32
+ * Subscribe to state changes.
33
+ * @param fn - invalidation callback.
34
+ * @returns unsubscribe function.
35
+ */
36
+ subscribe(fn: () => void): () => void
37
+ /**
38
+ * Mutate the state through a draft.
39
+ * @param mutator - draft mutator.
40
+ */
41
+ update(mutator: (draft: T) => void): void
42
+ /** Replace the state wholesale. @param next - next state. */
43
+ set(next: T): void
44
+ }
45
+
46
+ /**
47
+ * Create a snapshot store with optional whole-value localStorage persistence.
48
+ * The 0.1.1 `opts.flush: 'raf' | 'sync'` mode is deliberately not
49
+ * implemented: this plugin never used it (chips-store.ts persists
50
+ * synchronously and React reads via useSyncExternalStore).
51
+ * @param init - initial state.
52
+ * @param opts - optional persistence key.
53
+ * @returns the store.
54
+ */
55
+ export function createSnapshotStore<T>(
56
+ init: T,
57
+ opts?: { persist?: { name: string } },
58
+ ): SnapshotStore<T> {
59
+ let state = init
60
+ const listeners = new Set<() => void>()
61
+ const persistName = opts?.persist?.name
62
+
63
+ // Rehydrate once at construction: a persisted whole value replaces the
64
+ // initial state wholesale. Failures (quota, private mode, corrupted JSON)
65
+ // only log — a broken persisted value must not take the store down, and a
66
+ // store that cannot persist must still work in memory.
67
+ if (persistName !== undefined && typeof localStorage !== 'undefined') {
68
+ try {
69
+ const raw = localStorage.getItem(persistName)
70
+ if (raw !== null) state = JSON.parse(raw) as T
71
+ } catch (error) {
72
+ console.error(`snapshot store '${persistName}' rehydration failed:`, error)
73
+ }
74
+ }
75
+
76
+ const persist = (): void => {
77
+ if (persistName === undefined || typeof localStorage === 'undefined') return
78
+ try {
79
+ localStorage.setItem(persistName, JSON.stringify(state))
80
+ } catch (error) {
81
+ console.error(`snapshot store '${persistName}' persistence failed:`, error)
82
+ }
83
+ }
84
+
85
+ const notify = (): void => {
86
+ // Iterate a snapshot of the set so a callback that unsubscribes cannot
87
+ // make the loop skip listeners registered after it.
88
+ for (const fn of [...listeners]) fn()
89
+ }
90
+
91
+ return {
92
+ getSnapshot: (): T => state,
93
+ subscribe: (fn: () => void): (() => void) => {
94
+ listeners.add(fn)
95
+ return () => {
96
+ listeners.delete(fn)
97
+ }
98
+ },
99
+ update: (mutator: (draft: T) => void): void => {
100
+ // ponytail: official uses immer's structural sharing; here we copy one
101
+ // level shallowly (array spread / object spread) and let the mutator
102
+ // edit that copy before swapping it in wholesale. That only supports
103
+ // one-level-deep draft writes — fine while both plugin stores stay
104
+ // flat ({view, workspace}, Record<string, boolean>); if a state ever
105
+ // nests, switch this to immer instead of deepening the copy.
106
+ const draft = (Array.isArray(state) ? [...state] : { ...(state as object) }) as T
107
+ mutator(draft)
108
+ state = draft
109
+ notify()
110
+ persist()
111
+ },
112
+ set: (next: T): void => {
113
+ state = next
114
+ notify()
115
+ persist()
116
+ },
117
+ }
118
+ }
119
+
120
+ /**
121
+ * A live `create()` product: the ui-slots contract instance plus the raw
122
+ * snapshot store it was built on. The official engine instance carries the
123
+ * same `store` member (dsh-client-store's EngineStoreInstance); the
124
+ * framework-neutral `StoreInstance` contract omits it, so this file widens
125
+ * the return of {@link defineStore}'s create with a local subtype.
126
+ */
127
+ interface SnapshotStoreInstance<T, A extends ActionsDecl<T>> extends StoreInstance<T, A> {
128
+ /** The underlying snapshot store. */
129
+ readonly store: SnapshotStore<T>
130
+ }
131
+
132
+ /**
133
+ * Declare a store: an init lambda (fresh state per instance), an optional
134
+ * persistence key, and the full write set as pure draft mutators. Returns
135
+ * the registration currency of the slot system's store seat.
136
+ *
137
+ * The `A & ActionsDecl<T>` actions position is load-bearing: T resolves from
138
+ * `init` in the first inference round, and the intersection then
139
+ * contextually types each mutator's draft parameter, so call sites write
140
+ * `(draft, view: MobileView) => { … }` without annotating the draft. Keep
141
+ * the intersection — dropping it makes nav-store.ts fail to typecheck.
142
+ *
143
+ * Persist-key rules mirror the official defineStore: `decl.persist` alone at
144
+ * root scope, suffixed with `.{scopeKey}` per session scope, none when
145
+ * `decl.persist` is absent.
146
+ * @param decl - init/persist/actions declaration.
147
+ * @returns the store handle.
148
+ */
149
+ export function defineStore<T, A extends ActionsDecl<T>>(
150
+ decl: StoreSpec<T, A> & { actions: A & ActionsDecl<T> },
151
+ ): StoreHandle<T, A> {
152
+ return {
153
+ spec: decl,
154
+ create(scopeKey?: string): SnapshotStoreInstance<T, A> {
155
+ const persistKey =
156
+ decl.persist === undefined
157
+ ? undefined
158
+ : scopeKey === undefined
159
+ ? decl.persist
160
+ : `${decl.persist}.${scopeKey}`
161
+ const store = createSnapshotStore<T>(
162
+ decl.init(),
163
+ persistKey !== undefined ? { persist: { name: persistKey } } : undefined,
164
+ )
165
+ // TS cannot verify an object that is filled key-by-key from
166
+ // Object.keys(decl.actions) satisfies the BakedActions mapped type, so
167
+ // the map is built loosely and narrowed once at the contract boundary;
168
+ // at runtime every declared action key is present (the declaration's
169
+ // own keys are the ones iterated).
170
+ const actions = {} as Record<string, (...params: unknown[]) => void>
171
+ for (const [key, mutate] of Object.entries(decl.actions)) {
172
+ actions[key] = (...params: unknown[]): void => {
173
+ store.update((draft) => {
174
+ mutate(draft, ...params)
175
+ })
176
+ }
177
+ }
178
+ return {
179
+ actions: actions as BakedActions<T, A>,
180
+ getSnapshot: (): T => store.getSnapshot(),
181
+ subscribe: (fn: () => void): (() => void) => store.subscribe(fn),
182
+ store,
183
+ clearPersisted: (): void => {
184
+ if (persistKey === undefined || typeof localStorage === 'undefined') return
185
+ try {
186
+ localStorage.removeItem(persistKey)
187
+ } catch {
188
+ // Storage gone or denied: nothing to clean up anyway.
189
+ }
190
+ },
191
+ }
192
+ },
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Last path segment of a workspace directory, for showing "which workspace
198
+ * is this session in" on the mobile info card. 0.1.1's one-line
199
+ * implementation, kept verbatim.
200
+ * @param cwd - a workspace path (posix or windows separators).
201
+ * @returns the trailing name, or '' when no non-empty segment exists.
202
+ */
203
+ export function workspaceTitleOf(cwd: string): string {
204
+ return cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() ?? ''
205
+ }
@@ -0,0 +1,115 @@
1
+ // Compat: type re-homing + a structural session-row mirror.
2
+ //
3
+ // Most of src/client used to `import type { … } from
4
+ // '@deepseek-ai/dsh-client-runtime/client'`, a package DSH 0.1.2 removed.
5
+ // Type-only imports vanish at runtime, so each moved type just needs a new
6
+ // home; keeping those homes here concentrates every import-path swap in one
7
+ // file, and a future upstream rename touches only this file again.
8
+ //
9
+ // The one type that cannot be imported from either version is the session
10
+ // row: 0.1.1 declares `SessionSummary` in dsh-client-runtime, 0.1.2 in
11
+ // dsh-api-session-controller, and the two field sets differ (0.1.2 dropped
12
+ // `agentPreset` and `pendingInteraction`). Mirroring the handful of fields
13
+ // this plugin actually reads as a structural type (the same trick
14
+ // dsh-plugin-subscriptions' upstream compat.ts uses for RpcResult) lets rows
15
+ // from both versions assign in, instead of picking one version's type and
16
+ // breaking the other.
17
+ import type { Context } from '@deepseek-ai/cordis'
18
+ // Canonical homes for the two branded ids are the packages' `./types`
19
+ // subpaths. This tsconfig still resolves with node10 `moduleResolution:
20
+ // "node"`, which ignores package.json `exports` — the subpath only resolves
21
+ // because tsconfig.client.json maps it through `paths` to the exact .d.ts
22
+ // files; keep those two entries in sync if either package moves.
23
+ import type { SessionId } from '@deepseek-ai/dsh-session/types'
24
+ import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
25
+
26
+ /**
27
+ * Browser-half cordis context. 0.1.1's `dsh-client-runtime` exported
28
+ * `ClientContext` as the bare `Context` type (lib/types/client/index.d.ts:55);
29
+ * 0.1.2 has no such alias. Re-declared here under the same name so the
30
+ * dozen files that used it only change their import path, not the
31
+ * identifier.
32
+ */
33
+ export type ClientContext = Context
34
+
35
+ /** Session id. Both versions ship it in `@deepseek-ai/dsh-session/types`; re-exported to collect import points. */
36
+ export type { SessionId }
37
+
38
+ /** Workspace id. 0.1.2 lives in `@deepseek-ai/dsh-workspace/types`. */
39
+ export type { WorkspaceId }
40
+
41
+ /**
42
+ * Structural mirror of the session-row fields this plugin reads, valid
43
+ * against both versions' `SessionSummary`. Deliberately NOT imported from
44
+ * either version (see the file header): `projectionValues` stays `unknown`
45
+ * rather than copying the host's
46
+ * `Readonly<Partial<SessionProjectionMap>>` — that type differs between
47
+ * versions and fights assignments under `exactOptionalPropertyTypes`. The
48
+ * single read site (MobileHome.tsx:311) already casts.
49
+ *
50
+ * `agentPreset` and `pendingInteraction` are intentionally absent: their
51
+ * real readers (MobileHome.tsx, MobileSessionInfo.tsx) receive rows typed by
52
+ * the host's devDeps (0.1.2), not by this mirror, so fields here would not
53
+ * help them — use {@link agentPresetOf} / {@link hasPendingInteraction}
54
+ * instead.
55
+ */
56
+ export interface MobileSessionRow {
57
+ id: SessionId
58
+ title?: string
59
+ displayTitle: string
60
+ cwd?: string
61
+ parentId?: SessionId
62
+ origin?: 'subagent'
63
+ running: boolean
64
+ completed?: boolean
65
+ blank: boolean
66
+ updatedAt: number
67
+ /** 宿主投影值;本插件只读 sessionStats.turns,读的地方自己 cast。 */
68
+ projectionValues?: unknown
69
+ }
70
+
71
+ /**
72
+ * 0.1.1's session row carries `agentPreset`; 0.1.2 dropped it. Reading it
73
+ * must not depend on which version's types are in scope in the current
74
+ * file, so narrow through unknown.
75
+ * @param row - a session row of either version.
76
+ * @returns the agent preset name, or undefined when absent.
77
+ */
78
+ export function agentPresetOf(row: unknown): string | undefined {
79
+ const agentPreset = (row as { agentPreset?: unknown } | null | undefined)?.agentPreset
80
+ return typeof agentPreset === 'string' ? agentPreset : undefined
81
+ }
82
+
83
+ /**
84
+ * 0.1.1's session row carries `pendingInteraction` (the pending-interaction
85
+ * state behind the sidebar's yellow dot); 0.1.2 serves it from the
86
+ * uiSession service instead. This answers only "does this row carry a
87
+ * pending interaction".
88
+ * @param row - a session row of either version.
89
+ * @returns true when the row has a pending interaction.
90
+ */
91
+ export function hasPendingInteraction(row: unknown): boolean {
92
+ const pending = (row as { pendingInteraction?: unknown } | null | undefined)?.pendingInteraction
93
+ return pending !== undefined && pending !== null
94
+ }
95
+
96
+ /**
97
+ * `ctx.sessions.binding(id)?.session.rename(title)` 的返回值。
98
+ * 两版的包裹类型不同(0.1.1 是 RpcResult、0.1.2 是 RemoteResult),但判别式
99
+ * 联合的形状一致:成功分支带 ok:true(还有个我们不读的 value),失败分支带
100
+ * ok:false + error。这里只镜像本插件真正读到的两个字段(ok、error.message),
101
+ * 两版的真类型都能结构赋值进来。
102
+ */
103
+ export type RenameResult = Promise<
104
+ | { readonly ok: true }
105
+ | { readonly ok: false; readonly error: { readonly message: string } }
106
+ >
107
+
108
+ /**
109
+ * 待处理交互的选择器 hook(0.1.2 的标准 prop `useSessionPendingInteraction`);
110
+ * 0.1.1 没有这个 prop,运行时读到 undefined。本地镜像只描述本插件读到的形状
111
+ * (按会话 id 的只读 Map + 选择器调用),不从
112
+ * `@deepseek-ai/dsh-client-ui-session/client` 拿类型——那个包 0.1.1 装不到。
113
+ */
114
+ export type UsePendingInteractions =
115
+ <S>(sel: (m: ReadonlyMap<SessionId, unknown>) => S, eq?: (a: S, b: S) => boolean) => S
@@ -1,4 +1,4 @@
1
- import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
1
+ import type { ClientContext } from './compat/types.ts'
2
2
  /**
3
3
  * Debug badge — ?mobile-nav-debug=1
4
4
  * Renders a live state overlay (URL, viewport, media queries, shell chrome,
@@ -1,4 +1,4 @@
1
- import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
1
+ import type { ClientContext } from '../compat/types.ts'
2
2
 
3
3
  /** dsh-web-ui 兼容:explorer / preview 列的显隐标记与升起动画(同域同机制,合并一处)。 */
4
4
  export function installAionuiCompat(ctx: ClientContext): void {
@@ -1,4 +1,4 @@
1
- import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
1
+ import type { ClientContext } from '../compat/types.ts'
2
2
  import { GO_HOME_EVENT } from '../nav-store.ts'
3
3
 
4
4
  /** Phone breakpoint — same query every phone-only effect in this plugin uses. */
@@ -1,4 +1,4 @@
1
- import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
1
+ import type { ClientContext } from '../compat/types.ts'
2
2
  import { dotState } from '../session-dot.ts'
3
3
 
4
4
  /**
@@ -20,11 +20,30 @@ export function installHeaderStatusDot(ctx: ClientContext): void {
20
20
  if (frame === null) return
21
21
  const { current, byId } = ctx.sessions.list.getSnapshot()
22
22
  const row = current === undefined ? undefined : byId[current]
23
- const state = row === undefined ? undefined : dotState(row)
23
+ // 0.1.2 的待处理交互来源,每次现查:本插件的 inject 不含 uiSession(加了
24
+ // 0.1.1 就不激活),apply 此刻服务未必注册好;每次算 dot 时现查,注册顺序
25
+ // 就不再是问题。0.1.1 上没有这个服务,这里恒 undefined,黄点只由行字段决定。
26
+ const uiSession = ctx.get('uiSession')
27
+ const pending = current === undefined ? false
28
+ : uiSession?.pendingInteractions.getSnapshot().has(current) === true
29
+ const state = row === undefined ? undefined : dotState(row, pending)
24
30
  if (state === undefined) frame.removeAttribute('data-mobile-nav-dot')
25
31
  else frame.setAttribute('data-mobile-nav-dot', state)
26
32
  }
27
33
  apply()
28
- return ctx.sessions.list.subscribe(apply)
34
+ // Two sources can push the dot now (session list + the 0.1.2 pending
35
+ // map); probe uiSession once more right here and subscribe to whichever
36
+ // exist. 0.1.1 only ever has the first. Degradation note: if uiSession
37
+ // has not registered by this moment (this plugin ran before it), only
38
+ // the list subscription is attached — the dot stays correct because
39
+ // every later list change re-runs apply(), whose fresh probe then sees
40
+ // the service; a pending-only change before any list change would be
41
+ // missed until then.
42
+ const unsubscribes: Array<() => void> = [ctx.sessions.list.subscribe(apply)]
43
+ const pendingSource = ctx.get('uiSession')?.pendingInteractions
44
+ if (pendingSource !== undefined) unsubscribes.push(pendingSource.subscribe(apply))
45
+ return () => {
46
+ for (const off of unsubscribes) off()
47
+ }
29
48
  }, 'dsh-mobile-nav: header status dot')
30
49
  }
@@ -1,4 +1,4 @@
1
- import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
1
+ import type { ClientContext } from '../compat/types.ts'
2
2
  import { clientConfig, KEYBOARD_DEFAULTS, type KeyboardTuning } from '../client-config.ts'
3
3
 
4
4
  /** Phone breakpoint — same query every phone-only effect in this plugin uses. */
@@ -1,4 +1,4 @@
1
- import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
1
+ import type { ClientContext } from '../compat/types.ts'
2
2
 
3
3
  /** Phone breakpoint — same query every phone-only effect in this plugin uses. */
4
4
  const PHONE_QUERY = '(max-width: 767px)'
@@ -18,7 +18,7 @@
18
18
  * dialogs keep their own behaviour untouched (the repo's desktop no-op rule).
19
19
  */
20
20
 
21
- import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
21
+ import type { ClientContext } from '../compat/types.ts'
22
22
  import { hasLayer, popLayer, pushLayer } from '../history-nav.ts'
23
23
 
24
24
  const PHONE = '(max-width: 767px)'
@@ -0,0 +1,159 @@
1
+ import type { ClientContext } from '../compat/types.ts'
2
+
3
+ /** Phone breakpoint — same query every phone-only effect in this plugin uses. */
4
+ const PHONE_QUERY = '(max-width: 767px)'
5
+
6
+ /** The model pill itself. Absent in a subagent session — see `sync`. */
7
+ const MODEL_SEAT_SELECTOR = '[data-slot="conversation.input.model"]'
8
+
9
+ /** The model pill's own popup, which section 4 of composer.css.ts turns into a bottom sheet. */
10
+ const MENU_SELECTOR = '[data-slot="conversation.input.model"] [class$="_menu"]'
11
+
12
+ /**
13
+ * The third-party composer controls this effect relocates. Both register into
14
+ * `conversation.input.right`; moving the slot container rather than its
15
+ * children keeps them together and leaves exactly one node to put back.
16
+ */
17
+ const EXTRAS_SELECTOR = '[data-slot="conversation.input.right"]'
18
+
19
+ /**
20
+ * dsh-vision-router's own mount marker. Used here only to answer "is there
21
+ * anything in this slot BESIDES the vision toggle" — see `worthMoving`.
22
+ */
23
+ const VISION_SELECTOR = '[data-vision-router-mode-toggle]'
24
+
25
+ /**
26
+ * Set on the container while this effect manages it, in the row and in the
27
+ * sheet alike. It is the styling hook for both halves in composer.css.ts, and
28
+ * it is deliberately what the row's `display: none` keys off: without the
29
+ * marker the controls stay exactly where the host put them, so a version of
30
+ * this file that declines to act — or never runs at all — cannot leave them
31
+ * hidden in the row and absent from the sheet, i.e. unreachable.
32
+ */
33
+ const MANAGED = 'data-zen-sheet-extras'
34
+
35
+ /**
36
+ * Is the slot holding anything that actually costs the row width?
37
+ *
38
+ * The row is one nowrap flex line whose only elastic item is the model pill
39
+ * (composer.css.ts section 1), so entries here are paid for out of the model
40
+ * name. But that is only a problem once something WIDE lands in the slot —
41
+ * today dsh-plugin-subscriptions' speed chip, which renders "速度 · 标准" and
42
+ * eats ~70px. dsh-vision-router's toggle alone is a 28px icon that the row
43
+ * accommodates fine, and moving it on its own would cost a tap (it becomes
44
+ * two: open the sheet, then toggle) to buy width nobody needed.
45
+ *
46
+ * So: relocate only when the slot holds an entry that is not the vision
47
+ * toggle. With no such entry — subscriptions not installed, or installed but
48
+ * not contributing on this model — nothing moves and the row keeps its one
49
+ * icon, which is the behaviour asked for (user review, 2026-09-06).
50
+ *
51
+ * Why this rather than sniffing for subscriptions by name: the slot system
52
+ * adds no per-registration marker (measured — entries render straight into the
53
+ * container) and subscriptions marks nothing of its own, so naming it would
54
+ * mean adding a marker to our fork of that plugin and anchoring on it. That
55
+ * marker dies the day the fork is dropped, and the feature would go quiet
56
+ * with nothing to catch it. The width question is the real question anyway,
57
+ * and this asks it directly.
58
+ */
59
+ function worthMoving(extras: Element): boolean {
60
+ return [...extras.children].some(child => !child.matches(VISION_SELECTOR))
61
+ }
62
+
63
+ /**
64
+ * Phone: park the composer row's third-party controls inside the model sheet.
65
+ *
66
+ * With the speed chip and the vision toggle both present on a GPT model the
67
+ * row runs out of width. Both are model-scoped settings, so the model sheet —
68
+ * which already holds 模型 and 推理等级 — is where they belong (user request,
69
+ * 2026-09-06).
70
+ *
71
+ * Why move the real controls instead of drawing our own rows that drive them:
72
+ * the same reason native-trigger-overlay.ts moves the real trigger. A stand-in
73
+ * has to script the original, which is a road this plugin does not go down —
74
+ * beyond the trusted-input wall that effect documents, our own rows would have
75
+ * to mirror every piece of state (speed tier, disabled, aria-pressed, whether
76
+ * a vision twin exists for the current model) and would rot on the next
77
+ * upstream restyle. Moving the node keeps one source of truth: their React
78
+ * roots go on owning and updating these elements, we only change where they
79
+ * hang.
80
+ *
81
+ * Measured 2026-09-06 (DSH 0.1.2, 390px) before writing this — the two things
82
+ * that would have killed the approach both hold: a relocated control survives
83
+ * host re-renders (typing into the composer re-renders the row and does not
84
+ * yank it back) and stays hit-testable at its new position.
85
+ *
86
+ * Restoring on close is not cosmetic. The sheet unmounts when it closes and
87
+ * anything still inside goes with it, so the controls would vanish from the
88
+ * page while their React roots kept updating detached nodes. The observer
89
+ * therefore watches the menu leaving too, and the parked node is held as a
90
+ * reference rather than looked up — once the menu is detached a document
91
+ * query can no longer find it.
92
+ */
93
+ export function installModelSheetExtras(ctx: ClientContext): void {
94
+ ctx.effect(() => {
95
+ const phone = window.matchMedia(PHONE_QUERY)
96
+ /** The container while parked in the sheet, plus where to put it back. */
97
+ let parked: { node: Element, parent: Element, next: Node | null } | null = null
98
+
99
+ const park = (node: Element, menu: Element): void => {
100
+ if (parked !== null || node.parentElement === null) return
101
+ parked = { node, parent: node.parentElement, next: node.nextSibling }
102
+ menu.appendChild(node)
103
+ }
104
+
105
+ const unpark = (): void => {
106
+ if (parked === null) return
107
+ const { node, parent, next } = parked
108
+ parked = null
109
+ // The menu may already be detached, having taken the container with it.
110
+ // The node itself is still live either way; re-home it only if its old
111
+ // parent is still on the page (when the whole composer went away, the
112
+ // owning React roots rebuild the slot on the next mount).
113
+ if (parent.isConnected) parent.insertBefore(node, next)
114
+ }
115
+
116
+ /** Hand the container back to the host, marker and all. */
117
+ const release = (node: Element | null): void => {
118
+ unpark()
119
+ node?.removeAttribute(MANAGED)
120
+ }
121
+
122
+ const sync = (): void => {
123
+ const extras = document.querySelector(EXTRAS_SELECTOR)
124
+ if (extras === null) { unpark(); return }
125
+ // No model pill, no sheet to park into — a subagent session has none.
126
+ // Claiming the slot there would hide the controls from the row with
127
+ // nowhere to show them instead, which is the unreachable state the
128
+ // marker is meant to prevent.
129
+ const seat = document.querySelector(MODEL_SEAT_SELECTOR)
130
+ if (!phone.matches || seat === null || !worthMoving(extras)) { release(extras); return }
131
+ extras.setAttribute(MANAGED, '')
132
+ const menu = document.querySelector(MENU_SELECTOR)
133
+ if (menu === null) unpark()
134
+ else park(extras, menu)
135
+ }
136
+
137
+ // Synchronous in the observer callback, never behind rAF: the callback is
138
+ // a microtask and runs before this frame paints, so the controls are in
139
+ // the sheet the first time it is drawn. A rAF would land a frame late and
140
+ // show one frame of the sheet without them (AGENTS.md's "晚一帧" rule).
141
+ // Body-level childList is the same shape modal-back.ts uses; aria-expanded
142
+ // is watched too so a close that reuses the node still re-syncs.
143
+ const observer = new MutationObserver(sync)
144
+ observer.observe(document.body, {
145
+ childList: true,
146
+ subtree: true,
147
+ attributes: true,
148
+ attributeFilter: ['aria-expanded'],
149
+ })
150
+ phone.addEventListener('change', sync)
151
+ sync()
152
+
153
+ return () => {
154
+ observer.disconnect()
155
+ phone.removeEventListener('change', sync)
156
+ release(document.querySelector(EXTRAS_SELECTOR))
157
+ }
158
+ }, 'dsh-mobile-nav: model sheet extras')
159
+ }
@@ -21,7 +21,7 @@
21
21
  * tap can only ever reach the real thing — no double handling.
22
22
  */
23
23
 
24
- import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
24
+ import type { ClientContext } from '../compat/types.ts'
25
25
 
26
26
  const PHONE = '(max-width: 767px)'
27
27
  const HEADER = '[data-phase] header'
@@ -1,4 +1,4 @@
1
- import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
1
+ import type { ClientContext } from '../compat/types.ts'
2
2
 
3
3
  /**
4
4
  * Phone chrome: KEEP the system status bar (no fullscreen) and make it
@@ -1,4 +1,4 @@
1
- import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
1
+ import type { ClientContext } from '../compat/types.ts'
2
2
  import { clientConfig } from '../client-config.ts'
3
3
  import { NS } from '../locales.ts'
4
4
 
@@ -1,4 +1,4 @@
1
- import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
1
+ import type { ClientContext } from '../compat/types.ts'
2
2
 
3
3
  /** Per-browser opt-out flag; localStorage because remote (non-loopback)
4
4
  connections never persist the acknowledgement host-side. */
@@ -1,4 +1,4 @@
1
- import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
1
+ import type { ClientContext } from '../compat/types.ts'
2
2
 
3
3
  /** Phone breakpoint — same query every phone-only effect in this plugin uses. */
4
4
  const PHONE_QUERY = '(max-width: 767px)'