bosun 0.41.2 → 0.41.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/.env.example +1 -1
  2. package/agent/agent-pool.mjs +9 -2
  3. package/agent/agent-prompt-catalog.mjs +971 -0
  4. package/agent/agent-prompts.mjs +2 -970
  5. package/agent/agent-supervisor.mjs +119 -6
  6. package/agent/autofix-git.mjs +33 -0
  7. package/agent/autofix-prompts.mjs +151 -0
  8. package/agent/autofix.mjs +11 -175
  9. package/agent/bosun-skills.mjs +3 -2
  10. package/bosun.config.example.json +17 -0
  11. package/bosun.schema.json +87 -188
  12. package/cli.mjs +34 -1
  13. package/config/config-doctor.mjs +5 -250
  14. package/config/config-file-names.mjs +5 -0
  15. package/config/config.mjs +89 -493
  16. package/config/executor-config.mjs +493 -0
  17. package/config/repo-root.mjs +1 -2
  18. package/config/workspace-health.mjs +242 -0
  19. package/git/git-safety.mjs +15 -0
  20. package/github/github-oauth-portal.mjs +46 -0
  21. package/infra/library-manager-utils.mjs +22 -0
  22. package/infra/library-manager-well-known-sources.mjs +578 -0
  23. package/infra/library-manager.mjs +512 -1030
  24. package/infra/monitor.mjs +35 -9
  25. package/infra/session-tracker.mjs +10 -7
  26. package/kanban/kanban-adapter.mjs +17 -1
  27. package/lib/codebase-audit-manifests.mjs +117 -0
  28. package/lib/codebase-audit.mjs +18 -115
  29. package/package.json +18 -3
  30. package/server/setup-web-server.mjs +58 -5
  31. package/server/ui-server.mjs +1394 -79
  32. package/shell/codex-config-file.mjs +178 -0
  33. package/shell/codex-config.mjs +538 -575
  34. package/task/task-cli.mjs +54 -3
  35. package/task/task-executor.mjs +143 -13
  36. package/task/task-store.mjs +409 -1
  37. package/telegram/telegram-bot.mjs +127 -0
  38. package/tools/apply-pr-suggestions.mjs +401 -0
  39. package/tools/syntax-check.mjs +28 -9
  40. package/ui/app.js +3 -14
  41. package/ui/components/kanban-board.js +227 -4
  42. package/ui/components/session-list.js +85 -5
  43. package/ui/demo-defaults.js +338 -84
  44. package/ui/demo.html +155 -0
  45. package/ui/modules/session-api.js +96 -0
  46. package/ui/modules/settings-schema.js +1 -2
  47. package/ui/modules/state.js +43 -3
  48. package/ui/setup.html +4 -5
  49. package/ui/styles/components.css +58 -4
  50. package/ui/tabs/agents.js +12 -15
  51. package/ui/tabs/control.js +1 -0
  52. package/ui/tabs/library.js +484 -22
  53. package/ui/tabs/manual-flows.js +105 -29
  54. package/ui/tabs/tasks.js +848 -141
  55. package/ui/tabs/telemetry.js +129 -11
  56. package/ui/tabs/workflow-canvas-utils.mjs +130 -0
  57. package/ui/tabs/workflows.js +293 -23
  58. package/voice/voice-tool-definitions.mjs +757 -0
  59. package/voice/voice-tools.mjs +34 -778
  60. package/workflow/manual-flow-audit.mjs +165 -0
  61. package/workflow/manual-flows.mjs +164 -259
  62. package/workflow/workflow-engine.mjs +147 -58
  63. package/workflow/workflow-nodes/definitions.mjs +1207 -0
  64. package/workflow/workflow-nodes/transforms.mjs +612 -0
  65. package/workflow/workflow-nodes.mjs +358 -63
  66. package/workflow/workflow-templates.mjs +313 -191
  67. package/workflow-templates/_helpers.mjs +154 -0
  68. package/workflow-templates/agents.mjs +61 -4
  69. package/workflow-templates/code-quality.mjs +7 -7
  70. package/workflow-templates/github.mjs +20 -10
  71. package/workflow-templates/task-batch.mjs +44 -11
  72. package/workflow-templates/task-lifecycle.mjs +31 -6
  73. 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 [filters, setFilters] = useState({ repo: "", assignee: "", priority: "", search: "" });
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 { buildSessionApiPath, resolveSessionWorkspaceHint } from "../modules/session-api.js";
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
- sessionsError.value = "unavailable";
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
- loadSessions(_lastLoadFilter);
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
- if (error) {
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}