@recallkit/web 0.1.1

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 (64) hide show
  1. package/next-env.d.ts +6 -0
  2. package/next.config.ts +13 -0
  3. package/package.json +40 -0
  4. package/public/logo.png +0 -0
  5. package/public/textures/bg-scene.png +0 -0
  6. package/src/app/api/_lib/guards.ts +35 -0
  7. package/src/app/api/_lib/limits.ts +6 -0
  8. package/src/app/api/_lib/responses.ts +9 -0
  9. package/src/app/api/commit/complete/route.ts +112 -0
  10. package/src/app/api/commit/preview/route.ts +71 -0
  11. package/src/app/api/commit/route.ts +16 -0
  12. package/src/app/api/memory-cache/route.ts +50 -0
  13. package/src/app/api/pending/[id]/delete/route.ts +21 -0
  14. package/src/app/api/pending/[id]/route.ts +47 -0
  15. package/src/app/api/pending/route.ts +41 -0
  16. package/src/app/api/security.ts +25 -0
  17. package/src/app/api/status/route.ts +35 -0
  18. package/src/app/dashboard/page.tsx +57 -0
  19. package/src/app/drafts/page.tsx +5 -0
  20. package/src/app/globals.css +10 -0
  21. package/src/app/icon.png +0 -0
  22. package/src/app/layout.tsx +43 -0
  23. package/src/app/page.tsx +132 -0
  24. package/src/app/settings/page.tsx +76 -0
  25. package/src/components/ArrowRightIcon.tsx +25 -0
  26. package/src/components/CommitPreview.tsx +156 -0
  27. package/src/components/CopyValue.tsx +49 -0
  28. package/src/components/MemoryInbox.tsx +74 -0
  29. package/src/components/RetrievedMemories.tsx +36 -0
  30. package/src/components/TopNav.tsx +39 -0
  31. package/src/components/WalletConnectButton.tsx +68 -0
  32. package/src/components/inbox/EmptyInbox.tsx +20 -0
  33. package/src/components/inbox/InboxStats.tsx +41 -0
  34. package/src/components/inbox/MemoryCandidateList.tsx +90 -0
  35. package/src/components/inbox/MemoryCandidateRow.tsx +195 -0
  36. package/src/components/memory-cache/CachedMemoryList.tsx +47 -0
  37. package/src/components/memory-cache/EmptyCache.tsx +13 -0
  38. package/src/hooks/useCommitFlow.ts +55 -0
  39. package/src/hooks/useMemoryCache.ts +44 -0
  40. package/src/hooks/usePendingMemories.ts +137 -0
  41. package/src/hooks/useWallet.ts +69 -0
  42. package/src/lib/api.ts +71 -0
  43. package/src/lib/wallet.ts +88 -0
  44. package/src/services/commitMemories.ts +153 -0
  45. package/src/services/signerApi.ts +67 -0
  46. package/src/services/types.ts +22 -0
  47. package/src/stores/appStore.ts +18 -0
  48. package/src/stores/createStore.ts +41 -0
  49. package/src/stores/slices/memoryCacheSlice.ts +29 -0
  50. package/src/stores/slices/pendingMemorySlice.ts +21 -0
  51. package/src/stores/slices/walletSlice.ts +24 -0
  52. package/src/styles/base.css +61 -0
  53. package/src/styles/buttons.css +53 -0
  54. package/src/styles/data-display.css +485 -0
  55. package/src/styles/forms.css +86 -0
  56. package/src/styles/landing.css +75 -0
  57. package/src/styles/layout.css +111 -0
  58. package/src/styles/navigation.css +121 -0
  59. package/src/styles/overlays.css +65 -0
  60. package/src/styles/tokens.css +26 -0
  61. package/src/styles/utilities.css +358 -0
  62. package/src/utils/errors.ts +5 -0
  63. package/src/utils/format.ts +37 -0
  64. package/tsconfig.json +44 -0
@@ -0,0 +1,111 @@
1
+ .page {
2
+ width: 100%;
3
+ max-width: 1120px;
4
+ margin: 0 auto;
5
+ padding: 56px 32px;
6
+ }
7
+
8
+ .page--narrow {
9
+ max-width: 720px;
10
+ }
11
+
12
+ .page--hero {
13
+ padding: 96px 32px 88px;
14
+ max-width: 980px;
15
+ }
16
+
17
+ .page-header {
18
+ display: flex;
19
+ align-items: flex-end;
20
+ justify-content: space-between;
21
+ gap: 24px;
22
+ margin-bottom: 36px;
23
+ flex-wrap: wrap;
24
+ }
25
+
26
+ .page-header__title {
27
+ display: flex;
28
+ flex-direction: column;
29
+ gap: 8px;
30
+ }
31
+
32
+ .page-header__actions {
33
+ display: flex;
34
+ gap: 10px;
35
+ align-items: center;
36
+ }
37
+
38
+ .eyebrow {
39
+ display: inline-flex;
40
+ align-items: center;
41
+ gap: 7px;
42
+ font-family: var(--font-mono), ui-monospace, monospace;
43
+ font-size: 11px;
44
+ color: var(--accent);
45
+ letter-spacing: 0.04em;
46
+ }
47
+
48
+ .eyebrow__dot {
49
+ width: 6px;
50
+ height: 6px;
51
+ border-radius: 999px;
52
+ background: var(--accent);
53
+ box-shadow: 0 0 0 3px var(--accent-soft);
54
+ }
55
+
56
+ .h1 {
57
+ font-family: var(--font-display), var(--font-sans), sans-serif;
58
+ font-weight: 500;
59
+ font-size: 36px;
60
+ line-height: 1.05;
61
+ letter-spacing: -0.025em;
62
+ color: var(--ink);
63
+ margin: 0;
64
+ }
65
+
66
+ .h1--hero {
67
+ font-size: clamp(56px, 8vw, 88px);
68
+ line-height: 1.02;
69
+ letter-spacing: -0.035em;
70
+ max-width: 800px;
71
+ }
72
+
73
+ .h2 {
74
+ font-family: var(--font-display), var(--font-sans), sans-serif;
75
+ font-weight: 500;
76
+ font-size: 28px;
77
+ line-height: 1.1;
78
+ letter-spacing: -0.02em;
79
+ color: var(--ink);
80
+ margin: 0;
81
+ }
82
+
83
+ .lede {
84
+ font-size: 16px;
85
+ line-height: 1.55;
86
+ color: var(--text-muted);
87
+ margin: 0;
88
+ max-width: 560px;
89
+ }
90
+
91
+ .lede--hero {
92
+ font-size: 17px;
93
+ }
94
+
95
+ .body-dim {
96
+ font-size: 13.5px;
97
+ color: var(--text-dim);
98
+ margin: 0;
99
+ }
100
+
101
+ .accent-text {
102
+ color: var(--accent);
103
+ }
104
+
105
+ /* Keep the route shell full-width; .page owns content width. */
106
+ .shell {
107
+ padding-top: 0;
108
+ padding-bottom: 0;
109
+ width: 100%;
110
+ max-width: none;
111
+ }
@@ -0,0 +1,121 @@
1
+ .topnav {
2
+ display: flex;
3
+ align-items: center;
4
+ justify-content: space-between;
5
+ gap: 20px;
6
+ padding: 18px 28px;
7
+ border-bottom: 1px solid var(--border);
8
+ background: rgba(20, 20, 20, 0.7);
9
+ backdrop-filter: blur(6px);
10
+ position: sticky;
11
+ top: 0;
12
+ z-index: 10;
13
+ }
14
+
15
+ .topnav__brand {
16
+ display: flex;
17
+ align-items: center;
18
+ gap: 10px;
19
+ font-weight: 600;
20
+ font-size: 14px;
21
+ color: var(--ink);
22
+ }
23
+
24
+ .topnav__mark {
25
+ width: 32px;
26
+ height: 32px;
27
+ display: block;
28
+ image-rendering: pixelated;
29
+ image-rendering: crisp-edges;
30
+ object-fit: contain;
31
+ }
32
+
33
+ .topnav__links {
34
+ display: flex;
35
+ gap: 24px;
36
+ font-size: 13.5px;
37
+ margin-left: auto;
38
+ }
39
+
40
+ .topnav__links a {
41
+ color: var(--text-dim);
42
+ transition: color 0.15s ease;
43
+ }
44
+
45
+ .topnav__links a:hover {
46
+ color: var(--text);
47
+ }
48
+
49
+ .topnav__links a.is-active {
50
+ color: var(--ink);
51
+ }
52
+
53
+ .topnav__right {
54
+ display: flex;
55
+ justify-content: flex-end;
56
+ min-width: 0;
57
+ }
58
+
59
+ .wallet-nav {
60
+ display: flex;
61
+ align-items: center;
62
+ gap: 10px;
63
+ min-width: 0;
64
+ }
65
+
66
+ .wallet-nav__button {
67
+ display: inline-flex;
68
+ align-items: center;
69
+ justify-content: center;
70
+ gap: 8px;
71
+ min-height: 34px;
72
+ padding: 0 12px;
73
+ border: 1px solid var(--border-hover);
74
+ border-radius: 7px;
75
+ background: rgba(34, 34, 34, 0.82);
76
+ color: var(--text);
77
+ cursor: pointer;
78
+ font-family: var(--font-mono), ui-monospace, monospace;
79
+ font-size: 12px;
80
+ white-space: nowrap;
81
+ transition: border-color 0.12s ease, color 0.15s ease;
82
+ }
83
+
84
+ .wallet-nav__button:hover:not(:disabled) {
85
+ border-color: var(--text-dim);
86
+ color: var(--ink);
87
+ }
88
+
89
+ .wallet-nav__button:disabled {
90
+ opacity: 0.55;
91
+ cursor: not-allowed;
92
+ }
93
+
94
+ .wallet-nav__dot {
95
+ width: 6px;
96
+ height: 6px;
97
+ border-radius: 50%;
98
+ background: var(--accent);
99
+ box-shadow: 0 0 12px rgba(110, 231, 183, 0.6);
100
+ }
101
+
102
+ .wallet-nav__dot--off {
103
+ background: var(--text-faint);
104
+ box-shadow: none;
105
+ }
106
+
107
+ .wallet-nav__balance {
108
+ font-family: var(--font-mono), ui-monospace, monospace;
109
+ font-size: 11px;
110
+ color: var(--text-dim);
111
+ white-space: nowrap;
112
+ }
113
+
114
+ .wallet-nav__error {
115
+ max-width: 220px;
116
+ overflow: hidden;
117
+ color: var(--danger);
118
+ font-size: 12px;
119
+ text-overflow: ellipsis;
120
+ white-space: nowrap;
121
+ }
@@ -0,0 +1,65 @@
1
+ .modal-v2-backdrop {
2
+ position: fixed;
3
+ inset: 0;
4
+ display: grid;
5
+ place-items: center;
6
+ padding: 20px;
7
+ background: rgba(0, 0, 0, 0.6);
8
+ backdrop-filter: blur(4px);
9
+ z-index: 50;
10
+ }
11
+
12
+ .modal-v2 {
13
+ width: min(560px, calc(100vw - 40px));
14
+ max-height: calc(100dvh - 40px);
15
+ overflow: auto;
16
+ background: var(--surface);
17
+ border: 1px solid var(--border-hover);
18
+ border-radius: 12px;
19
+ box-shadow: 0 30px 80px rgba(0, 0, 0, 0.6);
20
+ display: flex;
21
+ flex-direction: column;
22
+ }
23
+
24
+ .modal-v2__head {
25
+ padding: 22px 26px 16px;
26
+ border-bottom: 1px solid var(--border);
27
+ display: flex;
28
+ justify-content: space-between;
29
+ align-items: flex-start;
30
+ gap: 16px;
31
+ }
32
+
33
+ .modal-v2__title {
34
+ font-family: var(--font-display), var(--font-sans), sans-serif;
35
+ font-size: 22px;
36
+ font-weight: 500;
37
+ color: var(--ink);
38
+ letter-spacing: -0.015em;
39
+ margin: 0;
40
+ }
41
+
42
+ .modal-v2__close {
43
+ background: transparent;
44
+ border: none;
45
+ color: var(--text-dim);
46
+ cursor: pointer;
47
+ padding: 4px;
48
+ }
49
+
50
+ .modal-v2__close:hover {
51
+ color: var(--ink);
52
+ }
53
+
54
+ .modal-v2__body {
55
+ padding: 20px 26px;
56
+ }
57
+
58
+ .modal-v2__foot {
59
+ padding: 16px 26px;
60
+ border-top: 1px solid var(--border);
61
+ display: flex;
62
+ justify-content: flex-end;
63
+ gap: 10px;
64
+ background: var(--bg);
65
+ }
@@ -0,0 +1,26 @@
1
+ :root {
2
+ /* Surfaces */
3
+ --bg: #141414;
4
+ --surface: #1a1a1a;
5
+ --surface-2: #181d1b;
6
+
7
+ /* Borders */
8
+ --border: #242424;
9
+ --border-strong: #2c2c2c;
10
+ --border-hover: #383838;
11
+
12
+ /* Text */
13
+ --ink: #fafafa;
14
+ --text: #e5e5e5;
15
+ --text-muted: #a3a3a3;
16
+ --text-dim: #737373;
17
+ --text-faint: #525252;
18
+
19
+ /* Accent */
20
+ --accent: #6ee7b7;
21
+ --accent-hover: #5ee0aa;
22
+ --accent-soft: rgba(110, 231, 183, 0.08);
23
+
24
+ /* Signals */
25
+ --danger: #f87171;
26
+ }
@@ -0,0 +1,358 @@
1
+ .bar {
2
+ display: inline-block;
3
+ width: 36px;
4
+ height: 4px;
5
+ border-radius: 2px;
6
+ background: rgba(255, 255, 255, 0.08);
7
+ position: relative;
8
+ vertical-align: middle;
9
+ overflow: hidden;
10
+ }
11
+
12
+ .bar__fill {
13
+ display: block;
14
+ height: 100%;
15
+ background: var(--accent);
16
+ border-radius: 2px;
17
+ }
18
+
19
+ .bar--dim .bar__fill {
20
+ background: var(--text-muted);
21
+ }
22
+
23
+ .copy-value {
24
+ display: inline-flex;
25
+ align-items: center;
26
+ justify-content: flex-end;
27
+ gap: 8px;
28
+ background: transparent;
29
+ border: none;
30
+ cursor: pointer;
31
+ font: inherit;
32
+ font-family: var(--font-mono), ui-monospace, monospace;
33
+ color: var(--text-muted);
34
+ padding: 0;
35
+ text-align: right;
36
+ overflow-wrap: anywhere;
37
+ min-width: 0;
38
+ max-width: 100%;
39
+ }
40
+
41
+ .copy-value__icon {
42
+ display: inline-flex;
43
+ flex-shrink: 0;
44
+ color: var(--text-faint);
45
+ transition: color 0.15s ease;
46
+ }
47
+
48
+ .copy-value__text {
49
+ min-width: 0;
50
+ overflow: hidden;
51
+ text-overflow: ellipsis;
52
+ white-space: nowrap;
53
+ }
54
+
55
+ .copy-value:hover .copy-value__icon,
56
+ .copy-value--copied .copy-value__icon {
57
+ color: var(--accent);
58
+ }
59
+
60
+ .spinner {
61
+ display: inline-block;
62
+ width: 12px;
63
+ height: 12px;
64
+ border: 1.5px solid currentColor;
65
+ border-top-color: transparent;
66
+ border-radius: 50%;
67
+ animation: spinner-rotate 0.7s linear infinite;
68
+ }
69
+
70
+ @keyframes spinner-rotate {
71
+ to {
72
+ transform: rotate(360deg);
73
+ }
74
+ }
75
+
76
+ .snippet {
77
+ display: block;
78
+ padding: 12px 14px;
79
+ border: 1px solid var(--border-strong);
80
+ border-radius: 8px;
81
+ background: rgba(30, 30, 30, 0.65);
82
+ font-family: var(--font-mono), ui-monospace, monospace;
83
+ font-size: 12.5px;
84
+ color: var(--text);
85
+ overflow-wrap: anywhere;
86
+ margin: 12px 0 0;
87
+ }
88
+
89
+ .snippet__prompt {
90
+ color: var(--text-faint);
91
+ user-select: none;
92
+ margin-right: 8px;
93
+ }
94
+
95
+ .meta {
96
+ display: flex;
97
+ flex-wrap: wrap;
98
+ align-items: center;
99
+ gap: 10px;
100
+ margin-top: 10px;
101
+ font-family: var(--font-mono), ui-monospace, monospace;
102
+ font-size: 11px;
103
+ color: var(--text-dim);
104
+ }
105
+
106
+ .meta__sep {
107
+ color: var(--text-faint);
108
+ }
109
+
110
+ .meta__kind {
111
+ color: var(--accent);
112
+ }
113
+
114
+ .row-actions {
115
+ display: flex;
116
+ gap: 6px;
117
+ align-self: flex-start;
118
+ opacity: 0;
119
+ transition: opacity 0.15s ease;
120
+ }
121
+
122
+ .list__row:hover .row-actions,
123
+ .list__row.is-selected .row-actions,
124
+ .row-actions.is-visible {
125
+ opacity: 1;
126
+ }
127
+
128
+ .row-action {
129
+ padding: 6px 10px;
130
+ background: transparent;
131
+ border: 1px solid var(--border-hover);
132
+ color: var(--text-muted);
133
+ border-radius: 6px;
134
+ font-size: 12px;
135
+ cursor: pointer;
136
+ transition: color 0.15s ease, border-color 0.12s ease;
137
+ font-family: var(--font-sans), sans-serif;
138
+ }
139
+
140
+ .row-action:hover {
141
+ color: var(--ink);
142
+ border-color: var(--text-dim);
143
+ }
144
+
145
+ .row-action--danger:hover {
146
+ color: var(--danger);
147
+ border-color: var(--danger);
148
+ }
149
+
150
+ .status-pill {
151
+ display: inline-flex;
152
+ align-items: center;
153
+ justify-content: center;
154
+ gap: 6px;
155
+ width: max-content;
156
+ min-width: max-content;
157
+ padding: 3px 10px;
158
+ border-radius: 999px;
159
+ font-family: var(--font-mono), ui-monospace, monospace;
160
+ font-size: 11px;
161
+ color: var(--accent);
162
+ background: var(--accent-soft);
163
+ white-space: nowrap;
164
+ }
165
+
166
+ .status-pill::before {
167
+ content: "";
168
+ flex: 0 0 auto;
169
+ width: 6px;
170
+ height: 6px;
171
+ border-radius: 50%;
172
+ background: var(--accent);
173
+ }
174
+
175
+ .status-pill--warn {
176
+ color: #fbbf24;
177
+ background: rgba(251, 191, 36, 0.08);
178
+ }
179
+
180
+ .status-pill--warn::before {
181
+ background: #fbbf24;
182
+ }
183
+
184
+ .status-pill--off {
185
+ color: var(--text-dim);
186
+ background: transparent;
187
+ border: 1px solid var(--border-hover);
188
+ }
189
+
190
+ .status-pill--off::before {
191
+ background: var(--text-dim);
192
+ }
193
+
194
+ .settings-group {
195
+ border: 1px solid var(--border-strong);
196
+ border-radius: 10px;
197
+ margin-bottom: 16px;
198
+ background: rgba(30, 30, 30, 0.5);
199
+ backdrop-filter: blur(2px);
200
+ }
201
+
202
+ .settings-group__head {
203
+ padding: 16px 20px;
204
+ border-bottom: 1px solid var(--border-strong);
205
+ font-family: var(--font-mono), ui-monospace, monospace;
206
+ font-size: 11px;
207
+ color: var(--text-dim);
208
+ letter-spacing: 0.04em;
209
+ }
210
+
211
+ .settings-row {
212
+ display: grid;
213
+ grid-template-columns: minmax(0, 1fr) minmax(180px, max-content);
214
+ align-items: center;
215
+ justify-content: space-between;
216
+ padding: 16px 20px;
217
+ border-bottom: 1px solid var(--border);
218
+ gap: 24px;
219
+ }
220
+
221
+ .settings-row:last-child {
222
+ border-bottom: none;
223
+ }
224
+
225
+ .settings-row__label {
226
+ font-size: 13.5px;
227
+ color: var(--text);
228
+ }
229
+
230
+ .settings-row__hint {
231
+ font-size: 12px;
232
+ color: var(--text-dim);
233
+ margin-top: 2px;
234
+ max-width: 34rem;
235
+ }
236
+
237
+ .settings-row__value {
238
+ font-family: var(--font-mono), ui-monospace, monospace;
239
+ font-size: 12.5px;
240
+ color: var(--text-muted);
241
+ text-align: right;
242
+ overflow-wrap: anywhere;
243
+ min-width: 0;
244
+ justify-self: end;
245
+ }
246
+
247
+ .settings-row > .copy-value,
248
+ .settings-row > .status-pill {
249
+ justify-self: end;
250
+ }
251
+
252
+ .summary-row {
253
+ display: flex;
254
+ justify-content: space-between;
255
+ align-items: center;
256
+ padding: 10px 0;
257
+ border-bottom: 1px solid var(--border);
258
+ font-size: 13px;
259
+ gap: 16px;
260
+ }
261
+
262
+ .summary-row:last-child {
263
+ border-bottom: none;
264
+ }
265
+
266
+ .summary-row__label {
267
+ color: var(--text-dim);
268
+ }
269
+
270
+ .summary-row__value {
271
+ color: var(--text);
272
+ font-family: var(--font-mono), ui-monospace, monospace;
273
+ font-size: 12px;
274
+ text-align: right;
275
+ overflow-wrap: anywhere;
276
+ }
277
+
278
+ .summary-row__value--accent {
279
+ color: var(--accent);
280
+ }
281
+
282
+ .error-v2 {
283
+ margin-top: 12px;
284
+ padding: 10px 14px;
285
+ border-radius: 7px;
286
+ background: rgba(248, 113, 113, 0.08);
287
+ border: 1px solid rgba(248, 113, 113, 0.3);
288
+ color: #fca5a5;
289
+ font-size: 13px;
290
+ }
291
+
292
+ .v-stack {
293
+ display: flex;
294
+ flex-direction: column;
295
+ gap: 16px;
296
+ }
297
+
298
+ .v-stack--lg {
299
+ gap: 28px;
300
+ }
301
+
302
+ .v-row {
303
+ display: flex;
304
+ align-items: center;
305
+ gap: 12px;
306
+ }
307
+
308
+ .v-row--between {
309
+ justify-content: space-between;
310
+ }
311
+
312
+ @media (max-width: 800px) {
313
+ .topnav {
314
+ align-items: flex-start;
315
+ flex-wrap: wrap;
316
+ padding: 16px 20px;
317
+ }
318
+
319
+ .topnav__links {
320
+ order: 3;
321
+ width: 100%;
322
+ gap: 18px;
323
+ margin-left: 0;
324
+ }
325
+
326
+ .topnav__right {
327
+ margin-left: auto;
328
+ }
329
+
330
+ .wallet-nav__balance,
331
+ .wallet-nav__error {
332
+ display: none;
333
+ }
334
+
335
+ .stat-grid {
336
+ grid-template-columns: repeat(2, 1fr);
337
+ }
338
+
339
+ .stat-grid__cell:nth-child(2) {
340
+ border-right: none;
341
+ }
342
+
343
+ .stat-grid__cell:nth-child(1),
344
+ .stat-grid__cell:nth-child(2) {
345
+ border-bottom: 1px solid var(--border-strong);
346
+ }
347
+
348
+ .settings-row {
349
+ grid-template-columns: 1fr;
350
+ gap: 10px;
351
+ }
352
+
353
+ .settings-row__value,
354
+ .settings-row .copy-value {
355
+ justify-self: start;
356
+ text-align: left;
357
+ }
358
+ }
@@ -0,0 +1,5 @@
1
+ export function readError(error: unknown, fallback = "Something went wrong."): string {
2
+ if (error instanceof Error && error.message) return error.message;
3
+ if (typeof error === "string" && error.length > 0) return error;
4
+ return fallback;
5
+ }
@@ -0,0 +1,37 @@
1
+ export function shortAddress(address: string): string {
2
+ return `${address.slice(0, 6)}...${address.slice(-4)}`;
3
+ }
4
+
5
+ export function percent(value: number): number {
6
+ return Math.round(value * 100);
7
+ }
8
+
9
+ export function formatTimeAgo(iso: string, now: Date = new Date()): string {
10
+ const then = new Date(iso).getTime();
11
+ if (Number.isNaN(then)) return "";
12
+ const diff = Math.max(0, now.getTime() - then);
13
+ const sec = Math.round(diff / 1000);
14
+ if (sec < 45) return "just now";
15
+ const min = Math.round(sec / 60);
16
+ if (min < 60) return `${min}m ago`;
17
+ const hr = Math.round(min / 60);
18
+ if (hr < 24) return `${hr}h ago`;
19
+ const day = Math.round(hr / 24);
20
+ if (day < 30) return `${day}d ago`;
21
+ const mo = Math.round(day / 30);
22
+ if (mo < 12) return `${mo}mo ago`;
23
+ const yr = Math.round(mo / 12);
24
+ return `${yr}y ago`;
25
+ }
26
+
27
+ export function formatScopeLabel(scope: {
28
+ level?: string;
29
+ projectId?: string;
30
+ workspaceId?: string;
31
+ repoPath?: string;
32
+ }): string {
33
+ const level = scope.level ?? (scope.projectId ? "project" : "global");
34
+ const detail = scope.projectId ?? scope.repoPath ?? scope.workspaceId;
35
+ const label = level === "project" ? "Project" : level === "session" ? "Session" : "Global";
36
+ return detail ? `${label} · ${detail}` : label;
37
+ }