bosun 0.41.2 → 0.41.3
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/.env.example +1 -1
- package/agent/agent-prompt-catalog.mjs +971 -0
- package/agent/agent-prompts.mjs +2 -970
- package/agent/agent-supervisor.mjs +6 -3
- package/agent/autofix-git.mjs +33 -0
- package/agent/autofix-prompts.mjs +151 -0
- package/agent/autofix.mjs +11 -175
- package/agent/bosun-skills.mjs +3 -2
- package/bosun.config.example.json +17 -0
- package/bosun.schema.json +87 -188
- package/cli.mjs +34 -1
- package/config/config-doctor.mjs +5 -250
- package/config/config-file-names.mjs +5 -0
- package/config/config.mjs +89 -493
- package/config/executor-config.mjs +493 -0
- package/config/repo-root.mjs +1 -2
- package/config/workspace-health.mjs +242 -0
- package/git/git-safety.mjs +15 -0
- package/github/github-oauth-portal.mjs +46 -0
- package/infra/library-manager-utils.mjs +22 -0
- package/infra/library-manager-well-known-sources.mjs +578 -0
- package/infra/library-manager.mjs +512 -1030
- package/infra/monitor.mjs +28 -9
- package/infra/session-tracker.mjs +10 -7
- package/kanban/kanban-adapter.mjs +17 -1
- package/lib/codebase-audit-manifests.mjs +117 -0
- package/lib/codebase-audit.mjs +18 -115
- package/package.json +18 -3
- package/server/ui-server.mjs +1194 -79
- package/shell/codex-config-file.mjs +178 -0
- package/shell/codex-config.mjs +538 -575
- package/task/task-cli.mjs +54 -3
- package/task/task-executor.mjs +143 -13
- package/task/task-store.mjs +409 -1
- package/telegram/telegram-bot.mjs +127 -0
- package/tools/apply-pr-suggestions.mjs +401 -0
- package/tools/syntax-check.mjs +21 -9
- package/ui/app.js +3 -14
- package/ui/components/kanban-board.js +227 -4
- package/ui/components/session-list.js +85 -5
- package/ui/demo-defaults.js +334 -80
- package/ui/demo.html +155 -0
- package/ui/modules/session-api.js +96 -0
- package/ui/modules/settings-schema.js +1 -2
- package/ui/modules/state.js +21 -3
- package/ui/setup.html +4 -5
- package/ui/styles/components.css +58 -4
- package/ui/tabs/agents.js +12 -15
- package/ui/tabs/control.js +1 -0
- package/ui/tabs/library.js +484 -22
- package/ui/tabs/manual-flows.js +105 -29
- package/ui/tabs/tasks.js +785 -140
- package/ui/tabs/telemetry.js +129 -11
- package/ui/tabs/workflow-canvas-utils.mjs +130 -0
- package/ui/tabs/workflows.js +293 -23
- package/voice/voice-tool-definitions.mjs +757 -0
- package/voice/voice-tools.mjs +34 -778
- package/workflow/manual-flow-audit.mjs +165 -0
- package/workflow/manual-flows.mjs +164 -259
- package/workflow/workflow-engine.mjs +147 -58
- package/workflow/workflow-nodes/definitions.mjs +1207 -0
- package/workflow/workflow-nodes/transforms.mjs +612 -0
- package/workflow/workflow-nodes.mjs +304 -52
- package/workflow/workflow-templates.mjs +313 -191
- package/workflow-templates/_helpers.mjs +154 -0
- package/workflow-templates/agents.mjs +61 -4
- package/workflow-templates/code-quality.mjs +7 -7
- package/workflow-templates/github.mjs +20 -10
- package/workflow-templates/task-batch.mjs +20 -9
- package/workflow-templates/task-lifecycle.mjs +31 -6
- package/workspace/worktree-manager.mjs +277 -3
|
@@ -28,6 +28,7 @@ const html = htm.bind(h);
|
|
|
28
28
|
const COLUMN_MAP = {
|
|
29
29
|
draft: ["draft"],
|
|
30
30
|
backlog: ["backlog", "open", "new", "todo"],
|
|
31
|
+
blocked: ["blocked", "error", "failed"],
|
|
31
32
|
inProgress: ["in-progress", "inprogress", "working", "active", "assigned"],
|
|
32
33
|
inReview: ["in-review", "inreview", "review", "pr-open", "pr-review"],
|
|
33
34
|
done: ["done", "completed", "closed", "merged", "cancelled"],
|
|
@@ -36,6 +37,7 @@ const COLUMN_MAP = {
|
|
|
36
37
|
const COLUMNS = [
|
|
37
38
|
{ id: "draft", title: "Drafts", icon: "\u{1F4DD}", color: "var(--color-warning, #f59e0b)" },
|
|
38
39
|
{ id: "backlog", title: "Backlog", icon: "\u{1F4CB}", color: "var(--text-secondary)" },
|
|
40
|
+
{ id: "blocked", title: "Blocked", icon: "\u26D4", color: "var(--color-error, #ef4444)" },
|
|
39
41
|
{ id: "inProgress", title: "In Progress", icon: "\u{1F528}", color: "var(--color-inprogress, #3b82f6)" },
|
|
40
42
|
{ id: "inReview", title: "In Review", icon: "\u{1F440}", color: "var(--color-inreview, #f59e0b)" },
|
|
41
43
|
{ id: "done", title: "Done", icon: "\u2705", color: "var(--color-done, #22c55e)" },
|
|
@@ -44,6 +46,7 @@ const COLUMNS = [
|
|
|
44
46
|
const COLUMN_TO_STATUS = {
|
|
45
47
|
draft: "draft",
|
|
46
48
|
backlog: "todo",
|
|
49
|
+
blocked: "blocked",
|
|
47
50
|
inProgress: "inprogress",
|
|
48
51
|
inReview: "inreview",
|
|
49
52
|
done: "done",
|
|
@@ -65,11 +68,158 @@ const PRIORITY_LABELS = {
|
|
|
65
68
|
|
|
66
69
|
const LOAD_MORE_THRESHOLD_PX = 140;
|
|
67
70
|
const AUTO_LOAD_MAX_TASKS = 300;
|
|
71
|
+
const KANBAN_BOARD_FILTER_SCHEMA_VERSION = 2;
|
|
72
|
+
const KANBAN_BOARD_FILTER_STORAGE_PREFIX = "ve-kanban-board-filters";
|
|
73
|
+
const KANBAN_BOARD_FILTER_LEGACY_KEY = "ve-kanban-board-filters";
|
|
74
|
+
const KANBAN_BOARD_GLOBAL_SCOPE = "global";
|
|
75
|
+
const DEFAULT_BOARD_FILTERS = Object.freeze({ repo: "", assignee: "", priority: "", search: "" });
|
|
76
|
+
const BOARD_FILTER_KEYS = ["repo", "assignee", "priority", "search"];
|
|
77
|
+
const ALLOWED_PRIORITIES = new Set(["critical", "high", "medium", "low"]);
|
|
78
|
+
const MAX_FILTER_SEARCH_LENGTH = 120;
|
|
68
79
|
|
|
69
80
|
function matchTaskId(a, b) {
|
|
70
81
|
return String(a) === String(b);
|
|
71
82
|
}
|
|
72
83
|
|
|
84
|
+
function getBoardFilterStorage(storage) {
|
|
85
|
+
if (storage && typeof storage.getItem === "function" && typeof storage.setItem === "function") return storage;
|
|
86
|
+
if (typeof localStorage === "undefined") return null;
|
|
87
|
+
return localStorage;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function normalizeBoardWorkspaceScope(workspaceId) {
|
|
91
|
+
const raw = String(workspaceId || "").trim();
|
|
92
|
+
return raw || KANBAN_BOARD_GLOBAL_SCOPE;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function buildBoardFilterStorageKey(workspaceId) {
|
|
96
|
+
return `${KANBAN_BOARD_FILTER_STORAGE_PREFIX}:${normalizeBoardWorkspaceScope(workspaceId)}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function trimFilterValue(value) {
|
|
100
|
+
return typeof value === "string" ? value.trim() : "";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function sanitizeBoardFilters(filters, options = {}) {
|
|
104
|
+
const raw = filters && typeof filters === "object" ? filters : {};
|
|
105
|
+
const result = { ...DEFAULT_BOARD_FILTERS };
|
|
106
|
+
for (const key of BOARD_FILTER_KEYS) {
|
|
107
|
+
result[key] = trimFilterValue(raw[key]);
|
|
108
|
+
}
|
|
109
|
+
result.search = result.search.slice(0, MAX_FILTER_SEARCH_LENGTH);
|
|
110
|
+
|
|
111
|
+
if (!ALLOWED_PRIORITIES.has(result.priority)) result.priority = "";
|
|
112
|
+
|
|
113
|
+
const repoSet = options?.allowedRepos instanceof Set ? options.allowedRepos : null;
|
|
114
|
+
const assigneeSet = options?.allowedAssignees instanceof Set ? options.allowedAssignees : null;
|
|
115
|
+
if (repoSet && repoSet.size > 0 && result.repo && !repoSet.has(result.repo)) result.repo = "";
|
|
116
|
+
if (assigneeSet && assigneeSet.size > 0 && result.assignee && !assigneeSet.has(result.assignee)) result.assignee = "";
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function areBoardFiltersEqual(a, b) {
|
|
121
|
+
return (
|
|
122
|
+
String(a?.repo || "") === String(b?.repo || "") &&
|
|
123
|
+
String(a?.assignee || "") === String(b?.assignee || "") &&
|
|
124
|
+
String(a?.priority || "") === String(b?.priority || "") &&
|
|
125
|
+
String(a?.search || "") === String(b?.search || "")
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function readRawPersistedBoardFilters(storage, key, workspaceScope) {
|
|
130
|
+
const raw = storage?.getItem?.(key);
|
|
131
|
+
if (!raw) return { filters: null, invalid: false, exists: false };
|
|
132
|
+
try {
|
|
133
|
+
const parsed = JSON.parse(raw);
|
|
134
|
+
if (!parsed || typeof parsed !== "object") return { filters: null, invalid: true, exists: true };
|
|
135
|
+
if (parsed.version !== KANBAN_BOARD_FILTER_SCHEMA_VERSION) return { filters: null, invalid: true, exists: true };
|
|
136
|
+
const payloadWorkspace = normalizeBoardWorkspaceScope(parsed.workspace);
|
|
137
|
+
if (payloadWorkspace !== normalizeBoardWorkspaceScope(workspaceScope)) {
|
|
138
|
+
return { filters: null, invalid: true, exists: true };
|
|
139
|
+
}
|
|
140
|
+
if (!parsed.filters || typeof parsed.filters !== "object") {
|
|
141
|
+
return { filters: null, invalid: true, exists: true };
|
|
142
|
+
}
|
|
143
|
+
return { filters: parsed.filters, invalid: false, exists: true };
|
|
144
|
+
} catch {
|
|
145
|
+
return { filters: null, invalid: true, exists: true };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function shouldRewritePersistedBoardFilters(rawFilters, sanitized) {
|
|
150
|
+
if (!rawFilters || typeof rawFilters !== "object") return true;
|
|
151
|
+
for (const key of BOARD_FILTER_KEYS) {
|
|
152
|
+
if (trimFilterValue(rawFilters[key]) !== sanitized[key]) return true;
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function readPersistedBoardFilters({ storage, workspaceId, validateWith } = {}) {
|
|
158
|
+
const resolvedStorage = getBoardFilterStorage(storage);
|
|
159
|
+
if (!resolvedStorage) return { ...DEFAULT_BOARD_FILTERS };
|
|
160
|
+
|
|
161
|
+
const workspaceScope = normalizeBoardWorkspaceScope(workspaceId);
|
|
162
|
+
const scopedKey = buildBoardFilterStorageKey(workspaceScope);
|
|
163
|
+
const scopedResult = readRawPersistedBoardFilters(resolvedStorage, scopedKey, workspaceScope);
|
|
164
|
+
if (scopedResult.invalid) {
|
|
165
|
+
persistBoardFilters({
|
|
166
|
+
storage: resolvedStorage,
|
|
167
|
+
workspaceId: workspaceScope,
|
|
168
|
+
filters: DEFAULT_BOARD_FILTERS,
|
|
169
|
+
});
|
|
170
|
+
return { ...DEFAULT_BOARD_FILTERS };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let rawFilters = scopedResult.filters;
|
|
174
|
+
if (!rawFilters && workspaceScope === KANBAN_BOARD_GLOBAL_SCOPE) {
|
|
175
|
+
const legacyResult = readRawPersistedBoardFilters(
|
|
176
|
+
resolvedStorage,
|
|
177
|
+
KANBAN_BOARD_FILTER_LEGACY_KEY,
|
|
178
|
+
workspaceScope,
|
|
179
|
+
);
|
|
180
|
+
rawFilters = legacyResult.filters;
|
|
181
|
+
if (rawFilters) {
|
|
182
|
+
persistBoardFilters({
|
|
183
|
+
storage: resolvedStorage,
|
|
184
|
+
workspaceId: workspaceScope,
|
|
185
|
+
filters: rawFilters,
|
|
186
|
+
});
|
|
187
|
+
} else if (legacyResult.invalid) {
|
|
188
|
+
resolvedStorage?.removeItem?.(KANBAN_BOARD_FILTER_LEGACY_KEY);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const sanitized = sanitizeBoardFilters(rawFilters || DEFAULT_BOARD_FILTERS, validateWith);
|
|
193
|
+
if (
|
|
194
|
+
rawFilters &&
|
|
195
|
+
shouldRewritePersistedBoardFilters(rawFilters, sanitized)
|
|
196
|
+
) {
|
|
197
|
+
persistBoardFilters({
|
|
198
|
+
storage: resolvedStorage,
|
|
199
|
+
workspaceId: workspaceScope,
|
|
200
|
+
filters: sanitized,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return sanitized;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function persistBoardFilters({ storage, workspaceId, filters } = {}) {
|
|
207
|
+
const resolvedStorage = getBoardFilterStorage(storage);
|
|
208
|
+
if (!resolvedStorage) return false;
|
|
209
|
+
const workspaceScope = normalizeBoardWorkspaceScope(workspaceId);
|
|
210
|
+
const payload = {
|
|
211
|
+
version: KANBAN_BOARD_FILTER_SCHEMA_VERSION,
|
|
212
|
+
workspace: workspaceScope,
|
|
213
|
+
filters: sanitizeBoardFilters(filters),
|
|
214
|
+
};
|
|
215
|
+
try {
|
|
216
|
+
resolvedStorage.setItem(buildBoardFilterStorageKey(workspaceScope), JSON.stringify(payload));
|
|
217
|
+
return true;
|
|
218
|
+
} catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
73
223
|
function getColumnForStatus(status) {
|
|
74
224
|
const s = (status || "").toLowerCase();
|
|
75
225
|
for (const [col, statuses] of Object.entries(COLUMN_MAP)) {
|
|
@@ -103,6 +253,29 @@ function getTaskRuntimeSnapshot(task) {
|
|
|
103
253
|
return task?.runtimeSnapshot || task?.meta?.runtimeSnapshot || null;
|
|
104
254
|
}
|
|
105
255
|
|
|
256
|
+
function getTaskBlockedPreview(task) {
|
|
257
|
+
const direct = String(
|
|
258
|
+
task?.blockedReason ||
|
|
259
|
+
task?.meta?.worktreeFailure?.blockedReason ||
|
|
260
|
+
task?.meta?.blockedContext?.summary ||
|
|
261
|
+
"",
|
|
262
|
+
).trim();
|
|
263
|
+
if (direct) return direct;
|
|
264
|
+
const timeline = Array.isArray(task?.timeline) ? task.timeline : [];
|
|
265
|
+
for (let index = timeline.length - 1; index >= 0; index -= 1) {
|
|
266
|
+
const message = String(
|
|
267
|
+
timeline[index]?.message ||
|
|
268
|
+
timeline[index]?.reason ||
|
|
269
|
+
timeline[index]?.error ||
|
|
270
|
+
"",
|
|
271
|
+
).trim();
|
|
272
|
+
if (/worktree failed|pre-pr validation failed|blocked/i.test(message)) {
|
|
273
|
+
return message;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return "";
|
|
277
|
+
}
|
|
278
|
+
|
|
106
279
|
function getTaskEpic(task) {
|
|
107
280
|
return String(task?.epic || task?.epicName || task?.meta?.epic || task?.meta?.epicName || "").trim();
|
|
108
281
|
}
|
|
@@ -447,6 +620,7 @@ function KanbanCard({ task, onOpen }) {
|
|
|
447
620
|
const sprint = getTaskSprint(task);
|
|
448
621
|
const storyPoints = getTaskStoryPoints(task);
|
|
449
622
|
const dueDate = getTaskDueDate(task);
|
|
623
|
+
const blockedPreview = getTaskBlockedPreview(task);
|
|
450
624
|
const repoName = task.repo || task.repository || "";
|
|
451
625
|
const issueNum = task.issueNumber || task.issue_number || (typeof task.id === "string" && /^\d+$/.test(task.id) ? task.id : null);
|
|
452
626
|
const hasAgent = Boolean(
|
|
@@ -506,6 +680,11 @@ function KanbanCard({ task, onOpen }) {
|
|
|
506
680
|
${task.description && html`
|
|
507
681
|
<${Typography} variant="caption" color="text.secondary" sx=${{ display: 'block', mt: 0.5 }}>${truncate(task.description, 72)}</${Typography}>
|
|
508
682
|
`}
|
|
683
|
+
${String(task?.status || "").toLowerCase() === "blocked" && html`
|
|
684
|
+
<${Typography} variant="caption" sx=${{ display: 'block', mt: 0.5, color: 'var(--color-error, #ef4444)', fontWeight: 600 }}>
|
|
685
|
+
${truncate(blockedPreview || "Blocked task. Open details for diagnostics.", 96)}
|
|
686
|
+
</${Typography}>
|
|
687
|
+
`}
|
|
509
688
|
${(epic || sprint || storyPoints || dueDate) && html`
|
|
510
689
|
<${Stack} direction="row" spacing=${0.5} flexWrap="wrap" sx=${{ mt: 0.75 }}>
|
|
511
690
|
${epic && html`<${Chip} label=${`Epic: ${truncate(epic, 18)}`} size="small" variant="outlined" sx=${{ height: 20, fontSize: '0.65rem' }} />`}
|
|
@@ -889,9 +1068,55 @@ function KanbanFilter({ tasks, filters, onFilterChange }) {
|
|
|
889
1068
|
}
|
|
890
1069
|
|
|
891
1070
|
/* ─── KanbanBoard (main export) ─── */
|
|
892
|
-
export function KanbanBoard({ onOpenTask, hasMoreTasks = false, loadingMoreTasks = false, onLoadMoreTasks = null, columnTotals = {}, totalTasks = 0 }) {
|
|
893
|
-
const
|
|
1071
|
+
export function KanbanBoard({ onOpenTask, hasMoreTasks = false, loadingMoreTasks = false, onLoadMoreTasks = null, columnTotals = {}, totalTasks = 0, workspaceId = "" }) {
|
|
1072
|
+
const workspaceScope = normalizeBoardWorkspaceScope(workspaceId);
|
|
1073
|
+
const [hydratedWorkspaceScope, setHydratedWorkspaceScope] = useState(workspaceScope);
|
|
1074
|
+
const [filters, setFilters] = useState(() => readPersistedBoardFilters({ workspaceId: workspaceScope }));
|
|
894
1075
|
const allTasks = tasksData.value || [];
|
|
1076
|
+
const boardTasksLoaded = Boolean(tasksLoaded.value);
|
|
1077
|
+
const knownRepos = useMemo(() => {
|
|
1078
|
+
const repos = new Set();
|
|
1079
|
+
for (const task of allTasks) {
|
|
1080
|
+
const repoName = String(task?.repo || task?.repository || "").trim();
|
|
1081
|
+
if (repoName) repos.add(repoName);
|
|
1082
|
+
}
|
|
1083
|
+
return repos;
|
|
1084
|
+
}, [allTasks]);
|
|
1085
|
+
const knownAssignees = useMemo(() => {
|
|
1086
|
+
const assignees = new Set();
|
|
1087
|
+
for (const task of allTasks) {
|
|
1088
|
+
const assignee = String(task?.assignee || "").trim();
|
|
1089
|
+
if (assignee) assignees.add(assignee);
|
|
1090
|
+
}
|
|
1091
|
+
return assignees;
|
|
1092
|
+
}, [allTasks]);
|
|
1093
|
+
|
|
1094
|
+
useEffect(() => {
|
|
1095
|
+
const hydrated = readPersistedBoardFilters({
|
|
1096
|
+
workspaceId: workspaceScope,
|
|
1097
|
+
validateWith: boardTasksLoaded
|
|
1098
|
+
? { allowedRepos: knownRepos, allowedAssignees: knownAssignees }
|
|
1099
|
+
: undefined,
|
|
1100
|
+
});
|
|
1101
|
+
setFilters((prev) => (areBoardFiltersEqual(prev, hydrated) ? prev : hydrated));
|
|
1102
|
+
setHydratedWorkspaceScope(workspaceScope);
|
|
1103
|
+
}, [boardTasksLoaded, knownAssignees, knownRepos, workspaceScope]);
|
|
1104
|
+
|
|
1105
|
+
useEffect(() => {
|
|
1106
|
+
if (!boardTasksLoaded) return;
|
|
1107
|
+
setFilters((prev) => {
|
|
1108
|
+
const sanitized = sanitizeBoardFilters(prev, {
|
|
1109
|
+
allowedRepos: knownRepos,
|
|
1110
|
+
allowedAssignees: knownAssignees,
|
|
1111
|
+
});
|
|
1112
|
+
return areBoardFiltersEqual(prev, sanitized) ? prev : sanitized;
|
|
1113
|
+
});
|
|
1114
|
+
}, [boardTasksLoaded, knownRepos, knownAssignees]);
|
|
1115
|
+
|
|
1116
|
+
useEffect(() => {
|
|
1117
|
+
if (hydratedWorkspaceScope !== workspaceScope) return;
|
|
1118
|
+
persistBoardFilters({ workspaceId: workspaceScope, filters });
|
|
1119
|
+
}, [workspaceScope, hydratedWorkspaceScope, filters]);
|
|
895
1120
|
|
|
896
1121
|
const filteredTasks = useMemo(() => {
|
|
897
1122
|
let tasks = allTasks;
|
|
@@ -951,5 +1176,3 @@ export function KanbanBoard({ onOpenTask, hasMoreTasks = false, loadingMoreTasks
|
|
|
951
1176
|
</${Box}>
|
|
952
1177
|
`;
|
|
953
1178
|
}
|
|
954
|
-
|
|
955
|
-
|
|
@@ -8,7 +8,14 @@ import { useState, useEffect, useCallback, useRef } from "preact/hooks";
|
|
|
8
8
|
import htm from "htm";
|
|
9
9
|
import { signal, computed, effect } from "@preact/signals";
|
|
10
10
|
import { apiFetch, onWsMessage } from "../modules/api.js";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
buildSessionApiPath,
|
|
13
|
+
createSessionLoadMeta,
|
|
14
|
+
markSessionLoadFailure,
|
|
15
|
+
markSessionLoadSuccess,
|
|
16
|
+
resetSessionRetryMeta,
|
|
17
|
+
resolveSessionWorkspaceHint,
|
|
18
|
+
} from "../modules/session-api.js";
|
|
12
19
|
import { formatRelative, truncate } from "../modules/utils.js";
|
|
13
20
|
import { resolveIcon } from "../modules/icon-utils.js";
|
|
14
21
|
import {
|
|
@@ -37,6 +44,7 @@ export const selectedSessionId = signal(readPersistedSelectedSessionId());
|
|
|
37
44
|
export const sessionMessages = signal([]);
|
|
38
45
|
export const sessionMessagesSessionId = signal("");
|
|
39
46
|
export const sessionsError = signal(null);
|
|
47
|
+
export const sessionLoadMeta = signal(createSessionLoadMeta());
|
|
40
48
|
/** Pagination metadata from the last loadSessionMessages call */
|
|
41
49
|
export const sessionPagination = signal(null);
|
|
42
50
|
|
|
@@ -61,6 +69,25 @@ let _wsListenerReady = false;
|
|
|
61
69
|
|
|
62
70
|
/** Track the last filter used so createSession can reload with the same filter */
|
|
63
71
|
let _lastLoadFilter = {};
|
|
72
|
+
let _sessionRetryTimer = null;
|
|
73
|
+
|
|
74
|
+
function clearSessionRetryTimer() {
|
|
75
|
+
if (_sessionRetryTimer) {
|
|
76
|
+
clearTimeout(_sessionRetryTimer);
|
|
77
|
+
_sessionRetryTimer = null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function scheduleSessionRetry(meta) {
|
|
82
|
+
clearSessionRetryTimer();
|
|
83
|
+
const nextRetryAt = Date.parse(String(meta?.nextRetryAt || ""));
|
|
84
|
+
if (!Number.isFinite(nextRetryAt)) return;
|
|
85
|
+
const delayMs = Math.max(0, nextRetryAt - Date.now());
|
|
86
|
+
_sessionRetryTimer = setTimeout(() => {
|
|
87
|
+
_sessionRetryTimer = null;
|
|
88
|
+
loadSessions(_lastLoadFilter, { source: "retry" }).catch(() => {});
|
|
89
|
+
}, delayMs);
|
|
90
|
+
}
|
|
64
91
|
|
|
65
92
|
function sessionPath(id, action = "") {
|
|
66
93
|
const session = (sessionsData.peek() || []).find((entry) => entry?.id === id) || null;
|
|
@@ -72,7 +99,7 @@ function sessionPath(id, action = "") {
|
|
|
72
99
|
}
|
|
73
100
|
|
|
74
101
|
/* ─── Data loaders ─── */
|
|
75
|
-
export async function loadSessions(filter = {}) {
|
|
102
|
+
export async function loadSessions(filter = {}, _opts = {}) {
|
|
76
103
|
const normalizedFilter = {
|
|
77
104
|
...(filter && typeof filter === "object" ? filter : {}),
|
|
78
105
|
};
|
|
@@ -96,9 +123,15 @@ export async function loadSessions(filter = {}) {
|
|
|
96
123
|
selectedSessionId.value = null;
|
|
97
124
|
}
|
|
98
125
|
}
|
|
126
|
+
clearSessionRetryTimer();
|
|
127
|
+
sessionLoadMeta.value = markSessionLoadSuccess(sessionLoadMeta.peek());
|
|
99
128
|
sessionsError.value = null;
|
|
100
129
|
} catch {
|
|
101
|
-
|
|
130
|
+
const nextMeta = markSessionLoadFailure(sessionLoadMeta.peek());
|
|
131
|
+
sessionLoadMeta.value = nextMeta;
|
|
132
|
+
scheduleSessionRetry(nextMeta);
|
|
133
|
+
const hasCachedData = Array.isArray(sessionsData.peek()) && sessionsData.peek().length > 0;
|
|
134
|
+
sessionsError.value = hasCachedData || Boolean(nextMeta.lastSuccessAt) ? null : "unavailable";
|
|
102
135
|
}
|
|
103
136
|
}
|
|
104
137
|
|
|
@@ -865,7 +898,10 @@ export function SessionList({
|
|
|
865
898
|
normalizeSessionViewFilter(sessionView),
|
|
866
899
|
);
|
|
867
900
|
const allSessions = sessionsData.value || [];
|
|
901
|
+
const loadMeta = sessionLoadMeta.value || createSessionLoadMeta();
|
|
868
902
|
const error = sessionsError.value;
|
|
903
|
+
const showStaleBanner = Boolean(loadMeta.stale && (loadMeta.lastSuccessAt || allSessions.length > 0));
|
|
904
|
+
const [retryCountdownNow, setRetryCountdownNow] = useState(() => Date.now());
|
|
869
905
|
const hasSearch = search.trim().length > 0;
|
|
870
906
|
const resolvedSessionView =
|
|
871
907
|
typeof onSessionViewChange === "function"
|
|
@@ -880,6 +916,14 @@ export function SessionList({
|
|
|
880
916
|
}
|
|
881
917
|
}, [onSessionViewChange, sessionView, uncontrolledSessionView]);
|
|
882
918
|
|
|
919
|
+
useEffect(() => {
|
|
920
|
+
if (!showStaleBanner || !loadMeta.nextRetryAt || loadMeta.retriesExhausted) return undefined;
|
|
921
|
+
const interval = setInterval(() => {
|
|
922
|
+
setRetryCountdownNow(Date.now());
|
|
923
|
+
}, 1000);
|
|
924
|
+
return () => clearInterval(interval);
|
|
925
|
+
}, [showStaleBanner, loadMeta.nextRetryAt, loadMeta.retriesExhausted]);
|
|
926
|
+
|
|
883
927
|
const setSessionView = useCallback(
|
|
884
928
|
(nextFilter) => {
|
|
885
929
|
const normalized = normalizeSessionViewFilter(nextFilter);
|
|
@@ -952,8 +996,10 @@ export function SessionList({
|
|
|
952
996
|
);
|
|
953
997
|
|
|
954
998
|
const handleRetry = useCallback(() => {
|
|
999
|
+
clearSessionRetryTimer();
|
|
955
1000
|
sessionsError.value = null;
|
|
956
|
-
|
|
1001
|
+
sessionLoadMeta.value = resetSessionRetryMeta(sessionLoadMeta.peek());
|
|
1002
|
+
loadSessions(_lastLoadFilter, { source: "manual-retry" });
|
|
957
1003
|
}, []);
|
|
958
1004
|
|
|
959
1005
|
const handleCreateSession = useCallback(() => {
|
|
@@ -1089,7 +1135,14 @@ export function SessionList({
|
|
|
1089
1135
|
`;
|
|
1090
1136
|
}
|
|
1091
1137
|
|
|
1092
|
-
|
|
1138
|
+
const nextRetryMs = Date.parse(String(loadMeta.nextRetryAt || ""));
|
|
1139
|
+
const retrySeconds =
|
|
1140
|
+
Number.isFinite(nextRetryMs) && !loadMeta.retriesExhausted
|
|
1141
|
+
? Math.max(0, Math.ceil((nextRetryMs - retryCountdownNow) / 1000))
|
|
1142
|
+
: 0;
|
|
1143
|
+
const retryAttemptDisplay = Math.min(loadMeta.retryAttempt || 0, loadMeta.maxAttempts || 0);
|
|
1144
|
+
|
|
1145
|
+
if (error && !showStaleBanner) {
|
|
1093
1146
|
return html`
|
|
1094
1147
|
<${Paper} elevation=${0} sx=${{ height: "100%", display: "flex", flexDirection: "column" }}>
|
|
1095
1148
|
<${Box} sx=${{ p: 1.5, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
@@ -1143,6 +1196,33 @@ export function SessionList({
|
|
|
1143
1196
|
</${Stack}>
|
|
1144
1197
|
</${Box}>
|
|
1145
1198
|
|
|
1199
|
+
${showStaleBanner &&
|
|
1200
|
+
html`
|
|
1201
|
+
<${Box} sx=${{ px: 1.5, pb: 1 }}>
|
|
1202
|
+
<${Alert}
|
|
1203
|
+
severity="warning"
|
|
1204
|
+
variant="outlined"
|
|
1205
|
+
action=${html`
|
|
1206
|
+
<${Button} size="small" color="warning" onClick=${handleRetry}>
|
|
1207
|
+
Retry now
|
|
1208
|
+
</${Button}>
|
|
1209
|
+
`}
|
|
1210
|
+
>
|
|
1211
|
+
<${Typography} variant="body2" sx=${{ fontWeight: 600 }}>
|
|
1212
|
+
Session list is showing stale data.
|
|
1213
|
+
</${Typography}>
|
|
1214
|
+
<${Typography} variant="caption" component="div">
|
|
1215
|
+
Last successful refresh: ${loadMeta.lastSuccessAt ? formatRelative(loadMeta.lastSuccessAt) : "unknown"}
|
|
1216
|
+
</${Typography}>
|
|
1217
|
+
<${Typography} variant="caption" component="div">
|
|
1218
|
+
${loadMeta.retriesExhausted
|
|
1219
|
+
? `Automatic retries stopped after ${loadMeta.maxAttempts} attempts.`
|
|
1220
|
+
: `Retry ${retryAttemptDisplay}/${loadMeta.maxAttempts} in ${retrySeconds}s.`}
|
|
1221
|
+
</${Typography}>
|
|
1222
|
+
</${Alert}>
|
|
1223
|
+
</${Box}>
|
|
1224
|
+
`}
|
|
1225
|
+
|
|
1146
1226
|
<!-- Search bar -->
|
|
1147
1227
|
<${Box} sx=${{ px: 1.5, pb: 1 }}>
|
|
1148
1228
|
<${TextField}
|