@linxin666/dsh-client-ui-task-board 0.1.12 → 0.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.
@@ -7,9 +7,26 @@ export interface TaskStore {
7
7
  save(tasks: readonly TaskRecord[]): void;
8
8
  /** Drop the persisted ledger (leaves the in-memory state alone). */
9
9
  clear(): void;
10
+ /**
11
+ * Subscribe to ledger changes written by ANOTHER tab of the same origin
12
+ * (browser storage events). The board controller reloads the ledger on
13
+ * such a change, so a task deleted in one tab cannot keep firing (or be
14
+ * written back) from the stale in-memory copy of another tab. No-op when
15
+ * the backend has no cross-instance channel (in-memory store).
16
+ */
17
+ subscribeExternal?(listener: () => void): () => void;
10
18
  }
11
19
  /** Storage key for the task ledger document. */
12
20
  export declare const DEFAULT_STORAGE_KEY = "dsh.taskBoard.v1";
21
+ /** Structural shape of the storage event fired in sibling tabs (DOM-free). */
22
+ export interface StorageChangeEvent {
23
+ key: string | null;
24
+ }
25
+ /** The event-target face the store needs for cross-tab notifications. */
26
+ export interface StorageEvents {
27
+ addEventListener(type: 'storage', listener: (event: StorageChangeEvent) => void): void;
28
+ removeEventListener(type: 'storage', listener: (event: StorageChangeEvent) => void): void;
29
+ }
13
30
  /** A task record is structurally valid if it round-trips through the UI. */
14
31
  export declare function isTaskRecord(value: unknown): value is TaskRecord;
15
32
  /** Parse + validate a persisted ledger document; invalid rows are dropped. */
@@ -18,14 +35,25 @@ export declare function parseLedger(raw: string | null): TaskRecord[];
18
35
  export declare class LocalStorageTaskStore implements TaskStore {
19
36
  private readonly key;
20
37
  private readonly storage;
38
+ private readonly events;
21
39
  /**
22
40
  * @param key - storage key for the ledger document.
23
41
  * @param storage - storage backend (defaults to the global localStorage; tests inject fakes).
42
+ * @param events - storage-event target for cross-tab notifications (defaults
43
+ * to the browser global; undefined in non-browser runtimes, where the
44
+ * subscription becomes a no-op).
24
45
  */
25
- constructor(key?: string, storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> | undefined);
46
+ constructor(key?: string, storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> | undefined, events?: StorageEvents | undefined);
26
47
  load(): TaskRecord[];
27
48
  save(tasks: readonly TaskRecord[]): void;
28
49
  clear(): void;
50
+ /**
51
+ * Cross-tab change subscription (see {@link TaskStore.subscribeExternal}).
52
+ * The browser fires the storage event in every OTHER tab of the same origin
53
+ * when one tab writes; a null key means the whole storage was cleared. Both
54
+ * cases reload the ledger here; unrelated keys are ignored.
55
+ */
56
+ subscribeExternal(listener: () => void): () => void;
29
57
  }
30
58
  /** In-memory backend (tests, and a fallback when storage is unavailable). */
31
59
  export declare class InMemoryTaskStore implements TaskStore {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-client-ui-task-board",
3
3
  "description": "Task board for the dsh web GUI: a sidebar entry plus a multi-column kanban view with local persistence, task details, and real execution through dsh sessions (session.prompt). Hot-pluggable — mounted via ~/.dsh/cordis.patch.yml + a profile node_modules symlink, no dsh source changes.",
4
- "version": "0.1.12",
4
+ "version": "0.1.13",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -35,10 +35,6 @@
35
35
  }
36
36
  },
37
37
  "peerDependencies": {
38
- "@deepseek-ai/dsh-client-connection": "^0.1.0-rc.6",
39
- "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
40
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
41
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
42
38
  "react": "^18.2.0"
43
39
  },
44
40
  "dependencies": {
@@ -41,13 +41,14 @@ export function PluginSettingsCard(props: PluginSettingsCardProps) {
41
41
  if (!state.available) return null
42
42
  const title = props.t(props.titleKey)
43
43
  const blocked = !state.dirty || state.invalid || state.saving
44
+ const cardClass = open ? `${css.cardOpen} ${css.card}` : css.card
44
45
  // The namespace exists but the Host does not serve it to this client (the
45
46
  // official settings allowlist omits third-party namespaces): show a card
46
47
  // that explains the gap instead of vanishing, so a missing card never
47
48
  // reads as a missing plugin.
48
49
  if (!state.exposed) {
49
50
  return (
50
- <li className={css.card}>
51
+ <li className={cardClass}>
51
52
  <button
52
53
  type="button"
53
54
  className={css.header}
@@ -59,7 +60,19 @@ export function PluginSettingsCard(props: PluginSettingsCardProps) {
59
60
  <span className={css.name}>{title}</span>
60
61
  <span className={css.description}>{props.t(props.descriptionKey)}</span>
61
62
  </span>
62
- <span className={open ? css.chevronOpen : css.chevron}>▾</span>
63
+ <svg
64
+ width="14"
65
+ height="14"
66
+ viewBox="0 0 14 14"
67
+ fill="none"
68
+ xmlns="http://www.w3.org/2000/svg"
69
+ className={open ? `${css.chevron} ${css.chevronOpen}` : css.chevron}
70
+ >
71
+ <path
72
+ d="M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z"
73
+ fill="currentColor"
74
+ />
75
+ </svg>
63
76
  </button>
64
77
  {open
65
78
  ? (
@@ -72,7 +85,7 @@ export function PluginSettingsCard(props: PluginSettingsCardProps) {
72
85
  )
73
86
  }
74
87
  return (
75
- <li className={css.card}>
88
+ <li className={cardClass}>
76
89
  <button
77
90
  type="button"
78
91
  className={css.header}
@@ -85,7 +98,19 @@ export function PluginSettingsCard(props: PluginSettingsCardProps) {
85
98
  <span className={css.description}>{props.t(props.descriptionKey)}</span>
86
99
  </span>
87
100
  {state.dirty ? <span className={css.pending}>{props.t('settings.unsaved')}</span> : null}
88
- <span className={open ? css.chevronOpen : css.chevron}>▾</span>
101
+ <svg
102
+ width="14"
103
+ height="14"
104
+ viewBox="0 0 14 14"
105
+ fill="none"
106
+ xmlns="http://www.w3.org/2000/svg"
107
+ className={open ? `${css.chevron} ${css.chevronOpen}` : css.chevron}
108
+ >
109
+ <path
110
+ d="M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z"
111
+ fill="currentColor"
112
+ />
113
+ </svg>
89
114
  </button>
90
115
  {open
91
116
  ? (
@@ -234,4 +259,4 @@ export function BooleanField(props: FieldProps & {
234
259
  <p className={css.hint}>{props.hint}</p>
235
260
  </div>
236
261
  )
237
- }
262
+ }
@@ -3,8 +3,11 @@
3
3
  *
4
4
  * The `conversation` slot is single-occupant (ui-conversation) and external
5
5
  * plugins cannot declare slots, so the board takes over the center column at
6
- * the DOM level: a container is appended inside the `[data-pane="conversation"]`
7
- * grid item (an extra trailing child React never manages), and a stylesheet
6
+ * the DOM level: a container is appended inside the center column
7
+ * (`[class*="centerCol"]`, the dsh 0.1.0-rc.6 AppFrame layout; previously
8
+ * `[data-pane="conversation"]` on older shells — the mount selector keeps both)
9
+ * as an extra trailing child
10
+ * React never manages, and a stylesheet
8
11
  * rule hides the conversation content while the board is active. Toggling is
9
12
  * a data attribute on <html> — no React involvement, so the conversation
10
13
  * subtree underneath stays mounted and stateful.
@@ -17,7 +20,7 @@ import css from './board.module.css'
17
20
  /** The injected board container (kept in the DOM, hidden when inactive). */
18
21
  export const BOARD_VIEW_SELECTOR = '[data-dsh-taskboard-view]'
19
22
 
20
- const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"]'
23
+ const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"], [class*="centerCol"]'
21
24
  const ACTIVE_ATTR = 'data-dsh-taskboard-active'
22
25
  /** The sibling panel's activation attribute (ssh), removed when this panel opens. */
23
26
  const OTHER_ACTIVE_ATTR = 'data-dsh-ssh-active'
@@ -6,7 +6,8 @@
6
6
 
7
7
  /* --- center-column takeover (global rules, attribute-scoped) ----------------- */
8
8
 
9
- [data-pane='conversation'] {
9
+ [data-pane='conversation'],
10
+ [class*='centerCol'] {
10
11
  position: relative;
11
12
  }
12
13
 
@@ -1,60 +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;
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;
26
37
  }
27
38
 
28
- .header:hover {
29
- background: var(--dsw-alias-interactive-bg-hover);
39
+ .header:focus-visible {
40
+ outline: 2px solid var(--dsw-alias-brand-primary);
41
+ outline-offset: -2px;
30
42
  }
31
43
 
32
44
  .headText {
33
- display: flex;
34
45
  flex-direction: column;
35
- gap: 2px;
36
46
  flex: 1;
47
+ gap: 4px;
37
48
  min-width: 0;
49
+ display: flex;
38
50
  }
39
51
 
40
52
  .name {
41
- font-weight: 600;
42
53
  color: var(--dsw-alias-label-primary);
54
+ font-size: 15px;
55
+ font-weight: 600;
56
+ line-height: 1.4;
43
57
  }
44
58
 
45
59
  .description {
46
- font-size: 12px;
47
60
  color: var(--dsw-alias-label-tertiary);
61
+ font-size: 13px;
62
+ line-height: 1.5;
48
63
  }
49
64
 
50
65
  .pending {
51
- font-size: 12px;
52
- 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;
53
75
  }
54
76
 
55
77
  .chevron {
56
- transition: transform 120ms ease;
57
78
  color: var(--dsw-alias-label-tertiary);
79
+ flex: none;
80
+ transition: transform 0.16s;
58
81
  }
59
82
 
60
83
  .chevronOpen {
@@ -62,51 +85,59 @@
62
85
  }
63
86
 
64
87
  .body {
65
- padding: 0 14px 14px;
66
- display: flex;
67
- flex-direction: column;
68
- gap: 14px;
88
+ border-top: 1px solid var(--dsw-alias-border-l2);
89
+ margin: 0 16px;
90
+ padding-bottom: 8px;
69
91
  }
70
92
 
71
93
  .readOnly {
72
- margin: 0;
73
- font-size: 12px;
74
94
  color: var(--dsw-alias-label-tertiary);
95
+ margin: 12px 0 0;
96
+ font-size: 12px;
97
+ line-height: 1.5;
75
98
  }
76
99
 
77
100
  .notExposed {
78
- margin: 0;
101
+ color: var(--dsw-alias-state-warn-primary);
102
+ margin: 12px 0 0;
79
103
  font-size: 12px;
80
104
  line-height: 1.5;
81
- color: var(--dsw-alias-state-warn-primary);
82
105
  }
83
106
 
84
107
  .footer {
85
- display: flex;
86
- align-items: center;
108
+ border-top: 1px solid var(--dsw-alias-border-l2);
87
109
  justify-content: flex-end;
110
+ align-items: center;
88
111
  gap: 8px;
112
+ padding: 12px 0 4px;
113
+ display: flex;
89
114
  }
90
115
 
91
116
  .failed {
92
- margin: 0 auto 0 0;
117
+ min-width: 0;
118
+ color: var(--dsw-alias-label-error);
119
+ flex: 1;
120
+ margin: 0;
93
121
  font-size: 12px;
94
- color: var(--dsw-alias-state-error-primary);
122
+ line-height: 1.5;
95
123
  }
96
124
 
97
125
  .discard,
98
126
  .save {
99
- padding: 5px 12px;
100
- border-radius: 6px;
127
+ appearance: none;
101
128
  font: inherit;
102
- font-size: 13px;
103
129
  cursor: pointer;
130
+ border: 1px solid transparent;
131
+ border-radius: 8px;
132
+ padding: 5px 14px;
133
+ font-size: 13px;
134
+ line-height: 1.5;
104
135
  }
105
136
 
106
137
  .discard {
107
- border: 1px solid var(--dsw-alias-border-l2);
108
- background: transparent;
138
+ border-color: var(--dsw-alias-border-l2);
109
139
  color: var(--dsw-alias-label-secondary);
140
+ background: transparent;
110
141
  }
111
142
 
112
143
  .discard:hover:not(:disabled) {
@@ -115,154 +146,153 @@
115
146
  }
116
147
 
117
148
  .save {
118
- border: 1px solid var(--dsw-alias-button-info-fill);
119
- background: var(--dsw-alias-button-info-fill);
120
- color: var(--dsw-alias-label-primary-foreground);
121
- }
122
-
123
- .save:hover:not(:disabled) {
124
- border-color: var(--dsw-alias-button-info-hover);
125
- background: var(--dsw-alias-button-info-hover);
126
- }
127
-
128
- .discard:active:not(:disabled),
129
- .save:active:not(:disabled) {
130
- transform: translateY(1px);
131
- }
132
-
133
- .discard:focus-visible:not(:disabled),
134
- .save:focus-visible:not(:disabled) {
135
- outline: 2px solid var(--dsw-alias-state-business-primary);
136
- outline-offset: -2px;
149
+ background: var(--dsw-alias-label-primary);
150
+ color: var(--dsw-alias-bg-layer-3);
137
151
  }
138
152
 
139
153
  .discard:disabled,
140
154
  .save:disabled {
141
- opacity: 0.5;
155
+ opacity: 0.4;
142
156
  cursor: default;
143
157
  }
144
158
 
159
+ .discard:focus-visible,
160
+ .save:focus-visible {
161
+ outline: 2px solid var(--dsw-alias-brand-primary);
162
+ outline-offset: 1px;
163
+ }
164
+
145
165
  .field {
146
- display: flex;
147
166
  flex-direction: column;
148
- 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);
149
174
  }
150
175
 
151
176
  .head {
152
- display: flex;
153
177
  align-items: center;
154
178
  gap: 8px;
179
+ display: flex;
155
180
  }
156
181
 
157
182
  .label {
183
+ min-width: 0;
184
+ color: var(--dsw-alias-label-primary);
185
+ flex: 1;
158
186
  font-size: 13px;
159
187
  font-weight: 500;
160
- color: var(--dsw-alias-label-primary);
188
+ line-height: 1.5;
161
189
  }
162
190
 
163
191
  .badges {
164
- display: flex;
165
192
  align-items: center;
166
- gap: 6px;
193
+ gap: 8px;
194
+ display: inline-flex;
167
195
  }
168
196
 
169
197
  .badge {
170
- font-size: 11px;
171
- padding: 1px 6px;
198
+ white-space: nowrap;
199
+ background: var(--dsw-alias-bg-module-platform);
200
+ color: var(--dsw-alias-label-secondary);
172
201
  border-radius: 999px;
173
- background: var(--dsw-alias-interactive-bg-hover-accent);
174
- color: var(--dsw-alias-state-business-primary);
202
+ padding: 1px 8px;
203
+ font-size: 11px;
204
+ font-weight: 500;
205
+ line-height: 17px;
175
206
  }
176
207
 
177
208
  .reset {
178
- font-size: 11px;
179
- border: 0;
180
- background: transparent;
181
- color: var(--dsw-alias-state-business-primary);
209
+ font: inherit;
210
+ color: var(--dsw-alias-label-secondary);
182
211
  cursor: pointer;
212
+ background: transparent;
213
+ border: none;
183
214
  padding: 0;
215
+ font-size: 12px;
216
+ line-height: 1.5;
184
217
  }
185
218
 
186
219
  .reset:hover:not(:disabled) {
187
- text-decoration: underline;
220
+ color: var(--dsw-alias-label-primary);
188
221
  }
189
222
 
190
- .reset:active:not(:disabled) {
191
- text-decoration: underline;
192
- opacity: 0.8;
223
+ .reset:disabled {
224
+ cursor: default;
193
225
  }
194
226
 
195
227
  .reset:focus-visible {
196
- outline: 2px solid var(--dsw-alias-state-business-primary);
197
- outline-offset: -2px;
198
- }
199
-
200
- .reset:disabled {
201
- opacity: 0.5;
202
- cursor: default;
228
+ outline: 2px solid var(--dsw-alias-brand-primary);
229
+ outline-offset: 2px;
203
230
  }
204
231
 
205
232
  .input,
206
233
  .select {
207
- padding: 6px 8px;
208
234
  border: 1px solid var(--dsw-alias-border-l2);
209
- border-radius: 6px;
235
+ background: var(--dsw-alias-bg-layer-3);
236
+ height: 34px;
210
237
  font: inherit;
211
- font-size: 13px;
212
238
  color: var(--dsw-alias-label-primary);
213
- background: var(--dsw-specific-input-major);
239
+ border-radius: 8px;
240
+ padding: 0 12px;
241
+ font-size: 13px;
242
+ line-height: 1.5;
214
243
  }
215
244
 
216
- .inputInvalid {
217
- padding: 6px 8px;
218
- border: 1px solid var(--dsw-alias-state-error-primary);
219
- border-radius: 6px;
220
- font: inherit;
221
- font-size: 13px;
222
- color: var(--dsw-alias-label-primary);
245
+ .input:focus-visible,
246
+ .select:focus-visible {
247
+ border-color: var(--dsw-alias-brand-primary);
248
+ outline: none;
223
249
  }
224
250
 
225
251
  .input:disabled,
226
252
  .select:disabled {
227
- opacity: 0.6;
253
+ color: var(--dsw-alias-label-tertiary);
254
+ cursor: default;
228
255
  }
229
256
 
230
- .hint {
231
- margin: 0;
232
- font-size: 12px;
233
- color: var(--dsw-alias-label-secondary);
257
+ .inputInvalid {
258
+ border: 1px solid var(--dsw-alias-label-error);
259
+ background: var(--dsw-alias-bg-layer-3);
260
+ height: 34px;
261
+ font: inherit;
262
+ color: var(--dsw-alias-label-primary);
263
+ border-radius: 8px;
264
+ padding: 0 12px;
265
+ font-size: 13px;
266
+ line-height: 1.5;
267
+ }
268
+
269
+ .inputInvalid:focus-visible {
270
+ outline: 2px solid var(--dsw-alias-label-error);
271
+ outline-offset: 1px;
272
+ border-color: var(--dsw-alias-label-error);
234
273
  }
235
274
 
236
275
  .invalid {
276
+ color: var(--dsw-alias-label-error);
237
277
  margin: 0;
238
278
  font-size: 12px;
239
- color: var(--dsw-alias-state-error-primary);
240
- }
241
-
242
- /* --- polish: header keyboard focus / press, unified motion, reduced-motion --- */
243
- .header:focus-visible {
244
- outline: 2px solid var(--dsw-alias-state-business-primary);
245
- outline-offset: -2px;
246
- }
247
-
248
- .header:active {
249
- background: var(--dsw-alias-interactive-bg-hover);
279
+ line-height: 1.5;
250
280
  }
251
281
 
252
- .header,
253
- .discard,
254
- .save,
255
- .reset {
256
- transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease, transform 120ms ease;
282
+ .hint {
283
+ color: var(--dsw-alias-label-tertiary);
284
+ margin: 0;
285
+ font-size: 12px;
286
+ line-height: 1.5;
257
287
  }
258
288
 
259
289
  @media (prefers-reduced-motion: reduce) {
260
290
  .card,
261
291
  .header,
262
292
  .chevron,
293
+ .chevronOpen,
263
294
  .discard,
264
- .save,
265
- .reset {
295
+ .save {
266
296
  transition: none;
267
297
  }
268
298
  }
@@ -94,6 +94,15 @@ export class BoardController {
94
94
  start(): void {
95
95
  this.tasks = this.deps.store.load()
96
96
  void this.reconcileRunningTasks()
97
+ // A sibling tab may have edited or deleted the ledger (same origin,
98
+ // storage events). Reload on external change so a task deleted in
99
+ // another tab stops firing here — and is never written back by this
100
+ // tab's stale copy (scheduler roll-forward, execution settlement).
101
+ const unsubscribeExternal = this.deps.store.subscribeExternal?.(() => {
102
+ this.tasks = this.deps.store.load()
103
+ this.notify()
104
+ })
105
+ if (unsubscribeExternal !== undefined) this.disposers.push(unsubscribeExternal)
97
106
  this.disposers.push(this.deps.sessions.list.subscribe(() => {
98
107
  this.onSessionsChanged()
99
108
  }))
@@ -329,7 +338,7 @@ export class BoardController {
329
338
  this.reconcileInFlight = true
330
339
  try {
331
340
  type Settled = Extract<ExecutionEvent, { kind: 'settled' }>
332
- const events: Array<{ task: TaskRecord; event: Settled }> = []
341
+ const events: Array<{ taskId: string; event: Settled }> = []
333
342
  for (const task of this.tasks) {
334
343
  if (task.status !== 'running') continue
335
344
  const execution = task.executions[task.executions.length - 1]
@@ -337,14 +346,20 @@ export class BoardController {
337
346
  // boundary); reconciliation exists for background/leftover runs.
338
347
  if (execution !== undefined && this.activeExecutionIds.has(execution.id)) continue
339
348
  const event = await this.deps.exec.reconcile(task)
340
- if (event !== undefined && event.kind === 'settled') events.push({ task, event })
349
+ if (event !== undefined && event.kind === 'settled') events.push({ taskId: task.id, event })
341
350
  }
342
351
  if (events.length === 0) return
343
352
  let changed = false
344
- for (const { task, event } of events) {
353
+ for (const { taskId, event } of events) {
354
+ // The reconcile call above awaited: a sibling tab may have rewritten
355
+ // the ledger (storage event reload) meanwhile. Re-read the freshest
356
+ // record now so the stale task captured before the await can never
357
+ // overwrite fields the sibling wrote.
358
+ const task = this.tasks.find(candidate => candidate.id === taskId)
359
+ if (task === undefined) continue
345
360
  const next = settleExecution(task, event.executionId, event.outcome, this.now(), event.error)
346
361
  if (next === task) continue
347
- this.tasks = this.tasks.map(candidate => candidate.id === task.id ? next : candidate)
362
+ this.tasks = this.tasks.map(candidate => candidate.id === taskId ? next : candidate)
348
363
  changed = true
349
364
  }
350
365
  if (changed) this.persistAndNotify()