@polderlabs/bizar 4.5.2 → 4.7.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/bizar-dash/dist/assets/main-DGGq-iZI.js +361 -0
- package/bizar-dash/dist/assets/main-DGGq-iZI.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-lbH6szyX.js → mobile-CWqPoGaT.js} +18 -18
- package/bizar-dash/dist/assets/mobile-CWqPoGaT.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-BRhoDOUz.js → mobile-i4Uv9eW8.js} +1 -1
- package/bizar-dash/dist/assets/{mobile-BRhoDOUz.js.map → mobile-i4Uv9eW8.js.map} +1 -1
- package/bizar-dash/dist/index.html +2 -2
- package/bizar-dash/dist/mobile.html +2 -2
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/bizar-dash/src/server/logger.mjs +71 -0
- package/bizar-dash/src/server/memory-lightrag.mjs +45 -6
- package/bizar-dash/src/server/metrics.mjs +193 -0
- package/bizar-dash/src/server/routes/chat.mjs +6 -3
- package/bizar-dash/src/server/routes/lightrag.mjs +3 -2
- package/bizar-dash/src/server/routes/memory.mjs +5 -4
- package/bizar-dash/src/server/routes/misc.mjs +2 -1
- package/bizar-dash/src/server/routes/overview.mjs +2 -1
- package/bizar-dash/src/server/server.mjs +62 -0
- package/bizar-dash/src/web/components/SearchModal.tsx +3 -2
- package/bizar-dash/src/web/components/VirtualList.tsx +53 -0
- package/bizar-dash/src/web/components/chat/ChatThread.tsx +17 -11
- package/bizar-dash/src/web/hooks/useI18n.ts +13 -0
- package/bizar-dash/src/web/lib/i18n.ts +25 -0
- package/bizar-dash/src/web/locales/en.json +52 -0
- package/bizar-dash/src/web/main.tsx +5 -0
- package/bizar-dash/src/web/views/Activity.tsx +24 -17
- package/bizar-dash/src/web/views/History.tsx +90 -75
- package/bizar-dash/src/web/views/Overview.tsx +11 -8
- package/bizar-dash/src/web/views/Settings.tsx +7 -1
- package/bizar-dash/tests/cli-bugfixes.test.mjs +4 -4
- package/bizar-dash/tests/cli-refactor.test.mjs +184 -0
- package/bizar-dash/tests/components/Button.test.tsx +41 -0
- package/bizar-dash/tests/components/Card.test.tsx +42 -0
- package/bizar-dash/tests/components/Modal.test.tsx +104 -0
- package/bizar-dash/tests/components/Spinner.test.tsx +32 -0
- package/bizar-dash/tests/components/StatusBadge.test.tsx +35 -0
- package/bizar-dash/tests/components/Toast.test.tsx +108 -0
- package/bizar-dash/tests/hooks/useModal.test.tsx +84 -0
- package/bizar-dash/tests/hooks/useToast.test.tsx +50 -0
- package/bizar-dash/tests/lib/i18n.test.ts +46 -0
- package/bizar-dash/tests/lib/utils.test.ts +194 -0
- package/bizar-dash/tests/logger.test.mjs +207 -0
- package/bizar-dash/tests/metrics.test.mjs +183 -0
- package/bizar-dash/tests/server-bugfixes.test.mjs +2 -2
- package/bizar-dash/tests/setup.ts +7 -0
- package/bizar-dash/vitest.config.ts +13 -0
- package/cli/artifact-cli.mjs +605 -0
- package/cli/artifact-render.mjs +621 -0
- package/cli/artifact-server.mjs +847 -0
- package/cli/artifact.mjs +38 -2096
- package/cli/bg.mjs +5 -13
- package/cli/bin.mjs +149 -1361
- package/cli/commands/artifact.mjs +20 -0
- package/cli/commands/dash.mjs +160 -0
- package/cli/commands/headroom.mjs +204 -0
- package/cli/commands/install.mjs +169 -0
- package/cli/commands/memory.mjs +25 -0
- package/cli/commands/minimax.mjs +265 -0
- package/cli/commands/mod.mjs +185 -0
- package/cli/commands/service.mjs +65 -0
- package/cli/commands/usage.mjs +109 -0
- package/cli/commands/util.mjs +306 -0
- package/cli/doctor.mjs +1 -13
- package/cli/provision.mjs +2 -13
- package/cli/service-controller.mjs +1 -11
- package/cli/service.mjs +1 -11
- package/cli/utils.mjs +41 -1
- package/package.json +6 -1
- package/bizar-dash/dist/assets/main-B4OfGAwz.js +0 -361
- package/bizar-dash/dist/assets/main-B4OfGAwz.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-lbH6szyX.js.map +0 -1
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// src/web/components/VirtualList.tsx — hand-rolled virtual scrolling (no external deps).
|
|
2
|
+
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
|
3
|
+
|
|
4
|
+
export function VirtualList<T>({
|
|
5
|
+
items,
|
|
6
|
+
renderItem,
|
|
7
|
+
itemHeight = 80,
|
|
8
|
+
height = 400,
|
|
9
|
+
overscan = 5,
|
|
10
|
+
className,
|
|
11
|
+
}: {
|
|
12
|
+
items: T[];
|
|
13
|
+
renderItem: (item: T, index: number) => React.ReactNode;
|
|
14
|
+
itemHeight?: number;
|
|
15
|
+
height?: number;
|
|
16
|
+
overscan?: number;
|
|
17
|
+
className?: string;
|
|
18
|
+
}) {
|
|
19
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
20
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
21
|
+
|
|
22
|
+
const visibleRange = useMemo(() => {
|
|
23
|
+
const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
|
|
24
|
+
const end = Math.min(
|
|
25
|
+
items.length,
|
|
26
|
+
Math.ceil((scrollTop + height) / itemHeight) + overscan,
|
|
27
|
+
);
|
|
28
|
+
return { start, end };
|
|
29
|
+
}, [scrollTop, itemHeight, height, overscan, items.length]);
|
|
30
|
+
|
|
31
|
+
const totalHeight = items.length * itemHeight;
|
|
32
|
+
const offsetY = visibleRange.start * itemHeight;
|
|
33
|
+
const visibleItems = items.slice(visibleRange.start, visibleRange.end);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
ref={containerRef}
|
|
38
|
+
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
|
|
39
|
+
style={{ height, overflowY: 'auto' }}
|
|
40
|
+
className={className}
|
|
41
|
+
>
|
|
42
|
+
<div style={{ height: totalHeight, position: 'relative' }}>
|
|
43
|
+
<div style={{ transform: `translateY(${offsetY}px)` }}>
|
|
44
|
+
{visibleItems.map((item, i) => (
|
|
45
|
+
<div key={visibleRange.start + i} style={{ height: itemHeight }}>
|
|
46
|
+
{renderItem(item, visibleRange.start + i)}
|
|
47
|
+
</div>
|
|
48
|
+
))}
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -8,6 +8,7 @@ import { useEffect, useRef } from 'react';
|
|
|
8
8
|
import { LoadingSkeleton } from './LoadingSkeleton';
|
|
9
9
|
import { WelcomeScreen } from './WelcomeScreen';
|
|
10
10
|
import { MessageBubble } from './MessageBubble';
|
|
11
|
+
import { VirtualList } from '../VirtualList';
|
|
11
12
|
|
|
12
13
|
interface Message {
|
|
13
14
|
role?: string;
|
|
@@ -105,17 +106,22 @@ export function ChatThread({
|
|
|
105
106
|
|
|
106
107
|
return (
|
|
107
108
|
<div className="chat-thread legacy" ref={innerRef}>
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
109
|
+
<VirtualList
|
|
110
|
+
items={messages}
|
|
111
|
+
itemHeight={120}
|
|
112
|
+
height={800}
|
|
113
|
+
className="chat-thread-virtual"
|
|
114
|
+
renderItem={(m, i) => (
|
|
115
|
+
<MessageBubble
|
|
116
|
+
message={m}
|
|
117
|
+
pinned={pinned.has(i)}
|
|
118
|
+
onCopy={() => onCopy(m)}
|
|
119
|
+
onDelete={() => onDelete(i)}
|
|
120
|
+
onTogglePin={() => onTogglePin(i)}
|
|
121
|
+
onRegenerate={() => onRegenerate(String(m.ts ?? i))}
|
|
122
|
+
/>
|
|
123
|
+
)}
|
|
124
|
+
/>
|
|
119
125
|
</div>
|
|
120
126
|
);
|
|
121
127
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// src/web/hooks/useI18n.ts — React hook for translations.
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { t, setLocale, getLocale } from '../lib/i18n';
|
|
4
|
+
|
|
5
|
+
export function useI18n() {
|
|
6
|
+
const [, force] = useState(0);
|
|
7
|
+
return {
|
|
8
|
+
t,
|
|
9
|
+
setLocale,
|
|
10
|
+
locale: getLocale(),
|
|
11
|
+
rerender: () => force((n) => n + 1),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// src/web/lib/i18n.ts — tiny key-based translation system.
|
|
2
|
+
export type Locale = 'en' | string;
|
|
3
|
+
export type Translations = Record<string, string>;
|
|
4
|
+
|
|
5
|
+
let currentLocale: Locale = 'en';
|
|
6
|
+
const translations: Record<Locale, Translations> = { en: {} };
|
|
7
|
+
|
|
8
|
+
export function setLocale(locale: Locale, dict?: Translations) {
|
|
9
|
+
currentLocale = locale;
|
|
10
|
+
if (dict) translations[locale] = dict;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function t(key: string, vars?: Record<string, string | number>): string {
|
|
14
|
+
let s = translations[currentLocale]?.[key] || translations['en']?.[key] || key;
|
|
15
|
+
if (vars) {
|
|
16
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
17
|
+
s = s.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return s;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getLocale(): Locale {
|
|
24
|
+
return currentLocale;
|
|
25
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"settings.title": "Settings",
|
|
3
|
+
"settings.general": "General",
|
|
4
|
+
"settings.envVars": "Environment Variables",
|
|
5
|
+
"settings.providers": "Providers",
|
|
6
|
+
"settings.theme": "Theme",
|
|
7
|
+
"settings.themeMode": "Mode",
|
|
8
|
+
"settings.themeAccent": "Accent",
|
|
9
|
+
"settings.layout": "Layout",
|
|
10
|
+
"settings.notifications": "Notifications",
|
|
11
|
+
"settings.activity": "Activity",
|
|
12
|
+
"settings.save": "Save",
|
|
13
|
+
"settings.reset": "Reset",
|
|
14
|
+
"settings.reload": "Reload",
|
|
15
|
+
"settings.dashboard": "Dashboard",
|
|
16
|
+
"settings.background": "Background Agents",
|
|
17
|
+
"settings.systemLlm": "System LLM API",
|
|
18
|
+
"settings.headroom": "Headroom",
|
|
19
|
+
"settings.updates": "Updates",
|
|
20
|
+
"settings.about": "About",
|
|
21
|
+
"settings.auth": "Authentication",
|
|
22
|
+
"settings.agents": "Agent Behavior",
|
|
23
|
+
"settings.service": "Service",
|
|
24
|
+
"settings.tailscale": "Tailscale Serve",
|
|
25
|
+
"chat.title": "Chat",
|
|
26
|
+
"chat.newSession": "New session",
|
|
27
|
+
"chat.sendMessage": "Send message",
|
|
28
|
+
"chat.placeholder": "Type your message…",
|
|
29
|
+
"chat.export": "Export transcript",
|
|
30
|
+
"chat.rename": "Rename session",
|
|
31
|
+
"chat.delete": "Delete session",
|
|
32
|
+
"memory.title": "Memory",
|
|
33
|
+
"memory.overview": "Overview",
|
|
34
|
+
"memory.lightrag": "LightRAG",
|
|
35
|
+
"memory.obsidian": "Obsidian Vault",
|
|
36
|
+
"memory.gitSync": "Git Sync",
|
|
37
|
+
"memory.semantic": "Semantic Search",
|
|
38
|
+
"memory.config": "Config",
|
|
39
|
+
"memory.refresh": "Refresh memory",
|
|
40
|
+
"tasks.title": "Tasks",
|
|
41
|
+
"tasks.create": "Create task",
|
|
42
|
+
"tasks.complete": "Mark complete",
|
|
43
|
+
"tasks.delete": "Delete task",
|
|
44
|
+
"activity.title": "Activity",
|
|
45
|
+
"activity.live": "Live",
|
|
46
|
+
"activity.paused": "Paused",
|
|
47
|
+
"activity.refresh": "Refresh",
|
|
48
|
+
"history.title": "History",
|
|
49
|
+
"history.export": "Export",
|
|
50
|
+
"overview.title": "Overview",
|
|
51
|
+
"overview.submit": "Submit to Odin"
|
|
52
|
+
}
|
|
@@ -3,6 +3,11 @@ import { StrictMode, useEffect, useState } from 'react';
|
|
|
3
3
|
import { createRoot } from 'react-dom/client';
|
|
4
4
|
import { App } from './App';
|
|
5
5
|
import { MobileApp } from './MobileApp';
|
|
6
|
+
import en from './locales/en.json';
|
|
7
|
+
import { setLocale } from './lib/i18n';
|
|
8
|
+
|
|
9
|
+
// Initialize i18n
|
|
10
|
+
setLocale('en', en);
|
|
6
11
|
// Import mobile CSS so the MobileApp renders with styles when served
|
|
7
12
|
// from index.html (e.g. when the auto-redirect to /m is bypassed).
|
|
8
13
|
import './styles/main.css';
|
|
@@ -35,6 +35,7 @@ import { Button } from '../components/Button';
|
|
|
35
35
|
import { Card, CardTitle } from '../components/Card';
|
|
36
36
|
import { EmptyState } from '../components/EmptyState';
|
|
37
37
|
import { Spinner } from '../components/Spinner';
|
|
38
|
+
import { VirtualList } from '../components/VirtualList';
|
|
38
39
|
import { useToast } from '../components/Toast';
|
|
39
40
|
import { useModal } from '../components/Modal';
|
|
40
41
|
import { api } from '../lib/api';
|
|
@@ -776,23 +777,29 @@ export function Activity({ snapshot, refreshSnapshot }: Props) {
|
|
|
776
777
|
<p>No events yet.</p>
|
|
777
778
|
</div>
|
|
778
779
|
) : (
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
780
|
+
<VirtualList
|
|
781
|
+
items={[...events].reverse()}
|
|
782
|
+
itemHeight={40}
|
|
783
|
+
height={400}
|
|
784
|
+
className="tl-stream-virtual"
|
|
785
|
+
renderItem={(ev) => {
|
|
786
|
+
const Icon =
|
|
787
|
+
ev.author ? Bot : ev.kind === 'task' ? CheckSquare : ev.kind === 'bg' ? Cpu : ActivityIcon;
|
|
788
|
+
return (
|
|
789
|
+
<div className="tl-stream-event">
|
|
790
|
+
<span className="tl-stream-event-time">
|
|
791
|
+
{new Date(ev.ts).toLocaleTimeString('en-GB', { hour12: false })}
|
|
792
|
+
</span>
|
|
793
|
+
<span className="tl-stream-event-icon" style={{ color: statusColor(ev.kind) }}>
|
|
794
|
+
<Icon size={12} />
|
|
795
|
+
</span>
|
|
796
|
+
<span className="tl-stream-event-text">
|
|
797
|
+
{ev.author || ev.text || ev.kind}
|
|
798
|
+
</span>
|
|
799
|
+
</div>
|
|
800
|
+
);
|
|
801
|
+
}}
|
|
802
|
+
/>
|
|
796
803
|
)}
|
|
797
804
|
</div>
|
|
798
805
|
</aside>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Shows per-project timelines (task events, plan events, project lifecycle)
|
|
3
3
|
// aggregated from the activity log.
|
|
4
4
|
|
|
5
|
-
import { useEffect, useState } from 'react';
|
|
5
|
+
import React, { useEffect, useState } from 'react';
|
|
6
6
|
import {
|
|
7
7
|
History as HistoryIcon,
|
|
8
8
|
RefreshCw,
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
import { Card, CardTitle, CardMeta } from '../components/Card';
|
|
18
18
|
import { Button } from '../components/Button';
|
|
19
19
|
import { Spinner } from '../components/Spinner';
|
|
20
|
+
import { VirtualList } from '../components/VirtualList';
|
|
20
21
|
import { useToast } from '../components/Toast';
|
|
21
22
|
import { api } from '../lib/api';
|
|
22
23
|
import { cn, formatRelative } from '../lib/utils';
|
|
@@ -65,6 +66,85 @@ const TIME_RANGES = [
|
|
|
65
66
|
{ id: 'all', label: 'All time', ms: 0 },
|
|
66
67
|
];
|
|
67
68
|
|
|
69
|
+
function renderProjectCard(
|
|
70
|
+
p: ProjectHistory,
|
|
71
|
+
events: HistoryEvent[],
|
|
72
|
+
expanded: Set<string>,
|
|
73
|
+
toggleProject: (id: string) => void,
|
|
74
|
+
): React.ReactNode {
|
|
75
|
+
const isOpen = expanded.has(p.id);
|
|
76
|
+
return (
|
|
77
|
+
<Card key={p.id} className="history-project" style={{ marginBottom: 4 }}>
|
|
78
|
+
<div className="history-project-head">
|
|
79
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0, flex: 1 }}>
|
|
80
|
+
<div className="history-project-name">
|
|
81
|
+
<Folder size={16} /> {p.name}
|
|
82
|
+
</div>
|
|
83
|
+
<div className="history-project-meta">{p.path}</div>
|
|
84
|
+
</div>
|
|
85
|
+
<div className="history-project-stats">
|
|
86
|
+
<span className="history-project-stat">
|
|
87
|
+
<span className="history-project-stat-num">{p.tasks.done}</span>
|
|
88
|
+
<span className="muted">/ {p.tasks.total} done</span>
|
|
89
|
+
</span>
|
|
90
|
+
{p.tasks.doing > 0 && (
|
|
91
|
+
<span className="history-project-stat">
|
|
92
|
+
<span className="history-project-stat-num">{p.tasks.doing}</span>
|
|
93
|
+
<span className="muted">doing</span>
|
|
94
|
+
</span>
|
|
95
|
+
)}
|
|
96
|
+
{p.tasks.blocked > 0 && (
|
|
97
|
+
<span className="history-project-stat">
|
|
98
|
+
<span className="history-project-stat-num">{p.tasks.blocked}</span>
|
|
99
|
+
<span className="muted">blocked</span>
|
|
100
|
+
</span>
|
|
101
|
+
)}
|
|
102
|
+
<span className="history-project-stat">
|
|
103
|
+
<FileText size={11} /> <span className="history-project-stat-num">{p.plans}</span>
|
|
104
|
+
<span className="muted"> plan{p.plans === 1 ? '' : 's'}</span>
|
|
105
|
+
</span>
|
|
106
|
+
{p.lastAccessed && (
|
|
107
|
+
<span className="history-project-stat">
|
|
108
|
+
<span className="muted">last opened {formatRelative(p.lastAccessed)}</span>
|
|
109
|
+
</span>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
className="icon-btn"
|
|
115
|
+
onClick={() => toggleProject(p.id)}
|
|
116
|
+
aria-label={isOpen ? 'Collapse' : 'Expand'}
|
|
117
|
+
>
|
|
118
|
+
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
119
|
+
</button>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
{isOpen && (
|
|
123
|
+
<div className="history-timeline-mini">
|
|
124
|
+
{events.length === 0 ? (
|
|
125
|
+
<div className="muted" style={{ padding: '12px 8px', fontSize: 12 }}>
|
|
126
|
+
No events in this time range.
|
|
127
|
+
</div>
|
|
128
|
+
) : (
|
|
129
|
+
events.slice(-100).reverse().map((ev, i) => (
|
|
130
|
+
<div key={i} className="history-timeline-row">
|
|
131
|
+
<span className="history-timeline-ts">
|
|
132
|
+
{new Date(ev.ts).toLocaleTimeString()}
|
|
133
|
+
</span>
|
|
134
|
+
<span className="history-timeline-kind">{ev.kind || 'event'}</span>
|
|
135
|
+
<span className="history-timeline-msg" title={String(ev.text || ev.title || '')}>
|
|
136
|
+
{ev.author ? `@${ev.author} ` : ''}
|
|
137
|
+
{String(ev.text ?? ev.title ?? ev.name ?? '')}
|
|
138
|
+
</span>
|
|
139
|
+
</div>
|
|
140
|
+
))
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
</Card>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
68
148
|
export function History({ snapshot }: Props) {
|
|
69
149
|
const toast = useToast();
|
|
70
150
|
const [data, setData] = useState<HistoryResponse | null>(null);
|
|
@@ -203,80 +283,15 @@ export function History({ snapshot }: Props) {
|
|
|
203
283
|
</Card>
|
|
204
284
|
)}
|
|
205
285
|
|
|
206
|
-
{data.projects.
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
</div>
|
|
216
|
-
<div className="history-project-meta">{p.path}</div>
|
|
217
|
-
</div>
|
|
218
|
-
<div className="history-project-stats">
|
|
219
|
-
<span className="history-project-stat">
|
|
220
|
-
<span className="history-project-stat-num">{p.tasks.done}</span>
|
|
221
|
-
<span className="muted">/ {p.tasks.total} done</span>
|
|
222
|
-
</span>
|
|
223
|
-
{p.tasks.doing > 0 && (
|
|
224
|
-
<span className="history-project-stat">
|
|
225
|
-
<span className="history-project-stat-num">{p.tasks.doing}</span>
|
|
226
|
-
<span className="muted">doing</span>
|
|
227
|
-
</span>
|
|
228
|
-
)}
|
|
229
|
-
{p.tasks.blocked > 0 && (
|
|
230
|
-
<span className="history-project-stat">
|
|
231
|
-
<span className="history-project-stat-num">{p.tasks.blocked}</span>
|
|
232
|
-
<span className="muted">blocked</span>
|
|
233
|
-
</span>
|
|
234
|
-
)}
|
|
235
|
-
<span className="history-project-stat">
|
|
236
|
-
<FileText size={11} /> <span className="history-project-stat-num">{p.plans}</span>
|
|
237
|
-
<span className="muted"> plan{p.plans === 1 ? '' : 's'}</span>
|
|
238
|
-
</span>
|
|
239
|
-
{p.lastAccessed && (
|
|
240
|
-
<span className="history-project-stat">
|
|
241
|
-
<span className="muted">last opened {formatRelative(p.lastAccessed)}</span>
|
|
242
|
-
</span>
|
|
243
|
-
)}
|
|
244
|
-
</div>
|
|
245
|
-
<button
|
|
246
|
-
type="button"
|
|
247
|
-
className="icon-btn"
|
|
248
|
-
onClick={() => toggleProject(p.id)}
|
|
249
|
-
aria-label={isOpen ? 'Collapse' : 'Expand'}
|
|
250
|
-
>
|
|
251
|
-
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
252
|
-
</button>
|
|
253
|
-
</div>
|
|
254
|
-
|
|
255
|
-
{isOpen && (
|
|
256
|
-
<div className="history-timeline-mini">
|
|
257
|
-
{events.length === 0 ? (
|
|
258
|
-
<div className="muted" style={{ padding: '12px 8px', fontSize: 12 }}>
|
|
259
|
-
No events in this time range.
|
|
260
|
-
</div>
|
|
261
|
-
) : (
|
|
262
|
-
events.slice(-100).reverse().map((ev, i) => (
|
|
263
|
-
<div key={i} className="history-timeline-row">
|
|
264
|
-
<span className="history-timeline-ts">
|
|
265
|
-
{new Date(ev.ts).toLocaleTimeString()}
|
|
266
|
-
</span>
|
|
267
|
-
<span className="history-timeline-kind">{ev.kind || 'event'}</span>
|
|
268
|
-
<span className="history-timeline-msg" title={String(ev.text || ev.title || '')}>
|
|
269
|
-
{ev.author ? `@${ev.author} ` : ''}
|
|
270
|
-
{String(ev.text ?? ev.title ?? ev.name ?? '')}
|
|
271
|
-
</span>
|
|
272
|
-
</div>
|
|
273
|
-
))
|
|
274
|
-
)}
|
|
275
|
-
</div>
|
|
276
|
-
)}
|
|
277
|
-
</Card>
|
|
278
|
-
);
|
|
279
|
-
})}
|
|
286
|
+
{data.projects.length > 0 && (
|
|
287
|
+
<VirtualList
|
|
288
|
+
items={data.projects}
|
|
289
|
+
itemHeight={70}
|
|
290
|
+
height={Math.min(data.projects.length * 70, 500)}
|
|
291
|
+
className="history-virtual-list"
|
|
292
|
+
renderItem={(p) => renderProjectCard(p, eventsByProject.get(p.id) || [], expanded, toggleProject)}
|
|
293
|
+
/>
|
|
294
|
+
)}
|
|
280
295
|
|
|
281
296
|
{/* Global / unassigned events */}
|
|
282
297
|
{eventsByProject.has('global') && (
|
|
@@ -33,6 +33,7 @@ import { Button } from '../components/Button';
|
|
|
33
33
|
import { MemoryStatusCard } from './memory/MemoryStatusCard';
|
|
34
34
|
import { EmptyState } from '../components/EmptyState';
|
|
35
35
|
import { Spinner } from '../components/Spinner';
|
|
36
|
+
import { VirtualList } from '../components/VirtualList';
|
|
36
37
|
import { useToast } from '../components/Toast';
|
|
37
38
|
import { useModal } from '../components/Modal';
|
|
38
39
|
import { FileBrowser } from '../components/FileBrowser';
|
|
@@ -367,21 +368,23 @@ function OverviewInner({
|
|
|
367
368
|
</div>
|
|
368
369
|
) : (
|
|
369
370
|
<div className={cn('activity-feed-list-wrap', !activityExpanded && 'activity-feed-list-wrap-collapsed')}>
|
|
370
|
-
<
|
|
371
|
-
{activityItems.slice(0, 30).
|
|
372
|
-
|
|
373
|
-
|
|
371
|
+
<VirtualList
|
|
372
|
+
items={activityItems.slice(0, 30).filter((it, idx) => !hiddenKeys.has(itemKey(it, idx)))}
|
|
373
|
+
itemHeight={60}
|
|
374
|
+
height={Math.min(activityItems.filter((it, idx) => !hiddenKeys.has(itemKey(it, idx))).length * 60, 480)}
|
|
375
|
+
className="activity-feed-list"
|
|
376
|
+
renderItem={(it, vlIdx) => {
|
|
377
|
+
const originalIdx = activityItems.slice(0, 30).findIndex((a) => a === it);
|
|
374
378
|
return (
|
|
375
379
|
<ActivityFeedItem
|
|
376
|
-
key={`${it.ts}-${idx}`}
|
|
377
380
|
item={it}
|
|
378
|
-
activityKey={
|
|
381
|
+
activityKey={itemKey(it, originalIdx)}
|
|
379
382
|
onNavigate={setActiveTab}
|
|
380
383
|
onHide={onHide}
|
|
381
384
|
/>
|
|
382
385
|
);
|
|
383
|
-
}
|
|
384
|
-
|
|
386
|
+
}}
|
|
387
|
+
/>
|
|
385
388
|
{!activityExpanded && activityItems.length > 8 && <div className="activity-feed-fade" aria-hidden="true" />}
|
|
386
389
|
</div>
|
|
387
390
|
)}
|
|
@@ -888,6 +888,7 @@ function SettingsViewInner({ settings: initial, refreshSnapshot }: Props) {
|
|
|
888
888
|
className="input"
|
|
889
889
|
value={settings.theme.accent}
|
|
890
890
|
onChange={(e) => patchTheme({ accent: e.target.value })}
|
|
891
|
+
aria-label="Accent color hex"
|
|
891
892
|
/>
|
|
892
893
|
</div>
|
|
893
894
|
</div>
|
|
@@ -906,6 +907,7 @@ function SettingsViewInner({ settings: initial, refreshSnapshot }: Props) {
|
|
|
906
907
|
className="input"
|
|
907
908
|
value={settings.theme.success}
|
|
908
909
|
onChange={(e) => patchTheme({ success: e.target.value })}
|
|
910
|
+
aria-label="Success color hex"
|
|
909
911
|
/>
|
|
910
912
|
</div>
|
|
911
913
|
</div>
|
|
@@ -924,6 +926,7 @@ function SettingsViewInner({ settings: initial, refreshSnapshot }: Props) {
|
|
|
924
926
|
className="input"
|
|
925
927
|
value={settings.theme.warning}
|
|
926
928
|
onChange={(e) => patchTheme({ warning: e.target.value })}
|
|
929
|
+
aria-label="Warning color hex"
|
|
927
930
|
/>
|
|
928
931
|
</div>
|
|
929
932
|
</div>
|
|
@@ -942,6 +945,7 @@ function SettingsViewInner({ settings: initial, refreshSnapshot }: Props) {
|
|
|
942
945
|
className="input"
|
|
943
946
|
value={settings.theme.error}
|
|
944
947
|
onChange={(e) => patchTheme({ error: e.target.value })}
|
|
948
|
+
aria-label="Error color hex"
|
|
945
949
|
/>
|
|
946
950
|
</div>
|
|
947
951
|
</div>
|
|
@@ -960,6 +964,7 @@ function SettingsViewInner({ settings: initial, refreshSnapshot }: Props) {
|
|
|
960
964
|
className="input"
|
|
961
965
|
value={settings.theme.info}
|
|
962
966
|
onChange={(e) => patchTheme({ info: e.target.value })}
|
|
967
|
+
aria-label="Info color hex"
|
|
963
968
|
/>
|
|
964
969
|
</div>
|
|
965
970
|
</div>
|
|
@@ -1144,8 +1149,9 @@ function SettingsViewInner({ settings: initial, refreshSnapshot }: Props) {
|
|
|
1144
1149
|
/>
|
|
1145
1150
|
</div>
|
|
1146
1151
|
<div className="task-form-field">
|
|
1147
|
-
<label className="field-label">Use HTTPS</label>
|
|
1152
|
+
<label className="field-label" htmlFor="tailscale-https">Use HTTPS</label>
|
|
1148
1153
|
<input
|
|
1154
|
+
id="tailscale-https"
|
|
1149
1155
|
type="checkbox"
|
|
1150
1156
|
checked={tailscaleDraft.https}
|
|
1151
1157
|
onChange={(e) => setTailscaleDraft((cur) => ({ ...cur, https: e.target.checked }))}
|
|
@@ -20,7 +20,7 @@ const REPO = join(process.cwd());
|
|
|
20
20
|
describe('B1 — parseWithModsFlag', () => {
|
|
21
21
|
it('exits with code 2 when --with-mods is the last arg', async () => {
|
|
22
22
|
// Import fresh each time to avoid module-level side effects
|
|
23
|
-
const { parseWithModsFlag } = await import('../../cli/
|
|
23
|
+
const { parseWithModsFlag } = await import('../../cli/commands/install.mjs');
|
|
24
24
|
let exitCode = null;
|
|
25
25
|
const origExit = process.exit;
|
|
26
26
|
// Mock process.exit to capture the code without terminating the test
|
|
@@ -36,7 +36,7 @@ describe('B1 — parseWithModsFlag', () => {
|
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
it('exits with code 2 when next arg is another flag', async () => {
|
|
39
|
-
const { parseWithModsFlag } = await import('../../cli/
|
|
39
|
+
const { parseWithModsFlag } = await import('../../cli/commands/install.mjs');
|
|
40
40
|
let exitCode = null;
|
|
41
41
|
const origExit = process.exit;
|
|
42
42
|
try {
|
|
@@ -51,7 +51,7 @@ describe('B1 — parseWithModsFlag', () => {
|
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
it('returns null when --with-mods is not present', async () => {
|
|
54
|
-
const { parseWithModsFlag } = await import('../../cli/
|
|
54
|
+
const { parseWithModsFlag } = await import('../../cli/commands/install.mjs');
|
|
55
55
|
let exitCode = null;
|
|
56
56
|
const origExit = process.exit;
|
|
57
57
|
try {
|
|
@@ -65,7 +65,7 @@ describe('B1 — parseWithModsFlag', () => {
|
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
it('returns array of mod ids when value is provided', async () => {
|
|
68
|
-
const { parseWithModsFlag } = await import('../../cli/
|
|
68
|
+
const { parseWithModsFlag } = await import('../../cli/commands/install.mjs');
|
|
69
69
|
let exitCode = null;
|
|
70
70
|
const origExit = process.exit;
|
|
71
71
|
try {
|