@linxin666/dsh-client-ui-task-board 0.3.4 → 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.
package/lib/index.js CHANGED
@@ -1620,6 +1620,13 @@ function isErrorTurnEnd(data) {
1620
1620
  var HostExecutionRunner = class {
1621
1621
  api;
1622
1622
  commands;
1623
+ /**
1624
+ * Newest scanned event seq per session whose complete backward scan found
1625
+ * no matching turn/end. While a session's newest seq is unchanged a
1626
+ * re-scan cannot change the outcome, so only a one-message probe runs per
1627
+ * poll tick instead of up to 100 history pages.
1628
+ */
1629
+ scanMemos = /* @__PURE__ */ new Map();
1623
1630
  constructor(api, commands) {
1624
1631
  this.api = api;
1625
1632
  this.commands = commands;
@@ -1696,11 +1703,24 @@ var HostExecutionRunner = class {
1696
1703
  items = response.result.value.items;
1697
1704
  }
1698
1705
  const summary = items.find((item) => item.sessionId === sessionId);
1699
- if (summary === void 0) return {
1700
- outcome: "cancelled",
1701
- error: "execution session no longer exists"
1702
- };
1706
+ if (summary === void 0) {
1707
+ this.scanMemos.delete(sessionId);
1708
+ return {
1709
+ outcome: "cancelled",
1710
+ error: "execution session no longer exists"
1711
+ };
1712
+ }
1703
1713
  if (summary.running) return { outcome: "pending" };
1714
+ const probe = await this.api.sessions.history(request({
1715
+ sessionId: summary.sessionId,
1716
+ maxMessages: 1
1717
+ }));
1718
+ if (!probe.result.ok) return { outcome: "pending" };
1719
+ const newestSeq = probe.result.value.events.reduce((newest, entry) => {
1720
+ const seq = entry.event.seq;
1721
+ return typeof seq !== "number" ? newest : newest === void 0 ? seq : Math.max(newest, seq);
1722
+ }, void 0);
1723
+ if (newestSeq !== void 0 && this.scanMemos.get(sessionId) === newestSeq) return { outcome: "pending" };
1704
1724
  const events = [];
1705
1725
  let beforeSeq;
1706
1726
  let reachedExecutionBoundary = false;
@@ -1729,7 +1749,11 @@ var HostExecutionRunner = class {
1729
1749
  }
1730
1750
  if (!reachedExecutionBoundary) return { outcome: "pending" };
1731
1751
  const turnEnd = events.filter((entry) => entry.event.type === "turn/end" && (startedAt <= 0 || typeof entry.event.time === "number" && entry.event.time >= startedAt)).sort((a, b) => (a.event.seq ?? Number.MAX_SAFE_INTEGER) - (b.event.seq ?? Number.MAX_SAFE_INTEGER))[0];
1732
- if (turnEnd === void 0) return { outcome: "pending" };
1752
+ if (turnEnd === void 0) {
1753
+ if (newestSeq !== void 0) this.scanMemos.set(sessionId, newestSeq);
1754
+ return { outcome: "pending" };
1755
+ }
1756
+ this.scanMemos.delete(sessionId);
1733
1757
  return isErrorTurnEnd(turnEnd.event.data) ? {
1734
1758
  outcome: "failed",
1735
1759
  error: "agent turn ended with an error"
@@ -2,7 +2,6 @@ import type { TaskRecord } from '../../core/tasks.ts';
2
2
  /** Compact relative/absolute time label. */
3
3
  export declare function formatHostTimestamp(ms: number, timeZone?: string): string;
4
4
  export declare function formatTime(ms: number, timeZone?: string): string;
5
- /** One card in a column. */
6
5
  declare function TaskCardInner({ task, pending, timeZone, onClick }: {
7
6
  task: TaskRecord;
8
7
  pending: boolean;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Shared task-modal pieces: the overlay shell (backdrop, form, title, error,
3
+ * footer) and the title/description/prompt field trio used by both the
4
+ * NewTaskModal and the EditTaskModal. State stays in the owning modal; these
5
+ * are controlled components.
6
+ */
7
+ import type { ReactNode } from 'react';
8
+ /** Modal overlay: closes on backdrop press, submits through the form. */
9
+ export declare function ModalShell({ ariaLabel, title, error, pending, submitLabel, onSubmit, onClose, children, }: {
10
+ ariaLabel: string;
11
+ title: string;
12
+ error: string | undefined;
13
+ pending: boolean;
14
+ submitLabel: string;
15
+ onSubmit: () => void;
16
+ onClose: () => void;
17
+ children: ReactNode;
18
+ }): import("react").JSX.Element;
19
+ /** Title + description + prompt fields shared by the new and edit task forms. */
20
+ export declare function TaskContentFields({ title, description, prompt, onTitleChange, onDescriptionChange, onPromptChange, }: {
21
+ title: string;
22
+ description: string;
23
+ prompt: string;
24
+ /** Receives the new title; the owner also clears its error state. */
25
+ onTitleChange: (value: string) => void;
26
+ onDescriptionChange: (value: string) => void;
27
+ onPromptChange: (value: string) => void;
28
+ }): import("react").JSX.Element;
@@ -2,8 +2,8 @@
2
2
  * Board controller: the single owner of task-ledger state and view state.
3
3
  *
4
4
  * In production it projects the Host ledger and submits confirmed actions;
5
- * the legacy store seam remains for v1 migration tests. It also closes the
6
- * board whenever the user navigates to a session.
5
+ * the legacy store seam remains for v1 migration tests. The board closes
6
+ * only on explicit user navigation, never implicitly on session-list churn.
7
7
  * Framework-free (structural runtime faces) so the whole orchestration is
8
8
  * unit-testable with fakes.
9
9
  *
@@ -188,9 +188,17 @@ export declare class BoardController {
188
188
  runTask(id: string): Promise<boolean>;
189
189
  /** Re-run a settled task through the Host (the Host replans and executes). */
190
190
  rerunTask(id: string): Promise<void>;
191
- /** Close the board when the user navigates to another session. */
191
+ /**
192
+ * Session-list notifications fire for all kinds of incidental churn
193
+ * (background navigation, the Host runner creating and selecting a fresh
194
+ * execution session, settlement, other plugins), so closing on `current`
195
+ * changes would evict the board without the user asking. The board closes
196
+ * only on explicit user navigation: a sidebar session/workspace row click
197
+ * (board-mount onClickSidebarRow) or the board's own actions
198
+ * (openSession / close). Keeping the hook preserves the subscription
199
+ * contract for future listeners.
200
+ */
192
201
  private onSessionsChanged;
193
- private lastCurrent;
194
202
  private persistAndNotify;
195
203
  private commitRemote;
196
204
  private performRemote;
@@ -30,6 +30,13 @@ export declare class SessionLaunchError extends Error {
30
30
  export declare class HostExecutionRunner {
31
31
  private readonly api;
32
32
  private readonly commands?;
33
+ /**
34
+ * Newest scanned event seq per session whose complete backward scan found
35
+ * no matching turn/end. While a session's newest seq is unchanged a
36
+ * re-scan cannot change the outcome, so only a one-message probe runs per
37
+ * poll tick instead of up to 100 history pages.
38
+ */
39
+ private readonly scanMemos;
33
40
  constructor(api: ApiProxy, commands?: SessionCommandDispatcher | undefined);
34
41
  launch(task: TaskRecord): Promise<string>;
35
42
  listRunning(): Promise<{
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-client-ui-task-board",
3
3
  "description": "Host-authoritative task board for the DSH Web GUI with real session execution, Host cron scheduling, and optional cross-platform idle-sleep protection; mounted without DSH source changes.",
4
- "version": "0.3.4",
4
+ "version": "0.3.6",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -9,7 +9,7 @@ import { useState } from 'react'
9
9
  import type { BoardController } from '../../core/controller.ts'
10
10
  import type { TaskRecord } from '../../core/tasks.ts'
11
11
  import { t } from '../locales.ts'
12
- import css from '../board.module.css'
12
+ import { ModalShell, TaskContentFields } from './TaskForm.tsx'
13
13
 
14
14
  /** Edit-task form overlay. */
15
15
  export function EditTaskModal({ controller, task, onClose }: { controller: BoardController; task: TaskRecord; onClose: () => void }) {
@@ -36,59 +36,23 @@ export function EditTaskModal({ controller, task, onClose }: { controller: Board
36
36
  }
37
37
 
38
38
  return (
39
- <div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) onClose() }}>
40
- <form
41
- className={css.modal}
42
- role="dialog"
43
- aria-label={t('edit.title')}
44
- onSubmit={event => { event.preventDefault(); void submit() }}
45
- >
46
- <h2 className={css.modalTitle}>{t('edit.title')}</h2>
47
-
48
- <label className={css.field}>
49
- <span className={css.fieldLabel}>{t('new.title')}</span>
50
- <input
51
- className={css.input}
52
- value={title}
53
- autoFocus
54
- placeholder={t('new.titlePlaceholder')}
55
- onChange={event => { setTitle(event.target.value); setError(undefined) }}
56
- />
57
- </label>
58
-
59
- <label className={css.field}>
60
- <span className={css.fieldLabel}>{t('new.description')}</span>
61
- <textarea
62
- className={css.input}
63
- rows={3}
64
- value={description}
65
- placeholder={t('new.descriptionPlaceholder')}
66
- onChange={event => { setDescription(event.target.value) }}
67
- />
68
- </label>
69
-
70
- <label className={css.field}>
71
- <span className={css.fieldLabel}>{t('new.prompt')}</span>
72
- <textarea
73
- className={css.input}
74
- rows={4}
75
- value={prompt}
76
- placeholder={t('new.promptPlaceholder')}
77
- onChange={event => { setPrompt(event.target.value) }}
78
- />
79
- </label>
80
-
81
- {error !== undefined && <p className={css.formError}>{error}</p>}
82
-
83
- <footer className={css.modalFooter}>
84
- <button type="button" className={css.ghostButton} onClick={onClose}>
85
- {t('new.cancel')}
86
- </button>
87
- <button type="submit" className={css.primaryButton} disabled={pending}>
88
- {t('edit.save')}
89
- </button>
90
- </footer>
91
- </form>
92
- </div>
39
+ <ModalShell
40
+ ariaLabel={t('edit.title')}
41
+ title={t('edit.title')}
42
+ error={error}
43
+ pending={pending}
44
+ submitLabel={t('edit.save')}
45
+ onSubmit={() => { void submit() }}
46
+ onClose={onClose}
47
+ >
48
+ <TaskContentFields
49
+ title={title}
50
+ description={description}
51
+ prompt={prompt}
52
+ onTitleChange={value => { setTitle(value); setError(undefined) }}
53
+ onDescriptionChange={setDescription}
54
+ onPromptChange={setPrompt}
55
+ />
56
+ </ModalShell>
93
57
  )
94
58
  }
@@ -8,6 +8,7 @@ import { isValidCron, nextRunAtMs } from '../../core/schedule.ts'
8
8
  import { TASK_PERMISSIONS, type TaskPermission } from '../../core/tasks.ts'
9
9
  import { t, type TaskBoardKey } from '../locales.ts'
10
10
  import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
11
+ import { ModalShell, TaskContentFields } from './TaskForm.tsx'
11
12
  import css from '../board.module.css'
12
13
 
13
14
  /** New-task form overlay. */
@@ -64,47 +65,23 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
64
65
  : undefined
65
66
 
66
67
  return (
67
- <div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) onClose() }}>
68
- <form
69
- className={css.modal}
70
- role="dialog"
71
- aria-label={t('board.new')}
72
- onSubmit={event => { event.preventDefault(); void submit() }}
73
- >
74
- <h2 className={css.modalTitle}>{t('board.new')}</h2>
75
-
76
- <label className={css.field}>
77
- <span className={css.fieldLabel}>{t('new.title')}</span>
78
- <input
79
- className={css.input}
80
- value={title}
81
- autoFocus
82
- placeholder={t('new.titlePlaceholder')}
83
- onChange={event => { setTitle(event.target.value); setError(undefined) }}
84
- />
85
- </label>
86
-
87
- <label className={css.field}>
88
- <span className={css.fieldLabel}>{t('new.description')}</span>
89
- <textarea
90
- className={css.input}
91
- rows={3}
92
- value={description}
93
- placeholder={t('new.descriptionPlaceholder')}
94
- onChange={event => { setDescription(event.target.value) }}
95
- />
96
- </label>
97
-
98
- <label className={css.field}>
99
- <span className={css.fieldLabel}>{t('new.prompt')}</span>
100
- <textarea
101
- className={css.input}
102
- rows={4}
103
- value={prompt}
104
- placeholder={t('new.promptPlaceholder')}
105
- onChange={event => { setPrompt(event.target.value) }}
106
- />
107
- </label>
68
+ <ModalShell
69
+ ariaLabel={t('board.new')}
70
+ title={t('board.new')}
71
+ error={error}
72
+ pending={pending}
73
+ submitLabel={t('new.submit')}
74
+ onSubmit={() => { void submit() }}
75
+ onClose={onClose}
76
+ >
77
+ <TaskContentFields
78
+ title={title}
79
+ description={description}
80
+ prompt={prompt}
81
+ onTitleChange={value => { setTitle(value); setError(undefined) }}
82
+ onDescriptionChange={setDescription}
83
+ onPromptChange={setPrompt}
84
+ />
108
85
 
109
86
  <label className={css.field}>
110
87
  <span className={css.fieldLabel}>{t('new.workspace')}</span>
@@ -201,18 +178,6 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
201
178
  </>
202
179
  )}
203
180
  </section>
204
-
205
- {error !== undefined && <p className={css.formError}>{error}</p>}
206
-
207
- <footer className={css.modalFooter}>
208
- <button type="button" className={css.ghostButton} onClick={onClose}>
209
- {t('new.cancel')}
210
- </button>
211
- <button type="submit" className={css.primaryButton} disabled={pending}>
212
- {t('new.submit')}
213
- </button>
214
- </footer>
215
- </form>
216
- </div>
181
+ </ModalShell>
217
182
  )
218
183
  }
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { memo, useCallback, useEffect, useState } from 'react'
7
7
  import { selectedTaskOf, type BoardController } from '../../core/controller.ts'
8
- import { COLUMNS, type TaskRecord } from '../../core/tasks.ts'
8
+ import { COLUMNS, canMoveManually, type TaskRecord } from '../../core/tasks.ts'
9
9
  import { t } from '../locales.ts'
10
10
  import css from '../board.module.css'
11
11
  import { NewTaskModal } from './NewTaskModal.tsx'
@@ -124,8 +124,27 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
124
124
  ) : (
125
125
  COLUMNS.map(column => {
126
126
  const tasks = visible.filter(task => task.status === column.status)
127
+ const isManualDropTarget = column.status === 'backlog' || column.status === 'todo'
127
128
  return (
128
- <section key={column.status} className={css.column} data-status={column.status} data-dsh-part="column">
129
+ <section
130
+ key={column.status}
131
+ className={css.column}
132
+ data-status={column.status}
133
+ data-dsh-part="column"
134
+ onDragOver={isManualDropTarget ? (event) => {
135
+ event.preventDefault()
136
+ event.dataTransfer.dropEffect = 'move'
137
+ } : undefined}
138
+ onDrop={isManualDropTarget ? (event) => {
139
+ event.preventDefault()
140
+ const taskId = event.dataTransfer.getData('text/plain')
141
+ if (!taskId) return
142
+ const dropped = snapshot.tasks.find(t => t.id === taskId)
143
+ if (dropped && canMoveManually(dropped.status, column.status) && dropped.status !== column.status) {
144
+ controller.moveTask(taskId, column.status)
145
+ }
146
+ } : undefined}
147
+ >
129
148
  <header className={css.columnHeader}>
130
149
  <span className={css.statusDot} data-status={column.status} aria-hidden="true" />
131
150
  <h3 className={css.columnTitle}>{t(STATUS_KEY[column.status])}</h3>
@@ -37,11 +37,12 @@ export function formatTime(ms: number, timeZone?: string): string {
37
37
  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
38
38
  }
39
39
 
40
- /** One card in a column. */
41
40
  function TaskCardInner({ task, pending, timeZone, onClick }: { task: TaskRecord; pending: boolean; timeZone?: string; onClick: () => void }) {
42
41
  const latest = task.executions[task.executions.length - 1]
43
42
  const runs = task.executions.length
44
43
  const archived = task.archivedAt !== undefined
44
+ const isDraggable = !archived && task.status !== 'running' && !pending
45
+
45
46
  return (
46
47
  <button
47
48
  type="button"
@@ -49,6 +50,11 @@ function TaskCardInner({ task, pending, timeZone, onClick }: { task: TaskRecord;
49
50
  data-status={archived ? 'archived' : task.status}
50
51
  data-dsh-part="card"
51
52
  data-pending={pending || undefined}
53
+ draggable={isDraggable}
54
+ onDragStart={isDraggable ? (event) => {
55
+ event.dataTransfer.setData('text/plain', task.id)
56
+ event.dataTransfer.effectAllowed = 'move'
57
+ } : undefined}
52
58
  onClick={onClick}
53
59
  title={task.description !== '' ? task.description : task.title}
54
60
  >
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Shared task-modal pieces: the overlay shell (backdrop, form, title, error,
3
+ * footer) and the title/description/prompt field trio used by both the
4
+ * NewTaskModal and the EditTaskModal. State stays in the owning modal; these
5
+ * are controlled components.
6
+ */
7
+ import type { ReactNode } from 'react'
8
+ import { t } from '../locales.ts'
9
+ import css from '../board.module.css'
10
+
11
+ /** Modal overlay: closes on backdrop press, submits through the form. */
12
+ export function ModalShell({
13
+ ariaLabel,
14
+ title,
15
+ error,
16
+ pending,
17
+ submitLabel,
18
+ onSubmit,
19
+ onClose,
20
+ children,
21
+ }: {
22
+ ariaLabel: string
23
+ title: string
24
+ error: string | undefined
25
+ pending: boolean
26
+ submitLabel: string
27
+ onSubmit: () => void
28
+ onClose: () => void
29
+ children: ReactNode
30
+ }) {
31
+ return (
32
+ <div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) onClose() }}>
33
+ <form
34
+ className={css.modal}
35
+ role="dialog"
36
+ aria-label={ariaLabel}
37
+ onSubmit={event => { event.preventDefault(); onSubmit() }}
38
+ >
39
+ <h2 className={css.modalTitle}>{title}</h2>
40
+
41
+ {children}
42
+
43
+ {error !== undefined && <p className={css.formError}>{error}</p>}
44
+
45
+ <footer className={css.modalFooter}>
46
+ <button type="button" className={css.ghostButton} onClick={onClose}>
47
+ {t('new.cancel')}
48
+ </button>
49
+ <button type="submit" className={css.primaryButton} disabled={pending}>
50
+ {submitLabel}
51
+ </button>
52
+ </footer>
53
+ </form>
54
+ </div>
55
+ )
56
+ }
57
+
58
+ /** Title + description + prompt fields shared by the new and edit task forms. */
59
+ export function TaskContentFields({
60
+ title,
61
+ description,
62
+ prompt,
63
+ onTitleChange,
64
+ onDescriptionChange,
65
+ onPromptChange,
66
+ }: {
67
+ title: string
68
+ description: string
69
+ prompt: string
70
+ /** Receives the new title; the owner also clears its error state. */
71
+ onTitleChange: (value: string) => void
72
+ onDescriptionChange: (value: string) => void
73
+ onPromptChange: (value: string) => void
74
+ }) {
75
+ return (
76
+ <>
77
+ <label className={css.field}>
78
+ <span className={css.fieldLabel}>{t('new.title')}</span>
79
+ <input
80
+ className={css.input}
81
+ value={title}
82
+ autoFocus
83
+ placeholder={t('new.titlePlaceholder')}
84
+ onChange={event => onTitleChange(event.target.value)}
85
+ />
86
+ </label>
87
+
88
+ <label className={css.field}>
89
+ <span className={css.fieldLabel}>{t('new.description')}</span>
90
+ <textarea
91
+ className={css.input}
92
+ rows={3}
93
+ value={description}
94
+ placeholder={t('new.descriptionPlaceholder')}
95
+ onChange={event => onDescriptionChange(event.target.value)}
96
+ />
97
+ </label>
98
+
99
+ <label className={css.field}>
100
+ <span className={css.fieldLabel}>{t('new.prompt')}</span>
101
+ <textarea
102
+ className={css.input}
103
+ rows={4}
104
+ value={prompt}
105
+ placeholder={t('new.promptPlaceholder')}
106
+ onChange={event => onPromptChange(event.target.value)}
107
+ />
108
+ </label>
109
+ </>
110
+ )
111
+ }
@@ -44,7 +44,13 @@ export function mountBoard(controller: BoardController): () => void {
44
44
  let container: HTMLDivElement | undefined
45
45
 
46
46
  const ensure = (): void => {
47
- if (container !== undefined) return
47
+ if (container !== undefined) {
48
+ if (container.isConnected) return
49
+ root?.unmount()
50
+ root = undefined
51
+ container.remove()
52
+ container = undefined
53
+ }
48
54
  const column = conversationColumn()
49
55
  if (column === undefined) return
50
56
  container = document.createElement('div')
@@ -2,8 +2,8 @@
2
2
  * Board controller: the single owner of task-ledger state and view state.
3
3
  *
4
4
  * In production it projects the Host ledger and submits confirmed actions;
5
- * the legacy store seam remains for v1 migration tests. It also closes the
6
- * board whenever the user navigates to a session.
5
+ * the legacy store seam remains for v1 migration tests. The board closes
6
+ * only on explicit user navigation, never implicitly on session-list churn.
7
7
  * Framework-free (structural runtime faces) so the whole orchestration is
8
8
  * unit-testable with fakes.
9
9
  *
@@ -108,11 +108,6 @@ function randomUuid(): string {
108
108
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
109
109
  }
110
110
 
111
- /** Read the current selection off a session-list snapshot (structural). */
112
- function currentOf(sessions: SessionsControllerFace): string | undefined {
113
- return sessions.list.getSnapshot().current
114
- }
115
-
116
111
  function messageOf(error: unknown): string {
117
112
  return error instanceof Error ? error.message : String(error)
118
113
  }
@@ -205,16 +200,11 @@ export class BoardController {
205
200
 
206
201
  openBoard(): void {
207
202
  if (this.boardOpen) return
208
- // Baseline the selection the board opened against: the board stays open
209
- // until the user navigates (selection changes), never on mere status
210
- // updates of the already-selected session.
211
- this.lastCurrent = currentOf(this.deps.sessions)
212
203
  this.boardOpen = true
213
204
  this.notify()
214
205
  }
215
206
 
216
207
  closeBoard(): void {
217
- if (!this.boardOpen) return
218
208
  this.boardOpen = false
219
209
  this.notify()
220
210
  }
@@ -400,6 +390,7 @@ export class BoardController {
400
390
  * @param sessionId - the execution session to open.
401
391
  */
402
392
  openSession(sessionId: string): void {
393
+ this.closeBoard()
403
394
  this.deps.sessions.open(sessionId)
404
395
  }
405
396
 
@@ -428,16 +419,20 @@ export class BoardController {
428
419
 
429
420
  // --- internals ---------------------------------------------------------------
430
421
 
431
- /** Close the board when the user navigates to another session. */
422
+ /**
423
+ * Session-list notifications fire for all kinds of incidental churn
424
+ * (background navigation, the Host runner creating and selecting a fresh
425
+ * execution session, settlement, other plugins), so closing on `current`
426
+ * changes would evict the board without the user asking. The board closes
427
+ * only on explicit user navigation: a sidebar session/workspace row click
428
+ * (board-mount onClickSidebarRow) or the board's own actions
429
+ * (openSession / close). Keeping the hook preserves the subscription
430
+ * contract for future listeners.
431
+ */
432
432
  private onSessionsChanged(): void {
433
- if (!this.boardOpen) return
434
- const current = currentOf(this.deps.sessions)
435
- if (current !== this.lastCurrent) this.closeBoard()
436
- this.lastCurrent = current
433
+ // Intentionally empty: never close the board implicitly.
437
434
  }
438
435
 
439
- private lastCurrent: string | undefined = undefined
440
-
441
436
  private persistAndNotify(): void {
442
437
  if (this.deps.transport === undefined) this.deps.store.save(this.tasks)
443
438
  this.notify()
@@ -50,6 +50,14 @@ function isErrorTurnEnd(data: unknown): boolean {
50
50
  }
51
51
 
52
52
  export class HostExecutionRunner {
53
+ /**
54
+ * Newest scanned event seq per session whose complete backward scan found
55
+ * no matching turn/end. While a session's newest seq is unchanged a
56
+ * re-scan cannot change the outcome, so only a one-message probe runs per
57
+ * poll tick instead of up to 100 history pages.
58
+ */
59
+ private readonly scanMemos = new Map<string, number>()
60
+
53
61
  constructor(
54
62
  private readonly api: ApiProxy,
55
63
  private readonly commands?: SessionCommandDispatcher,
@@ -124,8 +132,21 @@ export class HostExecutionRunner {
124
132
  items = response.result.value.items
125
133
  }
126
134
  const summary = items.find(item => item.sessionId === sessionId)
127
- if (summary === undefined) return { outcome: 'cancelled', error: 'execution session no longer exists' }
135
+ if (summary === undefined) {
136
+ this.scanMemos.delete(sessionId)
137
+ return { outcome: 'cancelled', error: 'execution session no longer exists' }
138
+ }
128
139
  if (summary.running) return { outcome: 'pending' }
140
+ // Probe the newest event before paging: SessionSummary.updatedAt tracks
141
+ // human prompts, not event appends, so only the history head proves
142
+ // whether a re-scan could find anything new.
143
+ const probe = await this.api.sessions.history(request({ sessionId: summary.sessionId, maxMessages: 1 }))
144
+ if (!probe.result.ok) return { outcome: 'pending' }
145
+ const newestSeq = probe.result.value.events.reduce<number | undefined>((newest, entry) => {
146
+ const seq = entry.event.seq
147
+ return typeof seq !== 'number' ? newest : newest === undefined ? seq : Math.max(newest, seq)
148
+ }, undefined)
149
+ if (newestSeq !== undefined && this.scanMemos.get(sessionId) === newestSeq) return { outcome: 'pending' }
129
150
  const events: Array<{ event: { type: string; seq?: number; time?: number; data: unknown } }> = []
130
151
  let beforeSeq: number | undefined
131
152
  let reachedExecutionBoundary = false
@@ -158,7 +179,12 @@ export class HostExecutionRunner {
158
179
  startedAt <= 0 || (typeof entry.event.time === 'number' && entry.event.time >= startedAt)
159
180
  ))
160
181
  .sort((a, b) => (a.event.seq ?? Number.MAX_SAFE_INTEGER) - (b.event.seq ?? Number.MAX_SAFE_INTEGER))[0]
161
- if (turnEnd === undefined) return { outcome: 'pending' }
182
+ if (turnEnd === undefined) {
183
+ // Complete scan, no match: remember the head so later ticks probe only.
184
+ if (newestSeq !== undefined) this.scanMemos.set(sessionId, newestSeq)
185
+ return { outcome: 'pending' }
186
+ }
187
+ this.scanMemos.delete(sessionId)
162
188
  return isErrorTurnEnd(turnEnd.event.data)
163
189
  ? { outcome: 'failed', error: 'agent turn ended with an error' }
164
190
  : { outcome: 'succeeded' }