@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/client.js +293 -303
- package/lib/client.js.map +1 -1
- package/lib/index.js +29 -5
- package/lib/types/client/board/TaskCard.d.ts +0 -1
- package/lib/types/client/board/TaskForm.d.ts +28 -0
- package/lib/types/core/controller.d.ts +12 -4
- package/lib/types/host-runner.d.ts +7 -0
- package/package.json +1 -1
- package/src/client/board/EditTaskModal.tsx +19 -55
- package/src/client/board/NewTaskModal.tsx +19 -54
- package/src/client/board/TaskBoard.tsx +21 -2
- package/src/client/board/TaskCard.tsx +7 -1
- package/src/client/board/TaskForm.tsx +111 -0
- package/src/client/board-mount.tsx +7 -1
- package/src/core/controller.ts +14 -19
- package/src/host-runner.ts +28 -2
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)
|
|
1700
|
-
|
|
1701
|
-
|
|
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)
|
|
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.
|
|
6
|
-
*
|
|
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
|
-
/**
|
|
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
|
+
"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
|
|
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
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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)
|
|
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')
|
package/src/core/controller.ts
CHANGED
|
@@ -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.
|
|
6
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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()
|
package/src/host-runner.ts
CHANGED
|
@@ -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)
|
|
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)
|
|
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' }
|