claude-simple 0.1.0
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/README.md +225 -0
- package/client.js +139 -0
- package/dist/addon-fit.min.js +2 -0
- package/dist/app.0.1.0.min.js +825 -0
- package/dist/highlight.min.css +10 -0
- package/dist/highlight.min.js +1244 -0
- package/dist/index.html +16 -0
- package/dist/marked.min.js +6 -0
- package/dist/style.0.1.0.min.css +1050 -0
- package/dist/xterm.min.css +209 -0
- package/dist/xterm.min.js +2 -0
- package/gateway.js +349 -0
- package/package.json +41 -0
- package/public/addon-fit.min.js +2 -0
- package/public/agent-panel.js +1079 -0
- package/public/api.js +78 -0
- package/public/app.js +95 -0
- package/public/base.css +4 -0
- package/public/cache.js +27 -0
- package/public/chat.js +1087 -0
- package/public/files.js +476 -0
- package/public/git.js +130 -0
- package/public/index.html +61 -0
- package/public/lib.js +2 -0
- package/public/router.js +71 -0
- package/public/settings.js +90 -0
- package/public/shell/boot.js +159 -0
- package/public/shell/dashboard.js +269 -0
- package/public/shell/hub.js +192 -0
- package/public/shell/layout.js +195 -0
- package/public/shell/notes.js +323 -0
- package/public/shell/session-context.js +19 -0
- package/public/shell/session-list.js +442 -0
- package/public/shell/session-nav.js +299 -0
- package/public/shell/templates.js +342 -0
- package/public/shell/util.js +63 -0
- package/public/shell.js +589 -0
- package/public/state.js +362 -0
- package/public/style.css +1048 -0
- package/public/terminal.js +177 -0
- package/public/xterm.min.css +209 -0
- package/public/xterm.min.js +2 -0
- package/server.js +12 -0
- package/src/agents/claude.js +58 -0
- package/src/agents/codex.js +284 -0
- package/src/agents/grok.js +318 -0
- package/src/agents/meta-agent.js +1952 -0
- package/src/agents/models.js +339 -0
- package/src/agents/sessions.js +1508 -0
- package/src/app.js +1282 -0
package/public/state.js
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// state.js — All reactive state + mutable runtime context
|
|
2
|
+
import { signal, computed } from './lib.js';
|
|
3
|
+
|
|
4
|
+
export const sessionsData = signal([]);
|
|
5
|
+
export const workspacesData = signal([]);
|
|
6
|
+
export const sessionFilter = signal(null); // cwd path OR configDir path (workspace tab)
|
|
7
|
+
export const sessionSearch = signal('');
|
|
8
|
+
export const sessionSort = signal('time'); // 'time' | 'project' (timeline sort)
|
|
9
|
+
export const expandedFolders = signal(new Set()); // cwd paths currently expanded in the session list
|
|
10
|
+
export const collapsedFolders = signal(new Set()); // cwd paths explicitly collapsed in the session list
|
|
11
|
+
export const filesPath = signal(''); // current path in Files tab
|
|
12
|
+
export const viewingFile = signal(null); // currently viewing file in Files tab
|
|
13
|
+
export const currentProject = signal(null); // { id, name, path } | null
|
|
14
|
+
export const currentTab = signal('chat');
|
|
15
|
+
export const isProcessing = signal(false);
|
|
16
|
+
export const currentAgent = signal(localStorage.getItem('agent') || 'claude'); // 'claude'|'codex'|'grok'
|
|
17
|
+
export const currentModel = signal(localStorage.getItem('model') || 'claude-sonnet-4-5');
|
|
18
|
+
export const currentEffort = signal('');
|
|
19
|
+
export const currentPermission = signal('default'); // 'default'|'acceptEdits'|'bypassPermissions'|'plan'|'auto'
|
|
20
|
+
export const filesRoot = signal(''); // custom root override for Files tab
|
|
21
|
+
export const gitRoot = signal(''); // custom root override for Git tab
|
|
22
|
+
export const agentsMeta = signal(null); // from /api/agents (dynamic model lists)
|
|
23
|
+
|
|
24
|
+
// Sidebar project-management filters
|
|
25
|
+
export const sidebarView = signal(localStorage.getItem('sidebarView') || 'projects'); // 'projects' | 'timeline'
|
|
26
|
+
export const agentFilter = signal(localStorage.getItem('agentFilter') || 'all'); // all|claude|codex|grok
|
|
27
|
+
export const timeRange = signal(localStorage.getItem('timeRange') || '14'); // '0'|'1'|'7'|'14'|'30'
|
|
28
|
+
export const activityHits = signal(null); // null | { q, results, loading, error }
|
|
29
|
+
export const activityLoading = signal(false);
|
|
30
|
+
/** agent:sessionId → { favorite, notes, title, updatedAt } (title = rename map) */
|
|
31
|
+
export const sessionMetaMap = signal({});
|
|
32
|
+
/** Sidebar: show only favorited sessions */
|
|
33
|
+
export const favoritesOnly = signal(localStorage.getItem('favoritesOnly') === '1');
|
|
34
|
+
/** Sessions with this many user turns or fewer get a “thin” badge */
|
|
35
|
+
export const LOW_TURN_THRESHOLD = 2;
|
|
36
|
+
|
|
37
|
+
// Chat display density: clean (tools batched/hidden) | normal | full (tools expanded-friendly)
|
|
38
|
+
export const chatDensity = signal(localStorage.getItem('chatDensity') || 'normal');
|
|
39
|
+
|
|
40
|
+
// Hub signals — must be declared BEFORE filteredSessions (uses them in the computed)
|
|
41
|
+
/** Hub mode: single WebUI, many edge machines (set after /api/hub probe). */
|
|
42
|
+
export const hubMode = signal(false);
|
|
43
|
+
export const machinesList = signal([]);
|
|
44
|
+
/** Currently selected edge machine (hub mode). Also mirrored on ctx.machineId for api/ws. */
|
|
45
|
+
export const selectedMachineId = signal(null);
|
|
46
|
+
/** Hub: user finished machine picker after login. */
|
|
47
|
+
export const hubMachineReady = signal(false);
|
|
48
|
+
|
|
49
|
+
// Mutable runtime — passed by reference so all modules see mutations
|
|
50
|
+
export const ctx = {
|
|
51
|
+
token: localStorage.getItem('token'),
|
|
52
|
+
sessionId: null,
|
|
53
|
+
agent: null,
|
|
54
|
+
machineId: null, // hub: set via setSelectedMachine / picker
|
|
55
|
+
ws: null,
|
|
56
|
+
shellBubble: null,
|
|
57
|
+
configDir: null,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Select edge machine (hub). Keeps signal + ctx + localStorage in sync. */
|
|
61
|
+
export function setSelectedMachine(id) {
|
|
62
|
+
const mid = id || null;
|
|
63
|
+
selectedMachineId.value = mid;
|
|
64
|
+
ctx.machineId = mid;
|
|
65
|
+
if (mid) localStorage.setItem('machineId', mid);
|
|
66
|
+
else localStorage.removeItem('machineId');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function metaKey(agent, sessionId, machineId) {
|
|
70
|
+
const base = `${agent || 'claude'}:${sessionId}`;
|
|
71
|
+
// Hub aggregates as machineId:agent:sessionId
|
|
72
|
+
if (machineId || ctx?.machineId) {
|
|
73
|
+
return `${machineId || ctx.machineId}:${base}`;
|
|
74
|
+
}
|
|
75
|
+
return base;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function getSessionMeta(agent, sessionId, machineId) {
|
|
79
|
+
const m = sessionMetaMap.peek()?.[metaKey(agent, sessionId, machineId)];
|
|
80
|
+
return m || { favorite: false, notes: '', title: '' };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Display title: user rename map → original display → short id. */
|
|
84
|
+
export function sessionDisplayTitle(s, { preferSnippet = false } = {}) {
|
|
85
|
+
if (!s) return 'Session';
|
|
86
|
+
if (preferSnippet && s.snippet) return s.snippet;
|
|
87
|
+
const meta = getSessionMeta(s.agent, s.sessionId, s.machineId);
|
|
88
|
+
const custom = (meta.title || '').trim();
|
|
89
|
+
if (custom) return custom;
|
|
90
|
+
return s.display || s.sessionId?.slice(0, 8) || 'Session';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function setChatDensity(d) {
|
|
94
|
+
const v = ['clean', 'normal', 'full'].includes(d) ? d : 'normal';
|
|
95
|
+
chatDensity.value = v;
|
|
96
|
+
localStorage.setItem('chatDensity', v);
|
|
97
|
+
document.getElementById('messages')?.setAttribute('data-density', v);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const AGENT_LABELS = {
|
|
101
|
+
claude: 'Claude',
|
|
102
|
+
codex: 'Codex',
|
|
103
|
+
grok: 'Grok',
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** Static fallbacks when /api/agents has not loaded yet. */
|
|
107
|
+
export const AGENT_MODELS = {
|
|
108
|
+
claude: [
|
|
109
|
+
{ value: 'claude-sonnet-4-5', label: 'sonnet-4-5' },
|
|
110
|
+
{ value: 'claude-sonnet-4-6', label: 'sonnet-4-6' },
|
|
111
|
+
{ value: 'claude-opus-4-5', label: 'opus-4-5' },
|
|
112
|
+
{ value: 'claude-haiku-4-5', label: 'haiku-4-5' },
|
|
113
|
+
],
|
|
114
|
+
codex: [
|
|
115
|
+
{ value: 'gpt-5.4', label: 'gpt-5.4' },
|
|
116
|
+
{ value: 'gpt-5.3', label: 'gpt-5.3' },
|
|
117
|
+
{ value: 'gpt-5.2', label: 'gpt-5.2' },
|
|
118
|
+
{ value: 'o3', label: 'o3' },
|
|
119
|
+
{ value: 'o4-mini', label: 'o4-mini' },
|
|
120
|
+
{ value: 'codex-mini-latest', label: 'codex-mini' },
|
|
121
|
+
],
|
|
122
|
+
grok: [
|
|
123
|
+
{ value: 'grok-4.5', label: 'grok-4.5' },
|
|
124
|
+
{ value: 'grok-4', label: 'grok-4' },
|
|
125
|
+
{ value: 'grok-3', label: 'grok-3' },
|
|
126
|
+
{ value: 'grok-3-mini', label: 'grok-3-mini' },
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const AGENT_DEFAULT_MODEL = {
|
|
131
|
+
claude: 'claude-sonnet-4-5',
|
|
132
|
+
codex: 'gpt-5.4',
|
|
133
|
+
grok: 'grok-4.5',
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
function normalizeModelEntry(m) {
|
|
137
|
+
if (!m) return null;
|
|
138
|
+
if (typeof m === 'string') return { value: m, label: m };
|
|
139
|
+
if (!m.value) return null;
|
|
140
|
+
return {
|
|
141
|
+
value: String(m.value),
|
|
142
|
+
label: m.label || m.value,
|
|
143
|
+
...(Array.isArray(m.efforts) && m.efforts.length ? { efforts: m.efforts.map(String) } : {}),
|
|
144
|
+
...(m.defaultEffort ? { defaultEffort: String(m.defaultEffort) } : {}),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const CUSTOM_MODELS_KEY = 'customModelsByAgent';
|
|
149
|
+
|
|
150
|
+
function readCustomModelsMap() {
|
|
151
|
+
try {
|
|
152
|
+
const raw = localStorage.getItem(CUSTOM_MODELS_KEY);
|
|
153
|
+
if (!raw) return {};
|
|
154
|
+
const o = JSON.parse(raw);
|
|
155
|
+
return o && typeof o === 'object' ? o : {};
|
|
156
|
+
} catch {
|
|
157
|
+
return {};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function writeCustomModelsMap(map) {
|
|
162
|
+
localStorage.setItem(CUSTOM_MODELS_KEY, JSON.stringify(map));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** User-added model ids for an agent (localStorage). */
|
|
166
|
+
export function getCustomModels(agent) {
|
|
167
|
+
const a = (agent || 'claude').toLowerCase();
|
|
168
|
+
const list = readCustomModelsMap()[a];
|
|
169
|
+
return Array.isArray(list) ? list.filter(Boolean).map(String) : [];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Persist a custom model id for agent (idempotent). */
|
|
173
|
+
export function addCustomModel(agent, modelId) {
|
|
174
|
+
const a = (agent || 'claude').toLowerCase();
|
|
175
|
+
const id = String(modelId || '').trim();
|
|
176
|
+
if (!id) return false;
|
|
177
|
+
const map = readCustomModelsMap();
|
|
178
|
+
const list = Array.isArray(map[a]) ? [...map[a]] : [];
|
|
179
|
+
if (!list.includes(id)) list.push(id);
|
|
180
|
+
map[a] = list;
|
|
181
|
+
writeCustomModelsMap(map);
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Remove a custom model id (only from user list, not discovery). */
|
|
186
|
+
export function removeCustomModel(agent, modelId) {
|
|
187
|
+
const a = (agent || 'claude').toLowerCase();
|
|
188
|
+
const id = String(modelId || '').trim();
|
|
189
|
+
const map = readCustomModelsMap();
|
|
190
|
+
const list = Array.isArray(map[a]) ? map[a].filter(x => x !== id) : [];
|
|
191
|
+
map[a] = list;
|
|
192
|
+
writeCustomModelsMap(map);
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Models for agent: discovery + user custom + static fallback. */
|
|
197
|
+
export function getModelsForAgent(agent) {
|
|
198
|
+
const a = (agent || 'claude').toLowerCase();
|
|
199
|
+
const meta = agentsMeta.peek()?.defaults?.[a];
|
|
200
|
+
let base = meta?.models?.length
|
|
201
|
+
? meta.models.map(normalizeModelEntry).filter(Boolean)
|
|
202
|
+
: (AGENT_MODELS[a] || AGENT_MODELS.claude).map(m => ({ ...m }));
|
|
203
|
+
|
|
204
|
+
const custom = getCustomModels(a);
|
|
205
|
+
const seen = new Set(base.map(m => m.value));
|
|
206
|
+
for (const id of custom) {
|
|
207
|
+
if (seen.has(id)) {
|
|
208
|
+
base = base.map(m => {
|
|
209
|
+
if (m.value !== id) return m;
|
|
210
|
+
const label = String(m.label || m.value);
|
|
211
|
+
return {
|
|
212
|
+
...m,
|
|
213
|
+
custom: true,
|
|
214
|
+
label: label.includes('★') ? label : `${label} ★`,
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
seen.add(id);
|
|
220
|
+
base.push({ value: id, label: `${id} ★`, custom: true });
|
|
221
|
+
}
|
|
222
|
+
return base;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function getDefaultModel(agent) {
|
|
226
|
+
const a = (agent || 'claude').toLowerCase();
|
|
227
|
+
const meta = agentsMeta.peek()?.defaults?.[a];
|
|
228
|
+
if (meta?.model) return String(meta.model);
|
|
229
|
+
return AGENT_DEFAULT_MODEL[a] || getModelsForAgent(a)[0]?.value || 'claude-sonnet-4-5';
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Effort levels for current (or given) model under agent. */
|
|
233
|
+
export function getEffortsForModel(agent, modelId) {
|
|
234
|
+
const models = getModelsForAgent(agent);
|
|
235
|
+
const m = models.find(x => x.value === modelId);
|
|
236
|
+
if (m?.efforts?.length) return m.efforts;
|
|
237
|
+
const meta = agentsMeta.peek()?.defaults?.[(agent || 'claude').toLowerCase()];
|
|
238
|
+
if (meta?.efforts?.length) return meta.efforts.map(String);
|
|
239
|
+
return ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function setAgent(agent) {
|
|
243
|
+
const a = (agent || 'claude').toLowerCase();
|
|
244
|
+
currentAgent.value = a;
|
|
245
|
+
localStorage.setItem('agent', a);
|
|
246
|
+
const models = getModelsForAgent(a);
|
|
247
|
+
const def = getDefaultModel(a);
|
|
248
|
+
if (!models.some(m => m.value === currentModel.peek())) {
|
|
249
|
+
currentModel.value = def;
|
|
250
|
+
localStorage.setItem('model', def);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function matchesAgentFilter(s, filter) {
|
|
255
|
+
if (!filter || filter === 'all') return true;
|
|
256
|
+
return (s.agent || 'claude') === filter;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function matchesTimeRange(s, daysStr) {
|
|
260
|
+
const days = parseInt(daysStr || '0', 10) || 0;
|
|
261
|
+
if (days <= 0) return true;
|
|
262
|
+
let ts = s.updatedAt || 0;
|
|
263
|
+
if (typeof ts === 'string') {
|
|
264
|
+
const n = Number(ts);
|
|
265
|
+
ts = Number.isFinite(n) && n > 0 ? (n < 1e12 ? n * 1000 : n) : Date.parse(ts) || 0;
|
|
266
|
+
} else if (typeof ts === 'number' && ts > 0 && ts < 1e12) {
|
|
267
|
+
ts = ts * 1000;
|
|
268
|
+
}
|
|
269
|
+
return ts >= Date.now() - days * 86400000;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function matchesSearch(s, search) {
|
|
273
|
+
if (!search) return true;
|
|
274
|
+
const meta = sessionMetaMap.value?.[metaKey(s.agent, s.sessionId, s.machineId)];
|
|
275
|
+
const hay = [
|
|
276
|
+
s.display, s.cwd, s.projectName, s.agent, s.sessionId, s.snippet, s.machineId,
|
|
277
|
+
meta?.notes, meta?.title,
|
|
278
|
+
].filter(Boolean).join(' ').toLowerCase();
|
|
279
|
+
return hay.includes(search);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function asSessionArray(v) {
|
|
283
|
+
if (Array.isArray(v)) return v;
|
|
284
|
+
// Hub/API/cache may occasionally yield an object or null
|
|
285
|
+
if (v && Array.isArray(v.results)) return v.results;
|
|
286
|
+
if (v && Array.isArray(v.sessions)) return v.sessions;
|
|
287
|
+
return [];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Sessions after local filters (agent / time / workspace / text / favorites). */
|
|
291
|
+
export const filteredSessions = computed(() => {
|
|
292
|
+
const filter = sessionFilter.value;
|
|
293
|
+
const search = sessionSearch.value.toLowerCase().trim();
|
|
294
|
+
const sort = sessionSort.value;
|
|
295
|
+
const aFilter = agentFilter.value;
|
|
296
|
+
const days = timeRange.value;
|
|
297
|
+
const favOnly = favoritesOnly.value;
|
|
298
|
+
const metaMap = sessionMetaMap.value;
|
|
299
|
+
// When deep activity results are present and user is searching, prefer those
|
|
300
|
+
const hits = activityHits.value;
|
|
301
|
+
let list = (hits?.results?.length && search)
|
|
302
|
+
? asSessionArray(hits.results)
|
|
303
|
+
: asSessionArray(sessionsData.value);
|
|
304
|
+
|
|
305
|
+
if (filter) list = list.filter(s => s && (s.cwd === filter || s.configDir === filter));
|
|
306
|
+
// Hub: only show sessions on the currently selected machine
|
|
307
|
+
const mid = selectedMachineId.value;
|
|
308
|
+
if (hubMode.value && mid) {
|
|
309
|
+
list = list.filter(s => s && (!s.machineId || s.machineId === mid));
|
|
310
|
+
}
|
|
311
|
+
list = list.filter(s => s && matchesAgentFilter(s, aFilter) && matchesTimeRange(s, days));
|
|
312
|
+
if (favOnly) {
|
|
313
|
+
list = list.filter(s => s && metaMap?.[metaKey(s.agent, s.sessionId, s.machineId)]?.favorite);
|
|
314
|
+
}
|
|
315
|
+
if (search && !(hits?.results && hits.q === search)) {
|
|
316
|
+
list = list.filter(s => s && matchesSearch(s, search));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
list = [...list].sort((a, b) => {
|
|
320
|
+
// Favorites float to top within the same sort key
|
|
321
|
+
const fa = metaMap?.[metaKey(a.agent, a.sessionId, a.machineId)]?.favorite ? 1 : 0;
|
|
322
|
+
const fb = metaMap?.[metaKey(b.agent, b.sessionId, b.machineId)]?.favorite ? 1 : 0;
|
|
323
|
+
if (fa !== fb) return fb - fa;
|
|
324
|
+
return sort === 'project'
|
|
325
|
+
? (a.cwd || '').localeCompare(b.cwd || '') || (b.updatedAt - a.updatedAt)
|
|
326
|
+
: b.updatedAt - a.updatedAt;
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// Cap timeline dump when no filter/search
|
|
330
|
+
if (!filter && !search && !favOnly) return list.slice(0, 120);
|
|
331
|
+
return list;
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
/** Project groups derived from filtered sessions. */
|
|
335
|
+
export const projectGroups = computed(() => {
|
|
336
|
+
const list = asSessionArray(filteredSessions.value);
|
|
337
|
+
const map = new Map();
|
|
338
|
+
for (const s of list) {
|
|
339
|
+
if (!s) continue;
|
|
340
|
+
// Hub: same cwd on different machines are different projects
|
|
341
|
+
const key = s.machineId ? `${s.machineId}::${s.cwd || '(no path)'}` : (s.cwd || '(no path)');
|
|
342
|
+
if (!map.has(key)) {
|
|
343
|
+
map.set(key, {
|
|
344
|
+
cwd: s.cwd || null,
|
|
345
|
+
machineId: s.machineId || null,
|
|
346
|
+
projectName: s.projectName || (s.cwd ? s.cwd.split('/').filter(Boolean).pop() : '(no path)'),
|
|
347
|
+
updatedAt: 0,
|
|
348
|
+
agents: {},
|
|
349
|
+
sessions: [],
|
|
350
|
+
latest: null,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
const g = map.get(key);
|
|
354
|
+
g.sessions.push(s);
|
|
355
|
+
g.agents[s.agent || 'claude'] = (g.agents[s.agent || 'claude'] || 0) + 1;
|
|
356
|
+
if ((s.updatedAt || 0) > g.updatedAt) {
|
|
357
|
+
g.updatedAt = s.updatedAt;
|
|
358
|
+
g.latest = s;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return [...map.values()].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
362
|
+
});
|