@linxin666/dsh-remote-web-ui 0.1.12 → 0.1.14

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 (68) hide show
  1. package/README.i18n.yaml +6 -0
  2. package/README.md +17 -3
  3. package/README.zh.md +171 -0
  4. package/lib/client.js +171 -100
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +338 -31
  7. package/lib/mobile.js +109 -33
  8. package/lib/mobile.js.map +1 -1
  9. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  10. package/lib/types/client/PluginSettingsCard.js +3 -2
  11. package/lib/types/client/RemoteSettingsCard.d.ts +4 -0
  12. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  13. package/lib/types/client/RemoteSettingsCard.js +3 -1
  14. package/lib/types/client/UpdateEntry.d.ts.map +1 -1
  15. package/lib/types/client/UpdateEntry.js +10 -2
  16. package/lib/types/client/deep-link.js +10 -5
  17. package/lib/types/client/locales.d.ts +8 -0
  18. package/lib/types/client/locales.d.ts.map +1 -1
  19. package/lib/types/client/locales.js +10 -2
  20. package/lib/types/client/update-api.d.ts +10 -0
  21. package/lib/types/client/update-api.d.ts.map +1 -1
  22. package/lib/types/client/update-api.js +22 -2
  23. package/lib/types/index.d.ts +7 -0
  24. package/lib/types/index.d.ts.map +1 -1
  25. package/lib/types/index.js +7 -1
  26. package/lib/types/mobile/api.d.ts +9 -2
  27. package/lib/types/mobile/api.d.ts.map +1 -1
  28. package/lib/types/mobile/api.js +7 -3
  29. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  30. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  31. package/lib/types/mobile/mobile-styles.js +3 -0
  32. package/lib/types/mobile/views/App.d.ts +3 -1
  33. package/lib/types/mobile/views/App.d.ts.map +1 -1
  34. package/lib/types/mobile/views/App.js +18 -3
  35. package/lib/types/mobile/views/ChatView.d.ts +6 -0
  36. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  37. package/lib/types/mobile/views/ChatView.js +95 -7
  38. package/lib/types/mobile/views/ChatView.test.js +161 -3
  39. package/lib/types/mobile/views/WorkspaceView.d.ts +3 -1
  40. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
  41. package/lib/types/mobile/views/WorkspaceView.js +9 -2
  42. package/lib/types/mobile/views/WorkspaceView.test.d.ts +2 -0
  43. package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +1 -0
  44. package/lib/types/mobile/views/WorkspaceView.test.js +56 -0
  45. package/lib/types/mobile-api.d.ts +2 -0
  46. package/lib/types/mobile-api.d.ts.map +1 -1
  47. package/lib/types/mobile-api.js +17 -2
  48. package/lib/types/update.d.ts +5 -1
  49. package/lib/types/update.d.ts.map +1 -1
  50. package/lib/types/update.js +122 -26
  51. package/package.json +1 -11
  52. package/src/client/PluginSettingsCard.tsx +30 -5
  53. package/src/client/RemoteSettingsCard.tsx +18 -0
  54. package/src/client/UpdateEntry.tsx +10 -2
  55. package/src/client/deep-link.ts +10 -4
  56. package/src/client/locales.ts +10 -2
  57. package/src/client/settings-card.module.css +139 -144
  58. package/src/client/update-api.ts +22 -2
  59. package/src/index.ts +14 -1
  60. package/src/mobile/api.ts +14 -2
  61. package/src/mobile/mobile-styles.ts +3 -0
  62. package/src/mobile/views/App.tsx +22 -3
  63. package/src/mobile/views/ChatView.test.tsx +185 -3
  64. package/src/mobile/views/ChatView.tsx +102 -8
  65. package/src/mobile/views/WorkspaceView.test.tsx +66 -0
  66. package/src/mobile/views/WorkspaceView.tsx +11 -2
  67. package/src/mobile-api.ts +20 -2
  68. package/src/update.ts +116 -26
@@ -1,76 +1,83 @@
1
+ /* Plugin settings card chrome + staged form fields.
2
+ * Aligned with the official ui-settings-plugins PluginCard / fields CSS:
3
+ * same semantic tokens, radius, typography and states so family cards read
4
+ * as siblings of the built-in Shell / Agent loop / Web search cards. */
5
+
1
6
  .card {
2
- list-style: none;
3
7
  border: 1px solid var(--dsw-alias-border-l2);
4
- border-radius: 8px;
5
8
  background: var(--dsw-alias-bg-layer-3);
6
- overflow: hidden;
9
+ border-radius: 12px;
10
+ list-style: none;
7
11
  transition: border-color 0.16s, background 0.16s;
8
12
  }
9
13
 
14
+ .card:hover {
15
+ border-color: var(--dsw-alias-label-dimmed);
16
+ }
17
+
10
18
  .cardOpen {
11
19
  background: var(--dsw-alias-bg-layer-2);
12
20
  border-color: var(--dsw-alias-label-dimmed);
13
21
  }
14
22
 
15
23
  .header {
16
- display: flex;
17
- align-items: center;
18
- gap: 8px;
24
+ appearance: none;
19
25
  width: 100%;
20
- padding: 10px 14px;
21
- border: 0;
22
- background: transparent;
23
- cursor: pointer;
24
- text-align: left;
25
26
  font: inherit;
26
- border-radius: 8px;
27
- transition: background-color 120ms ease, box-shadow 120ms ease;
28
- }
29
-
30
- .header:hover:not(:disabled) {
31
- background: var(--dsw-alias-interactive-bg-hover);
32
- }
33
-
34
- .header:active:not(:disabled) {
35
- background: var(--dsw-alias-interactive-bg-active);
27
+ color: inherit;
28
+ text-align: left;
29
+ cursor: pointer;
30
+ background: transparent;
31
+ border: 0;
32
+ border-radius: 12px;
33
+ align-items: center;
34
+ gap: 12px;
35
+ padding: 14px 16px;
36
+ display: flex;
36
37
  }
37
38
 
38
39
  .header:focus-visible {
39
- outline: none;
40
- box-shadow: 0 0 0 2px var(--dsw-alias-bg-layer-3), 0 0 0 4px var(--dsw-alias-brand-primary);
41
- }
42
-
43
- .header:disabled {
44
- opacity: 0.5;
45
- cursor: default;
40
+ outline: 2px solid var(--dsw-alias-brand-primary);
41
+ outline-offset: -2px;
46
42
  }
47
43
 
48
44
  .headText {
49
- display: flex;
50
45
  flex-direction: column;
51
- gap: 2px;
52
46
  flex: 1;
47
+ gap: 4px;
53
48
  min-width: 0;
49
+ display: flex;
54
50
  }
55
51
 
56
52
  .name {
57
- font-weight: 600;
58
53
  color: var(--dsw-alias-label-primary);
54
+ font-size: 15px;
55
+ font-weight: 600;
56
+ line-height: 1.4;
59
57
  }
60
58
 
61
59
  .description {
62
- font-size: 12px;
63
60
  color: var(--dsw-alias-label-tertiary);
61
+ font-size: 13px;
62
+ line-height: 1.5;
64
63
  }
65
64
 
66
65
  .pending {
67
- font-size: 12px;
68
- color: var(--dsw-alias-state-warn-primary);
66
+ white-space: nowrap;
67
+ background: var(--dsw-alias-bg-module-platform);
68
+ color: var(--dsw-alias-label-secondary);
69
+ border-radius: 999px;
70
+ flex: none;
71
+ padding: 1px 8px;
72
+ font-size: 11px;
73
+ font-weight: 500;
74
+ line-height: 17px;
69
75
  }
70
76
 
71
77
  .chevron {
72
- transition: transform 120ms ease;
73
78
  color: var(--dsw-alias-label-tertiary);
79
+ flex: none;
80
+ transition: transform 0.16s;
74
81
  }
75
82
 
76
83
  .chevronOpen {
@@ -78,52 +85,59 @@
78
85
  }
79
86
 
80
87
  .body {
81
- padding: 0 14px 14px;
82
- display: flex;
83
- flex-direction: column;
84
- gap: 14px;
88
+ border-top: 1px solid var(--dsw-alias-border-l2);
89
+ margin: 0 16px;
90
+ padding-bottom: 8px;
85
91
  }
86
92
 
87
93
  .readOnly {
88
- margin: 0;
89
- font-size: 12px;
90
94
  color: var(--dsw-alias-label-tertiary);
95
+ margin: 12px 0 0;
96
+ font-size: 12px;
97
+ line-height: 1.5;
91
98
  }
92
99
 
93
100
  .notExposed {
94
- margin: 0;
101
+ color: var(--dsw-alias-state-warn-primary);
102
+ margin: 12px 0 0;
95
103
  font-size: 12px;
96
104
  line-height: 1.5;
97
- color: var(--dsw-alias-state-warn-primary);
98
105
  }
99
106
 
100
107
  .footer {
101
- display: flex;
102
- align-items: center;
108
+ border-top: 1px solid var(--dsw-alias-border-l2);
103
109
  justify-content: flex-end;
110
+ align-items: center;
104
111
  gap: 8px;
112
+ padding: 12px 0 4px;
113
+ display: flex;
105
114
  }
106
115
 
107
116
  .failed {
108
- margin: 0 auto 0 0;
117
+ min-width: 0;
118
+ color: var(--dsw-alias-label-error);
119
+ flex: 1;
120
+ margin: 0;
109
121
  font-size: 12px;
110
- color: var(--dsw-alias-state-error-primary);
122
+ line-height: 1.5;
111
123
  }
112
124
 
113
125
  .discard,
114
126
  .save {
115
- padding: 5px 12px;
116
- border-radius: 6px;
127
+ appearance: none;
117
128
  font: inherit;
118
- font-size: 13px;
119
129
  cursor: pointer;
120
- transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
130
+ border: 1px solid transparent;
131
+ border-radius: 8px;
132
+ padding: 5px 14px;
133
+ font-size: 13px;
134
+ line-height: 1.5;
121
135
  }
122
136
 
123
137
  .discard {
124
- border: 1px solid var(--dsw-alias-border-l2);
125
- background: transparent;
138
+ border-color: var(--dsw-alias-border-l2);
126
139
  color: var(--dsw-alias-label-secondary);
140
+ background: transparent;
127
141
  }
128
142
 
129
143
  .discard:hover:not(:disabled) {
@@ -131,173 +145,154 @@
131
145
  border-color: var(--dsw-alias-label-dimmed);
132
146
  }
133
147
 
134
- .discard:active:not(:disabled) {
135
- background: var(--dsw-alias-interactive-bg-active);
136
- }
137
-
138
- .discard:focus-visible {
139
- outline: none;
140
- box-shadow: 0 0 0 2px var(--dsw-alias-bg-layer-3), 0 0 0 4px var(--dsw-alias-brand-primary);
141
- }
142
-
143
148
  .save {
144
- border: 1px solid var(--dsw-alias-button-info-fill);
145
- background: var(--dsw-alias-button-info-fill);
146
- color: var(--dsw-alias-label-primary-foreground);
147
- }
148
-
149
- .save:hover:not(:disabled) {
150
- border-color: var(--dsw-alias-button-info-hover);
151
- background: var(--dsw-alias-button-info-hover);
152
- }
153
-
154
- .save:active:not(:disabled) {
155
- border-color: var(--dsw-alias-button-info-hover);
156
- background: var(--dsw-alias-button-info-hover);
157
- filter: brightness(0.96);
158
- }
159
-
160
- .save:focus-visible {
161
- outline: none;
162
- box-shadow: 0 0 0 2px var(--dsw-alias-bg-layer-3), 0 0 0 4px var(--dsw-alias-brand-primary);
149
+ background: var(--dsw-alias-label-primary);
150
+ color: var(--dsw-alias-bg-layer-3);
163
151
  }
164
152
 
165
153
  .discard:disabled,
166
154
  .save:disabled {
167
- opacity: 0.5;
155
+ opacity: 0.4;
168
156
  cursor: default;
169
157
  }
170
158
 
159
+ .discard:focus-visible,
160
+ .save:focus-visible {
161
+ outline: 2px solid var(--dsw-alias-brand-primary);
162
+ outline-offset: 1px;
163
+ }
164
+
171
165
  .field {
172
- display: flex;
173
166
  flex-direction: column;
174
- gap: 4px;
167
+ gap: 6px;
168
+ padding: 12px 0;
169
+ display: flex;
170
+ }
171
+
172
+ .field + .field {
173
+ border-top: 1px solid var(--dsw-alias-border-l2);
175
174
  }
176
175
 
177
176
  .head {
178
- display: flex;
179
177
  align-items: center;
180
178
  gap: 8px;
179
+ display: flex;
181
180
  }
182
181
 
183
182
  .label {
183
+ min-width: 0;
184
+ color: var(--dsw-alias-label-primary);
185
+ flex: 1;
184
186
  font-size: 13px;
185
187
  font-weight: 500;
186
- color: var(--dsw-alias-label-primary);
188
+ line-height: 1.5;
187
189
  }
188
190
 
189
191
  .badges {
190
- display: flex;
191
192
  align-items: center;
192
- gap: 6px;
193
+ gap: 8px;
194
+ display: inline-flex;
193
195
  }
194
196
 
195
197
  .badge {
196
- flex: none;
197
- min-width: 0;
198
- font-size: 11px;
199
- padding: 1px 6px;
200
- border-radius: 999px;
201
- background: var(--dsw-alias-interactive-bg-hover-accent);
202
- color: var(--dsw-alias-state-business-primary);
203
198
  white-space: nowrap;
199
+ background: var(--dsw-alias-bg-module-platform);
200
+ color: var(--dsw-alias-label-secondary);
201
+ border-radius: 999px;
202
+ padding: 1px 8px;
203
+ font-size: 11px;
204
+ font-weight: 500;
205
+ line-height: 17px;
204
206
  }
205
207
 
206
208
  .reset {
207
- flex: none;
208
- font-size: 11px;
209
- border: 0;
210
- border-radius: 4px;
211
- background: transparent;
212
- color: var(--dsw-alias-state-business-primary);
209
+ font: inherit;
210
+ color: var(--dsw-alias-label-secondary);
213
211
  cursor: pointer;
214
- padding: 1px 2px;
215
- transition: background-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
212
+ background: transparent;
213
+ border: none;
214
+ padding: 0;
215
+ font-size: 12px;
216
+ line-height: 1.5;
216
217
  }
217
218
 
218
219
  .reset:hover:not(:disabled) {
219
220
  color: var(--dsw-alias-label-primary);
220
- background: var(--dsw-alias-interactive-bg-hover);
221
221
  }
222
222
 
223
- .reset:active:not(:disabled) {
224
- background: var(--dsw-alias-interactive-bg-active);
223
+ .reset:disabled {
224
+ cursor: default;
225
225
  }
226
226
 
227
227
  .reset:focus-visible {
228
- outline: none;
229
- box-shadow: 0 0 0 2px var(--dsw-alias-bg-layer-3), 0 0 0 4px var(--dsw-alias-brand-primary);
230
- }
231
-
232
- .reset:disabled {
233
- opacity: 0.5;
234
- cursor: default;
228
+ outline: 2px solid var(--dsw-alias-brand-primary);
229
+ outline-offset: 2px;
235
230
  }
236
231
 
237
232
  .input,
238
233
  .select {
239
- padding: 6px 8px;
240
234
  border: 1px solid var(--dsw-alias-border-l2);
241
- border-radius: 6px;
235
+ background: var(--dsw-alias-bg-layer-3);
236
+ height: 34px;
242
237
  font: inherit;
243
- font-size: 13px;
244
238
  color: var(--dsw-alias-label-primary);
245
- background: var(--dsw-specific-input-major);
246
- transition: border-color 120ms ease, box-shadow 120ms ease;
239
+ border-radius: 8px;
240
+ padding: 0 12px;
241
+ font-size: 13px;
242
+ line-height: 1.5;
247
243
  }
248
244
 
249
245
  .input:focus-visible,
250
246
  .select:focus-visible {
251
- outline: none;
252
247
  border-color: var(--dsw-alias-brand-primary);
253
- box-shadow: 0 0 0 2px var(--dsw-alias-bg-layer-3), 0 0 0 4px var(--dsw-alias-brand-primary);
248
+ outline: none;
249
+ }
250
+
251
+ .input:disabled,
252
+ .select:disabled {
253
+ color: var(--dsw-alias-label-tertiary);
254
+ cursor: default;
254
255
  }
255
256
 
256
257
  .inputInvalid {
257
- padding: 6px 8px;
258
- border: 1px solid var(--dsw-alias-state-error-primary);
259
- border-radius: 6px;
258
+ border: 1px solid var(--dsw-alias-label-error);
259
+ background: var(--dsw-alias-bg-layer-3);
260
+ height: 34px;
260
261
  font: inherit;
261
- font-size: 13px;
262
262
  color: var(--dsw-alias-label-primary);
263
- transition: border-color 120ms ease, box-shadow 120ms ease;
263
+ border-radius: 8px;
264
+ padding: 0 12px;
265
+ font-size: 13px;
266
+ line-height: 1.5;
264
267
  }
265
268
 
266
269
  .inputInvalid:focus-visible {
267
- outline: none;
268
- border-color: var(--dsw-alias-state-error-primary);
269
- box-shadow: 0 0 0 2px var(--dsw-alias-bg-layer-3), 0 0 0 4px var(--dsw-alias-state-error-primary);
270
+ outline: 2px solid var(--dsw-alias-label-error);
271
+ outline-offset: 1px;
272
+ border-color: var(--dsw-alias-label-error);
270
273
  }
271
274
 
272
- .input:disabled,
273
- .select:disabled {
274
- opacity: 0.6;
275
- }
276
-
277
- .hint {
275
+ .invalid {
276
+ color: var(--dsw-alias-label-error);
278
277
  margin: 0;
279
278
  font-size: 12px;
280
- color: var(--dsw-alias-label-secondary);
279
+ line-height: 1.5;
281
280
  }
282
281
 
283
- .invalid {
282
+ .hint {
283
+ color: var(--dsw-alias-label-tertiary);
284
284
  margin: 0;
285
285
  font-size: 12px;
286
- color: var(--dsw-alias-state-error-primary);
286
+ line-height: 1.5;
287
287
  }
288
288
 
289
- /* Accessibility: collapse the interaction transitions for reduced-motion
290
- users; the card chrome itself stays static. */
291
289
  @media (prefers-reduced-motion: reduce) {
292
290
  .card,
293
291
  .header,
294
292
  .chevron,
293
+ .chevronOpen,
295
294
  .discard,
296
- .save,
297
- .reset,
298
- .input,
299
- .select,
300
- .inputInvalid {
295
+ .save {
301
296
  transition: none;
302
297
  }
303
298
  }
@@ -6,14 +6,34 @@
6
6
 
7
7
  import type { UpdateRunResult, UpdateStatus } from '../update.ts'
8
8
 
9
+ /**
10
+ * Error thrown when the update status probe fails: carries the HTTP status
11
+ * so the panel can tell "the update route is not mounted" (404 — the host
12
+ * process runs an older plugin build) apart from real network failures.
13
+ */
14
+ export class UpdateStatusError extends Error {
15
+ /** HTTP status of the failed response (0 when the fetch never returned). */
16
+ readonly status: number
17
+ constructor(status: number) {
18
+ super('update status unavailable (HTTP ' + String(status) + ')')
19
+ this.status = status
20
+ }
21
+ }
22
+
9
23
  /**
10
24
  * Probe the update status: install mode, owning profile, and the
11
25
  * current-vs-latest comparison for every family package.
12
26
  * @returns the status snapshot.
13
27
  */
14
28
  export async function fetchUpdateStatus(): Promise<UpdateStatus> {
15
- const response = await fetch('/api/update/status')
16
- if (!response.ok) throw new Error('update status unavailable')
29
+ let response: Response
30
+ try {
31
+ response = await fetch('/api/update/status')
32
+ } catch {
33
+ // Network-level failure: nothing came back at all.
34
+ throw new UpdateStatusError(0)
35
+ }
36
+ if (!response.ok) throw new UpdateStatusError(response.status)
17
37
  return await response.json() as UpdateStatus
18
38
  }
19
39
 
package/src/index.ts CHANGED
@@ -96,6 +96,13 @@ export interface Config {
96
96
  * tunnel setup. The manual `publicBaseUrl` is ignored while this is on.
97
97
  */
98
98
  autoTunnel?: boolean
99
+ /**
100
+ * Mobile composer behavior: when true (default), a plain Enter in the
101
+ * phone chat textarea sends the prompt and Shift+Enter inserts a newline.
102
+ * When false, plain Enter inserts a newline and only the send button
103
+ * sends (Shift+Enter keeps inserting a newline).
104
+ */
105
+ mobileEnterToSend?: boolean
99
106
  /** Master switch for the plugin (browser half + host pairing surfaces). */
100
107
  enabled?: boolean
101
108
  }
@@ -108,6 +115,7 @@ export const Config: z<Config> = z.object({
108
115
  requirePairingForLan: z.boolean().default(true),
109
116
  publicBaseUrl: z.string(),
110
117
  autoTunnel: z.boolean().default(false),
118
+ mobileEnterToSend: z.boolean().default(true),
111
119
  enabled: z.boolean().default(true),
112
120
  })
113
121
 
@@ -130,6 +138,7 @@ const DEFAULTS: ResolvedConfig = {
130
138
  requirePairingForLan: true,
131
139
  publicBaseUrl: undefined,
132
140
  autoTunnel: false,
141
+ mobileEnterToSend: true,
133
142
  enabled: true,
134
143
  }
135
144
 
@@ -147,6 +156,7 @@ export function apply(ctx: Context, config?: Config): void {
147
156
  requirePairingForLan: config?.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
148
157
  publicBaseUrl: config?.publicBaseUrl,
149
158
  autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
159
+ mobileEnterToSend: config?.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
150
160
  enabled: config?.enabled ?? DEFAULTS.enabled,
151
161
  }
152
162
  // The live source the pairing service and the gate read: the settings
@@ -163,6 +173,7 @@ export function apply(ctx: Context, config?: Config): void {
163
173
  requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
164
174
  publicBaseUrl: value.publicBaseUrl,
165
175
  autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
176
+ mobileEnterToSend: value.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
166
177
  enabled: value.enabled ?? DEFAULTS.enabled,
167
178
  }
168
179
  }
@@ -267,7 +278,9 @@ export function apply(ctx: Context, config?: Config): void {
267
278
  const routes = [
268
279
  ...makeRoutes({ service, lanAddresses }),
269
280
  ...makeMobileRoutes(),
270
- ...(apiProxy !== undefined ? makeMobileApiRoutes({ service, apiProxy }) : []),
281
+ ...(apiProxy !== undefined
282
+ ? makeMobileApiRoutes({ service, apiProxy, mobileEnterToSend: () => resolve().mobileEnterToSend })
283
+ : []),
271
284
  ...updateRoutes,
272
285
  ]
273
286
  const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled)
package/src/mobile/api.ts CHANGED
@@ -35,12 +35,23 @@ export interface HistoryPage {
35
35
  projections?: SessionProjectionsBlock
36
36
  }
37
37
 
38
+ /** Read-only display preferences the plugin answers locally on `/m/api`. */
39
+ export interface MobilePreferences {
40
+ /** Plain Enter sends the drafted prompt (false: Enter inserts a newline). */
41
+ mobileEnterToSend: boolean
42
+ }
43
+
38
44
  /** The workspace roster (session ids come back per workspace). */
39
45
  export async function listWorkspaces(): Promise<WorkspaceView[]> {
40
46
  const { items } = await callUnary<{ items: WorkspaceView[] }>('workspace.list', {})
41
47
  return items
42
48
  }
43
49
 
50
+ /** Read-only mobile display preferences (answered by the plugin, not the host proxy). */
51
+ export async function fetchMobilePreferences(): Promise<MobilePreferences> {
52
+ return await callUnary<MobilePreferences>('mobile.preferences', {})
53
+ }
54
+
44
55
  /** One session.list page; omit the cursor for the first page. */
45
56
  export async function listSessions(cursor?: string): Promise<SessionPage> {
46
57
  return await callUnary<SessionPage>('session.list', cursor === undefined ? {} : { cursor })
@@ -56,17 +67,18 @@ export async function createSession(
56
67
  return await callUnary<CreatedSession>('session.create', options)
57
68
  }
58
69
 
59
- /** One history window; omit beforeSeq for the tail page. */
70
+ /** One history window; omit beforeSeq for the tail page, pass a signal to abort. */
60
71
  export async function history(
61
72
  sessionId: string,
62
73
  beforeSeq?: number,
63
74
  maxMessages = 30,
75
+ signal?: AbortSignal,
64
76
  ): Promise<HistoryPage> {
65
77
  return await callUnary<HistoryPage>('session.history', {
66
78
  sessionId,
67
79
  maxMessages,
68
80
  ...(beforeSeq !== undefined ? { beforeSeq } : {}),
69
- })
81
+ }, signal)
70
82
  }
71
83
 
72
84
  /** Send one text prompt (queued: the agent picks it up in order). */
@@ -765,6 +765,9 @@ body {
765
765
  min-width: 0;
766
766
  min-height: 40px;
767
767
  max-height: 120px;
768
+ /* Grow with the content (still bounded by min/max-height); browsers
769
+ without field-sizing support ignore this line and keep the old size. */
770
+ field-sizing: content;
768
771
  padding: 9px 12px;
769
772
  border: 1px solid var(--m-border);
770
773
  border-radius: 10px;
@@ -42,6 +42,12 @@ export interface RenderMessage {
42
42
  toolSummary?: string
43
43
  }
44
44
 
45
+ /** Read the optional workspace target carried from the pairing QR flow. */
46
+ export function mobileWorkspaceTarget(search: string): string | undefined {
47
+ const value = new URLSearchParams(search).get('workspace')
48
+ return value === null || value === '' ? undefined : value
49
+ }
50
+
45
51
  /** Map a list row to the surface model; the title comes from projections when present. */
46
52
  export function toSessionView(item: {
47
53
  sessionId: string
@@ -83,6 +89,9 @@ export function formatTime(epochMs: number): string {
83
89
  */
84
90
  export function App() {
85
91
  const [route, setRoute] = useState<Route>({ kind: 'workspaces' })
92
+ const [initialWorkspaceId, setInitialWorkspaceId] = useState<string | undefined>(
93
+ () => mobileWorkspaceTarget(window.location.search),
94
+ )
86
95
  const muxRef = useRef<MuxClient | undefined>(undefined)
87
96
 
88
97
  // The mux stream lives for the page lifetime: session events keep the
@@ -113,10 +122,20 @@ export function App() {
113
122
  setRoute({ kind: 'chat', session, workspace })
114
123
  }, [])
115
124
 
125
+ const openWorkspace = useCallback((workspace: WorkspaceRow) => {
126
+ if (initialWorkspaceId !== undefined) {
127
+ const url = new URL(window.location.href)
128
+ url.searchParams.delete('workspace')
129
+ window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`)
130
+ setInitialWorkspaceId(undefined)
131
+ }
132
+ setRoute({ kind: 'sessions', workspace })
133
+ }, [initialWorkspaceId])
134
+
116
135
  return (
117
136
  <div className="mobile">
118
137
  {route.kind === 'workspaces'
119
- ? <WorkspaceRoster onPick={(workspace) => { setRoute({ kind: 'sessions', workspace }) }} />
138
+ ? <WorkspaceRoster initialWorkspaceId={initialWorkspaceId} onPick={openWorkspace} />
120
139
  : route.kind === 'sessions'
121
140
  ? (
122
141
  <SessionListView
@@ -152,8 +171,8 @@ export function staleHostHint(message: string): string | undefined {
152
171
  }
153
172
 
154
173
  /** Fetch one history page (tail by default) — thin wrapper so views share the call shape. */
155
- export function loadHistory(sessionId: string, beforeSeq?: number) {
156
- return fetchHistory(sessionId, beforeSeq)
174
+ export function loadHistory(sessionId: string, beforeSeq?: number, signal?: AbortSignal) {
175
+ return fetchHistory(sessionId, beforeSeq, undefined, signal)
157
176
  }
158
177
 
159
178
  export { listSessions, listWorkspaces, prompt }