@archznn/crewloop-skills 0.5.0 → 0.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/README.md +4 -16
- package/package.json +3 -3
- package/packages/cli/dist/agents.js +1 -1
- package/packages/cli/dist/agents.js.map +1 -1
- package/packages/cli/dist/cli.d.ts.map +1 -1
- package/packages/cli/dist/cli.js +31 -37
- package/packages/cli/dist/cli.js.map +1 -1
- package/packages/cli/dist/hooks.d.ts +6 -4
- package/packages/cli/dist/hooks.d.ts.map +1 -1
- package/packages/cli/dist/hooks.js +258 -98
- package/packages/cli/dist/hooks.js.map +1 -1
- package/packages/cli/dist/tests/cli.test.js +21 -0
- package/packages/cli/dist/tests/cli.test.js.map +1 -1
- package/packages/cli/dist/tests/hooks.test.js +253 -27
- package/packages/cli/dist/tests/hooks.test.js.map +1 -1
- package/references/conventions.md +1 -10
- package/references/workflow.md +1 -1
- package/servers/dashboard/README.md +55 -1
- package/servers/dashboard/bin/crewloop-shim.js +4 -0
- package/servers/dashboard/dist/adapters/agy.d.ts +19 -0
- package/servers/dashboard/dist/adapters/agy.d.ts.map +1 -0
- package/servers/dashboard/dist/adapters/agy.js +108 -0
- package/servers/dashboard/dist/adapters/agy.js.map +1 -0
- package/servers/dashboard/dist/adapters/codex.d.ts.map +1 -1
- package/servers/dashboard/dist/adapters/codex.js +2 -0
- package/servers/dashboard/dist/adapters/codex.js.map +1 -1
- package/servers/dashboard/dist/adapters/kimi.d.ts +1 -1
- package/servers/dashboard/dist/adapters/kimi.d.ts.map +1 -1
- package/servers/dashboard/dist/adapters/kimi.js +9 -0
- package/servers/dashboard/dist/adapters/kimi.js.map +1 -1
- package/servers/dashboard/dist/adapters/shim.d.ts +1 -1
- package/servers/dashboard/dist/adapters/shim.d.ts.map +1 -1
- package/servers/dashboard/dist/adapters/shim.js +32 -11
- package/servers/dashboard/dist/adapters/shim.js.map +1 -1
- package/servers/dashboard/dist/adapters/shim.test.js +46 -4
- package/servers/dashboard/dist/adapters/shim.test.js.map +1 -1
- package/servers/dashboard/dist/lib/constants.d.ts +5 -0
- package/servers/dashboard/dist/lib/constants.d.ts.map +1 -0
- package/servers/dashboard/dist/lib/constants.js +46 -0
- package/servers/dashboard/dist/lib/constants.js.map +1 -0
- package/servers/dashboard/dist/lib/format.d.ts +6 -0
- package/servers/dashboard/dist/lib/format.d.ts.map +1 -0
- package/servers/dashboard/dist/lib/format.js +52 -0
- package/servers/dashboard/dist/lib/format.js.map +1 -0
- package/servers/dashboard/dist/lib/graph.d.ts +22 -0
- package/servers/dashboard/dist/lib/graph.d.ts.map +1 -0
- package/servers/dashboard/dist/lib/graph.js +45 -0
- package/servers/dashboard/dist/lib/graph.js.map +1 -0
- package/servers/dashboard/dist/lib/invocations.d.ts +32 -0
- package/servers/dashboard/dist/lib/invocations.d.ts.map +1 -0
- package/servers/dashboard/dist/lib/invocations.js +135 -0
- package/servers/dashboard/dist/lib/invocations.js.map +1 -0
- package/servers/dashboard/dist/lib/invocations.test.d.ts +2 -0
- package/servers/dashboard/dist/lib/invocations.test.d.ts.map +1 -0
- package/servers/dashboard/dist/lib/invocations.test.js +68 -0
- package/servers/dashboard/dist/lib/invocations.test.js.map +1 -0
- package/servers/dashboard/dist/lib/paths.d.ts +2 -0
- package/servers/dashboard/dist/lib/paths.d.ts.map +1 -0
- package/servers/dashboard/dist/lib/paths.js +40 -0
- package/servers/dashboard/dist/lib/paths.js.map +1 -0
- package/servers/dashboard/dist/presenter.d.ts.map +1 -1
- package/servers/dashboard/dist/presenter.js +2 -0
- package/servers/dashboard/dist/presenter.js.map +1 -1
- package/servers/dashboard/dist/public/assets/index-DjmMKbPN.css +1 -0
- package/servers/dashboard/dist/public/assets/index-DzOqMleZ.js +5323 -0
- package/servers/dashboard/dist/public/assets/index-DzOqMleZ.js.map +1 -0
- package/servers/dashboard/dist/public/index.html +16 -0
- package/servers/dashboard/dist/server.d.ts.map +1 -1
- package/servers/dashboard/dist/server.js +5 -1
- package/servers/dashboard/dist/server.js.map +1 -1
- package/servers/dashboard/dist/skills/infer.d.ts.map +1 -1
- package/servers/dashboard/dist/skills/infer.js +0 -6
- package/servers/dashboard/dist/skills/infer.js.map +1 -1
- package/servers/dashboard/dist/skills/infer.test.js +10 -3
- package/servers/dashboard/dist/skills/infer.test.js.map +1 -1
- package/servers/dashboard/dist/skills/mapping.d.ts +0 -3
- package/servers/dashboard/dist/skills/mapping.d.ts.map +1 -1
- package/servers/dashboard/dist/skills/mapping.js +0 -18
- package/servers/dashboard/dist/skills/mapping.js.map +1 -1
- package/servers/dashboard/dist/skills/registry.d.ts.map +1 -1
- package/servers/dashboard/dist/skills/registry.js +0 -1
- package/servers/dashboard/dist/skills/registry.js.map +1 -1
- package/servers/dashboard/dist/tests/adapters.test.d.ts +2 -0
- package/servers/dashboard/dist/tests/adapters.test.d.ts.map +1 -0
- package/servers/dashboard/dist/tests/adapters.test.js +180 -0
- package/servers/dashboard/dist/tests/adapters.test.js.map +1 -0
- package/servers/dashboard/dist/tests/lib-helpers.test.d.ts +2 -0
- package/servers/dashboard/dist/tests/lib-helpers.test.d.ts.map +1 -0
- package/servers/dashboard/dist/tests/lib-helpers.test.js +123 -0
- package/servers/dashboard/dist/tests/lib-helpers.test.js.map +1 -0
- package/servers/dashboard/dist/tests/shim.test.d.ts +2 -0
- package/servers/dashboard/dist/tests/shim.test.d.ts.map +1 -0
- package/servers/dashboard/dist/tests/shim.test.js +133 -0
- package/servers/dashboard/dist/tests/shim.test.js.map +1 -0
- package/servers/dashboard/dist/types.d.ts +5 -2
- package/servers/dashboard/dist/types.d.ts.map +1 -1
- package/servers/dashboard/package.json +24 -6
- package/servers/dashboard/src/adapters/agy.ts +136 -0
- package/servers/dashboard/src/adapters/codex.ts +2 -0
- package/servers/dashboard/src/adapters/kimi.ts +11 -1
- package/servers/dashboard/src/adapters/shim.test.ts +57 -4
- package/servers/dashboard/src/adapters/shim.ts +31 -11
- package/servers/dashboard/src/lib/constants.ts +44 -0
- package/servers/dashboard/src/lib/format.ts +44 -0
- package/servers/dashboard/src/lib/graph.ts +69 -0
- package/servers/dashboard/src/lib/invocations.test.ts +70 -0
- package/servers/dashboard/src/lib/invocations.ts +172 -0
- package/servers/dashboard/src/lib/paths.ts +35 -0
- package/servers/dashboard/src/presenter.ts +2 -0
- package/servers/dashboard/src/server.ts +5 -1
- package/servers/dashboard/src/skills/infer.test.ts +11 -3
- package/servers/dashboard/src/skills/infer.ts +1 -8
- package/servers/dashboard/src/skills/mapping.ts +0 -20
- package/servers/dashboard/src/skills/registry.ts +0 -1
- package/servers/dashboard/src/tests/adapters.test.ts +198 -0
- package/servers/dashboard/src/tests/lib-helpers.test.ts +133 -0
- package/servers/dashboard/src/tests/shim.test.ts +153 -0
- package/servers/dashboard/src/types.ts +5 -3
- package/servers/dashboard/ui/index.html +15 -0
- package/servers/dashboard/ui/postcss.config.js +6 -0
- package/servers/dashboard/ui/src/App.tsx +360 -0
- package/servers/dashboard/ui/src/components/ActiveSkillPanel.tsx +69 -0
- package/servers/dashboard/ui/src/components/ActivityGraph.tsx +74 -0
- package/servers/dashboard/ui/src/components/CommandPalette.tsx +200 -0
- package/servers/dashboard/ui/src/components/FileActivity.tsx +20 -0
- package/servers/dashboard/ui/src/components/FileDiff.tsx +68 -0
- package/servers/dashboard/ui/src/components/FileList.tsx +64 -0
- package/servers/dashboard/ui/src/components/FilterBar.tsx +208 -0
- package/servers/dashboard/ui/src/components/Network3D.tsx +178 -0
- package/servers/dashboard/ui/src/components/SessionSelector.tsx +95 -0
- package/servers/dashboard/ui/src/components/Sidebar.tsx +110 -0
- package/servers/dashboard/ui/src/components/TelemetryPanel.tsx +57 -0
- package/servers/dashboard/ui/src/components/Timeline.tsx +57 -0
- package/servers/dashboard/ui/src/components/TimelineRow.tsx +112 -0
- package/servers/dashboard/ui/src/components/TopBar.tsx +116 -0
- package/servers/dashboard/ui/src/components/ViewHeader.tsx +19 -0
- package/servers/dashboard/ui/src/components/ui/Icon.tsx +105 -0
- package/servers/dashboard/ui/src/components/ui/StatusBadge.tsx +19 -0
- package/servers/dashboard/ui/src/components/views/FilesView.tsx +23 -0
- package/servers/dashboard/ui/src/components/views/NetworkView.tsx +20 -0
- package/servers/dashboard/ui/src/components/views/Overview.tsx +135 -0
- package/servers/dashboard/ui/src/components/views/SessionsView.tsx +84 -0
- package/servers/dashboard/ui/src/components/views/SettingsView.tsx +138 -0
- package/servers/dashboard/ui/src/components/views/SkillsView.tsx +92 -0
- package/servers/dashboard/ui/src/components/views/TimelineView.tsx +46 -0
- package/servers/dashboard/ui/src/contexts/FilterContext.tsx +41 -0
- package/servers/dashboard/ui/src/contexts/PinnedSessionsContext.tsx +80 -0
- package/servers/dashboard/ui/src/contexts/SettingsContext.tsx +60 -0
- package/servers/dashboard/ui/src/hooks/useCommandPalette.ts +36 -0
- package/servers/dashboard/ui/src/hooks/useKeyboardShortcut.ts +38 -0
- package/servers/dashboard/ui/src/hooks/useNow.ts +12 -0
- package/servers/dashboard/ui/src/hooks/useReducedMotion.ts +15 -0
- package/servers/dashboard/ui/src/hooks/useSessions.ts +64 -0
- package/servers/dashboard/ui/src/hooks/useTheme.ts +30 -0
- package/servers/dashboard/ui/src/hooks/useViewport.ts +19 -0
- package/servers/dashboard/ui/src/hooks/useWebSocket.ts +118 -0
- package/servers/dashboard/ui/src/lib/export.test.ts +33 -0
- package/servers/dashboard/ui/src/lib/export.ts +39 -0
- package/servers/dashboard/ui/src/lib/filter.test.ts +95 -0
- package/servers/dashboard/ui/src/lib/filter.ts +178 -0
- package/servers/dashboard/ui/src/lib/format.test.ts +25 -0
- package/servers/dashboard/ui/src/lib/search.test.ts +52 -0
- package/servers/dashboard/ui/src/lib/search.ts +60 -0
- package/servers/dashboard/ui/src/lib/settings.test.ts +50 -0
- package/servers/dashboard/ui/src/lib/settings.ts +56 -0
- package/servers/dashboard/ui/src/lib/types.ts +124 -0
- package/servers/dashboard/ui/src/main.tsx +19 -0
- package/servers/dashboard/ui/src/styles/index.css +155 -0
- package/servers/dashboard/ui/tailwind.config.js +45 -0
- package/servers/dashboard/ui/tsconfig.json +33 -0
- package/servers/dashboard/ui/tsconfig.node.json +10 -0
- package/servers/dashboard/ui/vite.config.ts +37 -0
- package/servers/dashboard/ui/vitest.config.ts +8 -0
- package/skills/accessibility-auditor/SKILL.md +0 -20
- package/skills/architect/SKILL.md +0 -45
- package/skills/designer/SKILL.md +0 -30
- package/skills/docs-writer/SKILL.md +0 -13
- package/skills/engineer/SKILL.md +0 -30
- package/skills/maintainer/SKILL.md +0 -20
- package/skills/orchestrator/SKILL.md +0 -13
- package/skills/product-manager/SKILL.md +0 -20
- package/skills/researcher/SKILL.md +0 -20
- package/skills/reviewer/SKILL.md +0 -30
- package/skills/security-guard/SKILL.md +0 -20
- package/skills/shipper/SKILL.md +0 -33
- package/skills/tester/SKILL.md +0 -20
- package/packages/cli/dist/mcp.d.ts +0 -28
- package/packages/cli/dist/mcp.d.ts.map +0 -1
- package/packages/cli/dist/mcp.js +0 -148
- package/packages/cli/dist/mcp.js.map +0 -1
- package/packages/cli/dist/tests/mcp.test.d.ts +0 -2
- package/packages/cli/dist/tests/mcp.test.d.ts.map +0 -1
- package/packages/cli/dist/tests/mcp.test.js +0 -232
- package/packages/cli/dist/tests/mcp.test.js.map +0 -1
- package/references/obsidian-mcp-usage.md +0 -190
- package/servers/dashboard/public/app.js +0 -516
- package/servers/dashboard/public/index.html +0 -96
- package/servers/dashboard/public/styles.css +0 -819
- package/servers/obsidian-mcp/README.md +0 -82
- package/servers/obsidian-mcp/pyproject.toml +0 -32
- package/servers/obsidian-mcp/src/obsidian_mcp/__init__.py +0 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/config.py +0 -47
- package/servers/obsidian-mcp/src/obsidian_mcp/indexer/__init__.py +0 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/indexer/embeddings.py +0 -105
- package/servers/obsidian-mcp/src/obsidian_mcp/indexer/indexer.py +0 -79
- package/servers/obsidian-mcp/src/obsidian_mcp/indexer/store.py +0 -141
- package/servers/obsidian-mcp/src/obsidian_mcp/indexer/sync.py +0 -37
- package/servers/obsidian-mcp/src/obsidian_mcp/learning/__init__.py +0 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/learning/detector.py +0 -66
- package/servers/obsidian-mcp/src/obsidian_mcp/learning/note_generator.py +0 -40
- package/servers/obsidian-mcp/src/obsidian_mcp/main.py +0 -4
- package/servers/obsidian-mcp/src/obsidian_mcp/models.py +0 -42
- package/servers/obsidian-mcp/src/obsidian_mcp/privacy/__init__.py +0 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/privacy/filter.py +0 -68
- package/servers/obsidian-mcp/src/obsidian_mcp/rag/__init__.py +0 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/rag/engine.py +0 -50
- package/servers/obsidian-mcp/src/obsidian_mcp/rag/graph_search.py +0 -55
- package/servers/obsidian-mcp/src/obsidian_mcp/rag/text_search.py +0 -37
- package/servers/obsidian-mcp/src/obsidian_mcp/rag/vector_search.py +0 -118
- package/servers/obsidian-mcp/src/obsidian_mcp/server.py +0 -61
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/__init__.py +0 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/create.py +0 -43
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/delete.py +0 -16
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/learn.py +0 -42
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/list.py +0 -16
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/read.py +0 -15
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/registry.py +0 -130
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/related.py +0 -20
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/search.py +0 -26
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/sync.py +0 -22
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/update.py +0 -34
- package/servers/obsidian-mcp/src/obsidian_mcp/vault/__init__.py +0 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/vault/parser.py +0 -82
- package/servers/obsidian-mcp/src/obsidian_mcp/vault/repository.py +0 -68
- package/servers/obsidian-mcp/src/obsidian_mcp/vault/writer.py +0 -61
- package/servers/obsidian-mcp/tests/conftest.py +0 -39
- package/servers/obsidian-mcp/tests/test_async_tools.py +0 -87
- package/servers/obsidian-mcp/tests/test_edge_cases.py +0 -59
- package/servers/obsidian-mcp/tests/test_indexer.py +0 -27
- package/servers/obsidian-mcp/tests/test_integration.py +0 -90
- package/servers/obsidian-mcp/tests/test_learning.py +0 -34
- package/servers/obsidian-mcp/tests/test_privacy.py +0 -31
- package/servers/obsidian-mcp/tests/test_privacy_config.py +0 -44
- package/servers/obsidian-mcp/tests/test_rag.py +0 -64
- package/servers/obsidian-mcp/tests/test_read_raw.py +0 -37
- package/servers/obsidian-mcp/tests/test_tfidf_fallback.py +0 -54
- package/servers/obsidian-mcp/tests/test_tools.py +0 -108
- package/servers/obsidian-mcp/tests/test_vault.py +0 -103
- package/servers/obsidian-mcp/tests/test_writer.py +0 -139
- package/skills/obsidian-second-brain/SKILL.md +0 -298
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import type { ClientSession } from '../../../../src/types';
|
|
3
|
+
import { ActiveSkillPanel } from '../ActiveSkillPanel';
|
|
4
|
+
import { TelemetryPanel } from '../TelemetryPanel';
|
|
5
|
+
import { ActivityGraph } from '../ActivityGraph';
|
|
6
|
+
import { ViewHeader } from '../ViewHeader';
|
|
7
|
+
import { usePinnedSessions } from '../../contexts/PinnedSessionsContext';
|
|
8
|
+
import { sourceIcon } from '../../../../src/lib/constants';
|
|
9
|
+
import { Icon } from '../ui/Icon';
|
|
10
|
+
import { truncate } from '../../../../src/lib/format';
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
sessions: Map<string, ClientSession>;
|
|
14
|
+
selectedSession: ClientSession | undefined;
|
|
15
|
+
onSelectSession: (id: string) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function Overview({ sessions, selectedSession, onSelectSession }: Props) {
|
|
19
|
+
const { pins } = usePinnedSessions();
|
|
20
|
+
const allSessions = useMemo(() => Array.from(sessions.values()), [sessions]);
|
|
21
|
+
const activeCount = useMemo(
|
|
22
|
+
() => allSessions.filter((s) => s.lifecycle === 'running').length,
|
|
23
|
+
[allSessions]
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const topSkills = useMemo(() => {
|
|
27
|
+
const counts = new Map<string, number>();
|
|
28
|
+
for (const s of allSessions) {
|
|
29
|
+
const name = s.activeSkill?.name || s.skill;
|
|
30
|
+
if (name) counts.set(name, (counts.get(name) || 0) + 1);
|
|
31
|
+
}
|
|
32
|
+
return Array.from(counts.entries())
|
|
33
|
+
.sort((a, b) => b[1] - a[1])
|
|
34
|
+
.slice(0, 5);
|
|
35
|
+
}, [allSessions]);
|
|
36
|
+
|
|
37
|
+
const topTools = useMemo(() => {
|
|
38
|
+
const counts = new Map<string, number>();
|
|
39
|
+
for (const s of allSessions) {
|
|
40
|
+
for (const e of s.events) {
|
|
41
|
+
if (e.tool) counts.set(e.tool, (counts.get(e.tool) || 0) + 1);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return Array.from(counts.entries())
|
|
45
|
+
.sort((a, b) => b[1] - a[1])
|
|
46
|
+
.slice(0, 5);
|
|
47
|
+
}, [allSessions]);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="flex flex-col h-full overflow-hidden">
|
|
51
|
+
<ViewHeader title="Overview" icon="House" />
|
|
52
|
+
<div className="flex-1 overflow-y-auto p-5">
|
|
53
|
+
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
|
54
|
+
<ActiveSkillPanel session={selectedSession} />
|
|
55
|
+
<TelemetryPanel session={selectedSession} />
|
|
56
|
+
<ActivityGraph session={selectedSession} />
|
|
57
|
+
|
|
58
|
+
<section className="panel p-5">
|
|
59
|
+
<h2 className="text-xs font-medium text-text-muted uppercase tracking-widest pb-3 border-b border-border-default">
|
|
60
|
+
Sessions
|
|
61
|
+
</h2>
|
|
62
|
+
<div className="pt-4 grid grid-cols-2 gap-3">
|
|
63
|
+
<div className="flex flex-col gap-1">
|
|
64
|
+
<span className="font-display text-4xl text-accent">{allSessions.length}</span>
|
|
65
|
+
<span className="text-[11px] uppercase tracking-widest text-text-muted">Total</span>
|
|
66
|
+
</div>
|
|
67
|
+
<div className="flex flex-col gap-1">
|
|
68
|
+
<span className="font-display text-4xl text-running">{activeCount}</span>
|
|
69
|
+
<span className="text-[11px] uppercase tracking-widest text-text-muted">Active</span>
|
|
70
|
+
</div>
|
|
71
|
+
<div className="flex flex-col gap-1">
|
|
72
|
+
<span className="font-display text-4xl text-accent">{pins.length}</span>
|
|
73
|
+
<span className="text-[11px] uppercase tracking-widest text-text-muted">Pinned</span>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</section>
|
|
77
|
+
|
|
78
|
+
<section className="panel p-5">
|
|
79
|
+
<h2 className="text-xs font-medium text-text-muted uppercase tracking-widest pb-3 border-b border-border-default">
|
|
80
|
+
Top Skills
|
|
81
|
+
</h2>
|
|
82
|
+
<div className="pt-3 flex flex-col gap-2">
|
|
83
|
+
{topSkills.length === 0 && <p className="text-sm text-text-muted">No skills observed.</p>}
|
|
84
|
+
{topSkills.map(([name, count]) => (
|
|
85
|
+
<div key={name} className="flex items-center justify-between text-sm">
|
|
86
|
+
<span className="text-text-primary">{name}</span>
|
|
87
|
+
<span className="text-text-muted tabular">{count}</span>
|
|
88
|
+
</div>
|
|
89
|
+
))}
|
|
90
|
+
</div>
|
|
91
|
+
</section>
|
|
92
|
+
|
|
93
|
+
<section className="panel p-5">
|
|
94
|
+
<h2 className="text-xs font-medium text-text-muted uppercase tracking-widest pb-3 border-b border-border-default">
|
|
95
|
+
Top Tools
|
|
96
|
+
</h2>
|
|
97
|
+
<div className="pt-3 flex flex-col gap-2">
|
|
98
|
+
{topTools.length === 0 && <p className="text-sm text-text-muted">No tools observed.</p>}
|
|
99
|
+
{topTools.map(([name, count]) => (
|
|
100
|
+
<div key={name} className="flex items-center justify-between text-sm">
|
|
101
|
+
<span className="font-mono text-text-primary">{name}</span>
|
|
102
|
+
<span className="text-text-muted tabular">{count}</span>
|
|
103
|
+
</div>
|
|
104
|
+
))}
|
|
105
|
+
</div>
|
|
106
|
+
</section>
|
|
107
|
+
|
|
108
|
+
<section className="panel p-5 md:col-span-2 xl:col-span-3">
|
|
109
|
+
<h2 className="text-xs font-medium text-text-muted uppercase tracking-widest pb-3 border-b border-border-default">
|
|
110
|
+
Recent Sessions
|
|
111
|
+
</h2>
|
|
112
|
+
<div className="pt-3 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
113
|
+
{allSessions.slice(0, 6).map((s) => (
|
|
114
|
+
<button
|
|
115
|
+
key={s.id}
|
|
116
|
+
onClick={() => onSelectSession(s.id)}
|
|
117
|
+
className="flex items-center gap-3 p-3 rounded-lg border border-border-default bg-elevated hover:border-accent transition-colors text-left"
|
|
118
|
+
>
|
|
119
|
+
<Icon name={sourceIcon(s.source)} className="w-5 h-5 text-text-secondary" />
|
|
120
|
+
<div className="flex flex-col min-w-0">
|
|
121
|
+
<span className="text-sm text-text-primary font-mono truncate">
|
|
122
|
+
{truncate(s.activeSkill?.name || 'No active skill', 24)}
|
|
123
|
+
</span>
|
|
124
|
+
<span className="text-[11px] text-text-muted uppercase">{s.source}</span>
|
|
125
|
+
</div>
|
|
126
|
+
</button>
|
|
127
|
+
))}
|
|
128
|
+
{allSessions.length === 0 && <p className="text-sm text-text-muted">No sessions yet.</p>}
|
|
129
|
+
</div>
|
|
130
|
+
</section>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { ClientSession } from '../../../../src/types';
|
|
2
|
+
import { usePinnedSessions } from '../../contexts/PinnedSessionsContext';
|
|
3
|
+
import { ViewHeader } from '../ViewHeader';
|
|
4
|
+
import { FilterBar } from '../FilterBar';
|
|
5
|
+
import { Icon } from '../ui/Icon';
|
|
6
|
+
import { formatDuration, formatTime, truncate } from '../../../../src/lib/format';
|
|
7
|
+
import type { FilterOptions } from '../../lib/types';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
sessions: ClientSession[];
|
|
11
|
+
selectedSessionId: string | null;
|
|
12
|
+
filterOptions: FilterOptions;
|
|
13
|
+
onSelectSession: (id: string) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function lifecycleColor(lifecycle: string): string {
|
|
17
|
+
switch (lifecycle) {
|
|
18
|
+
case 'starting':
|
|
19
|
+
return 'bg-warning';
|
|
20
|
+
case 'running':
|
|
21
|
+
return 'bg-running';
|
|
22
|
+
case 'ended':
|
|
23
|
+
return 'bg-text-muted';
|
|
24
|
+
default:
|
|
25
|
+
return 'bg-text-muted';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function SessionsView({ sessions, selectedSessionId, filterOptions, onSelectSession }: Props) {
|
|
30
|
+
const { togglePin, isPinned } = usePinnedSessions();
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="flex flex-col h-full overflow-hidden">
|
|
34
|
+
<ViewHeader title="Sessions" icon="Rows" />
|
|
35
|
+
<FilterBar options={filterOptions} resultCount={sessions.length} />
|
|
36
|
+
<div className="flex-1 overflow-y-auto p-5">
|
|
37
|
+
<div className="flex flex-col gap-2">
|
|
38
|
+
{sessions.map((s) => {
|
|
39
|
+
const duration = s.endedAt
|
|
40
|
+
? formatDuration(s.endedAt - s.startTime)
|
|
41
|
+
: formatDuration(Date.now() - s.startTime);
|
|
42
|
+
const active = s.id === selectedSessionId;
|
|
43
|
+
const pinned = isPinned(s.id);
|
|
44
|
+
return (
|
|
45
|
+
<button
|
|
46
|
+
key={s.id}
|
|
47
|
+
onClick={() => onSelectSession(s.id)}
|
|
48
|
+
className={`group flex items-center gap-3 w-full text-left p-3 rounded-lg border transition-colors ${
|
|
49
|
+
active
|
|
50
|
+
? 'bg-elevated border-accent'
|
|
51
|
+
: 'bg-surface border-border-default hover:bg-elevated'
|
|
52
|
+
}`}
|
|
53
|
+
>
|
|
54
|
+
<button
|
|
55
|
+
onClick={(e) => {
|
|
56
|
+
e.stopPropagation();
|
|
57
|
+
togglePin(s.id);
|
|
58
|
+
}}
|
|
59
|
+
aria-label={pinned ? 'Unpin session' : 'Pin session'}
|
|
60
|
+
className={`p-1 rounded hover:bg-base transition-colors ${
|
|
61
|
+
pinned ? 'text-accent' : 'text-text-muted group-hover:text-text-secondary'
|
|
62
|
+
}`}
|
|
63
|
+
>
|
|
64
|
+
<Icon name={pinned ? 'PushPinSlash' : 'PushPin'} className="w-4 h-4" />
|
|
65
|
+
</button>
|
|
66
|
+
<span className={`w-2.5 h-2.5 rounded-full ${lifecycleColor(s.lifecycle)}`} />
|
|
67
|
+
<div className="flex flex-col min-w-0 flex-1">
|
|
68
|
+
<span className="text-sm text-text-primary font-mono truncate">
|
|
69
|
+
{truncate(s.activeSkill?.name || s.id, 40)}
|
|
70
|
+
</span>
|
|
71
|
+
<span className="text-[11px] text-text-muted">
|
|
72
|
+
{formatTime(s.startTime)} · {duration} · {s.events.length} events
|
|
73
|
+
</span>
|
|
74
|
+
</div>
|
|
75
|
+
<span className="text-[11px] uppercase text-text-muted">{s.source}</span>
|
|
76
|
+
</button>
|
|
77
|
+
);
|
|
78
|
+
})}
|
|
79
|
+
{sessions.length === 0 && <p className="text-sm text-text-muted text-center py-8">No sessions match the filters.</p>}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { useSettings } from '../../contexts/SettingsContext';
|
|
2
|
+
import { ViewHeader } from '../ViewHeader';
|
|
3
|
+
import { Icon } from '../ui/Icon';
|
|
4
|
+
|
|
5
|
+
export function SettingsView() {
|
|
6
|
+
const { settings, setSettings, reducedMotion } = useSettings();
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<div className="flex flex-col h-full overflow-hidden">
|
|
10
|
+
<ViewHeader title="Settings" icon="Gear" />
|
|
11
|
+
<div className="flex-1 overflow-y-auto p-5">
|
|
12
|
+
<div className="max-w-2xl flex flex-col gap-4">
|
|
13
|
+
<section className="panel p-5">
|
|
14
|
+
<h2 className="text-xs font-medium text-text-muted uppercase tracking-widest pb-3 border-b border-border-default">
|
|
15
|
+
Appearance
|
|
16
|
+
</h2>
|
|
17
|
+
<div className="pt-4 flex flex-col gap-4">
|
|
18
|
+
<div className="flex items-center justify-between">
|
|
19
|
+
<div>
|
|
20
|
+
<div className="text-sm text-text-primary">Theme</div>
|
|
21
|
+
<div className="text-xs text-text-muted">Choose your preferred color scheme.</div>
|
|
22
|
+
</div>
|
|
23
|
+
<select
|
|
24
|
+
value={settings.theme}
|
|
25
|
+
onChange={(e) => setSettings((s) => ({ ...s, theme: e.target.value as typeof s.theme }))}
|
|
26
|
+
className="h-9 px-3 rounded-lg bg-elevated border border-border-default text-sm text-text-primary outline-none focus:border-accent"
|
|
27
|
+
>
|
|
28
|
+
<option value="system">System</option>
|
|
29
|
+
<option value="dark">Dark</option>
|
|
30
|
+
<option value="light">Light</option>
|
|
31
|
+
</select>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div className="flex items-center justify-between">
|
|
35
|
+
<div>
|
|
36
|
+
<div className="text-sm text-text-primary">Density</div>
|
|
37
|
+
<div className="text-xs text-text-muted">Control how compact lists appear.</div>
|
|
38
|
+
</div>
|
|
39
|
+
<div className="flex items-center gap-2">
|
|
40
|
+
<button
|
|
41
|
+
onClick={() => setSettings((s) => ({ ...s, density: 'comfortable' }))}
|
|
42
|
+
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-xs transition-colors ${
|
|
43
|
+
settings.density === 'comfortable'
|
|
44
|
+
? 'border-accent text-accent bg-accent/10'
|
|
45
|
+
: 'border-border-default text-text-secondary hover:bg-elevated'
|
|
46
|
+
}`}
|
|
47
|
+
>
|
|
48
|
+
<Icon name="ArrowsOutLineVertical" className="w-4 h-4" />
|
|
49
|
+
Comfortable
|
|
50
|
+
</button>
|
|
51
|
+
<button
|
|
52
|
+
onClick={() => setSettings((s) => ({ ...s, density: 'compact' }))}
|
|
53
|
+
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-xs transition-colors ${
|
|
54
|
+
settings.density === 'compact'
|
|
55
|
+
? 'border-accent text-accent bg-accent/10'
|
|
56
|
+
: 'border-border-default text-text-secondary hover:bg-elevated'
|
|
57
|
+
}`}
|
|
58
|
+
>
|
|
59
|
+
<Icon name="ArrowsInLineVertical" className="w-4 h-4" />
|
|
60
|
+
Compact
|
|
61
|
+
</button>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div className="flex items-center justify-between">
|
|
66
|
+
<div>
|
|
67
|
+
<div className="text-sm text-text-primary">Reduced motion</div>
|
|
68
|
+
<div className="text-xs text-text-muted">Disable animations throughout the dashboard.</div>
|
|
69
|
+
</div>
|
|
70
|
+
<button
|
|
71
|
+
onClick={() => setSettings((s) => ({ ...s, reducedMotion: !s.reducedMotion }))}
|
|
72
|
+
className={`relative w-11 h-6 rounded-full transition-colors ${
|
|
73
|
+
settings.reducedMotion ? 'bg-accent' : 'bg-border-strong'
|
|
74
|
+
}`}
|
|
75
|
+
aria-pressed={settings.reducedMotion}
|
|
76
|
+
>
|
|
77
|
+
<span
|
|
78
|
+
className={`absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform ${
|
|
79
|
+
settings.reducedMotion ? 'translate-x-5' : 'translate-x-0'
|
|
80
|
+
}`}
|
|
81
|
+
/>
|
|
82
|
+
</button>
|
|
83
|
+
</div>
|
|
84
|
+
{reducedMotion && (
|
|
85
|
+
<p className="text-xs text-text-muted">OS reduced-motion preference is also enabled.</p>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
</section>
|
|
89
|
+
|
|
90
|
+
<section className="panel p-5">
|
|
91
|
+
<h2 className="text-xs font-medium text-text-muted uppercase tracking-widest pb-3 border-b border-border-default">
|
|
92
|
+
Behavior
|
|
93
|
+
</h2>
|
|
94
|
+
<div className="pt-4 flex flex-col gap-4">
|
|
95
|
+
<div className="flex items-center justify-between">
|
|
96
|
+
<div>
|
|
97
|
+
<div className="text-sm text-text-primary">Auto-follow active session</div>
|
|
98
|
+
<div className="text-xs text-text-muted">Automatically select the running session.</div>
|
|
99
|
+
</div>
|
|
100
|
+
<button
|
|
101
|
+
onClick={() => setSettings((s) => ({ ...s, autoFollowActive: !s.autoFollowActive }))}
|
|
102
|
+
className={`relative w-11 h-6 rounded-full transition-colors ${
|
|
103
|
+
settings.autoFollowActive ? 'bg-accent' : 'bg-border-strong'
|
|
104
|
+
}`}
|
|
105
|
+
aria-pressed={settings.autoFollowActive}
|
|
106
|
+
>
|
|
107
|
+
<span
|
|
108
|
+
className={`absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform ${
|
|
109
|
+
settings.autoFollowActive ? 'translate-x-5' : 'translate-x-0'
|
|
110
|
+
}`}
|
|
111
|
+
/>
|
|
112
|
+
</button>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<div className="flex items-center justify-between">
|
|
116
|
+
<div>
|
|
117
|
+
<div className="text-sm text-text-primary">Max events per session</div>
|
|
118
|
+
<div className="text-xs text-text-muted">Limit how many events are kept in memory.</div>
|
|
119
|
+
</div>
|
|
120
|
+
<input
|
|
121
|
+
type="number"
|
|
122
|
+
min={10}
|
|
123
|
+
max={1000}
|
|
124
|
+
value={settings.maxEvents}
|
|
125
|
+
onChange={(e) => {
|
|
126
|
+
const n = parseInt(e.target.value, 10);
|
|
127
|
+
if (!Number.isNaN(n)) setSettings((s) => ({ ...s, maxEvents: Math.max(10, Math.min(1000, n)) }));
|
|
128
|
+
}}
|
|
129
|
+
className="w-24 h-9 px-3 rounded-lg bg-elevated border border-border-default text-sm text-text-primary outline-none focus:border-accent"
|
|
130
|
+
/>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
</section>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import type { ToolInvocation } from '../../../../src/lib/invocations';
|
|
3
|
+
import { operationType } from '../../../../src/lib/invocations';
|
|
4
|
+
import { resolvePath } from '../../../../src/lib/paths';
|
|
5
|
+
import { ViewHeader } from '../ViewHeader';
|
|
6
|
+
import { FilterBar } from '../FilterBar';
|
|
7
|
+
import type { FilterOptions } from '../../lib/types';
|
|
8
|
+
import { Icon } from '../ui/Icon';
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
invocations: ToolInvocation[];
|
|
12
|
+
filterOptions: FilterOptions;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function SkillsView({ invocations, filterOptions }: Props) {
|
|
16
|
+
const stats = useMemo(() => {
|
|
17
|
+
const skills = new Map<string, number>();
|
|
18
|
+
const tools = new Map<string, number>();
|
|
19
|
+
const files = new Set<string>();
|
|
20
|
+
for (const inv of invocations) {
|
|
21
|
+
if (inv.skill) skills.set(inv.skill, (skills.get(inv.skill) || 0) + 1);
|
|
22
|
+
tools.set(inv.tool, (tools.get(inv.tool) || 0) + 1);
|
|
23
|
+
const path = resolvePath(inv.input, inv.output);
|
|
24
|
+
if (path) files.add(path);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
skills: Array.from(skills.entries()).sort((a, b) => b[1] - a[1]),
|
|
28
|
+
tools: Array.from(tools.entries()).sort((a, b) => b[1] - a[1]),
|
|
29
|
+
reads: invocations.filter((i) => operationType(i.tool) === 'read').length,
|
|
30
|
+
edits: invocations.filter((i) => operationType(i.tool) === 'edit').length,
|
|
31
|
+
fileCount: files.size,
|
|
32
|
+
};
|
|
33
|
+
}, [invocations]);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="flex flex-col h-full overflow-hidden">
|
|
37
|
+
<ViewHeader title="Skills" icon="ChartPie" />
|
|
38
|
+
<FilterBar options={filterOptions} resultCount={invocations.length} />
|
|
39
|
+
<div className="flex-1 overflow-y-auto p-5">
|
|
40
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
|
41
|
+
<div className="panel p-4 flex flex-col gap-1">
|
|
42
|
+
<span className="font-display text-4xl text-accent">{stats.skills.length}</span>
|
|
43
|
+
<span className="text-[11px] uppercase tracking-widest text-text-muted">Skills</span>
|
|
44
|
+
</div>
|
|
45
|
+
<div className="panel p-4 flex flex-col gap-1">
|
|
46
|
+
<span className="font-display text-4xl text-accent">{stats.tools.length}</span>
|
|
47
|
+
<span className="text-[11px] uppercase tracking-widest text-text-muted">Tools</span>
|
|
48
|
+
</div>
|
|
49
|
+
<div className="panel p-4 flex flex-col gap-1">
|
|
50
|
+
<span className="font-display text-4xl text-accent">{stats.fileCount}</span>
|
|
51
|
+
<span className="text-[11px] uppercase tracking-widest text-text-muted">Files touched</span>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
56
|
+
<section className="panel p-5">
|
|
57
|
+
<h2 className="text-xs font-medium text-text-muted uppercase tracking-widest pb-3 border-b border-border-default">
|
|
58
|
+
Skill usage
|
|
59
|
+
</h2>
|
|
60
|
+
<div className="pt-3 flex flex-col gap-2">
|
|
61
|
+
{stats.skills.length === 0 && <p className="text-sm text-text-muted">No skills observed.</p>}
|
|
62
|
+
{stats.skills.map(([name, count]) => (
|
|
63
|
+
<div key={name} className="flex items-center justify-between text-sm">
|
|
64
|
+
<div className="flex items-center gap-2">
|
|
65
|
+
<Icon name="Circle" className="w-2 h-2 text-accent" />
|
|
66
|
+
<span className="text-text-primary">{name}</span>
|
|
67
|
+
</div>
|
|
68
|
+
<span className="text-text-muted tabular">{count}</span>
|
|
69
|
+
</div>
|
|
70
|
+
))}
|
|
71
|
+
</div>
|
|
72
|
+
</section>
|
|
73
|
+
|
|
74
|
+
<section className="panel p-5">
|
|
75
|
+
<h2 className="text-xs font-medium text-text-muted uppercase tracking-widest pb-3 border-b border-border-default">
|
|
76
|
+
Tool usage
|
|
77
|
+
</h2>
|
|
78
|
+
<div className="pt-3 flex flex-col gap-2">
|
|
79
|
+
{stats.tools.length === 0 && <p className="text-sm text-text-muted">No tools observed.</p>}
|
|
80
|
+
{stats.tools.map(([name, count]) => (
|
|
81
|
+
<div key={name} className="flex items-center justify-between text-sm">
|
|
82
|
+
<span className="font-mono text-text-primary">{name}</span>
|
|
83
|
+
<span className="text-text-muted tabular">{count}</span>
|
|
84
|
+
</div>
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
</section>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { ToolInvocation } from '../../../../src/lib/invocations';
|
|
3
|
+
import { ViewHeader } from '../ViewHeader';
|
|
4
|
+
import { FilterBar } from '../FilterBar';
|
|
5
|
+
import { Timeline } from '../Timeline';
|
|
6
|
+
import { toJson, download, filename, toExportableEvent } from '../../lib/export';
|
|
7
|
+
import type { FilterOptions } from '../../lib/types';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
invocations: ToolInvocation[];
|
|
11
|
+
filterOptions: FilterOptions;
|
|
12
|
+
onMouseEnter?: () => void;
|
|
13
|
+
onMouseLeave?: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function TimelineView({ invocations, filterOptions, onMouseEnter, onMouseLeave }: Props) {
|
|
17
|
+
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
|
18
|
+
|
|
19
|
+
function toggleExpanded(id: string) {
|
|
20
|
+
setExpandedIds((prev) => {
|
|
21
|
+
const next = new Set(prev);
|
|
22
|
+
if (next.has(id)) next.delete(id);
|
|
23
|
+
else next.add(id);
|
|
24
|
+
return next;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function handleExport() {
|
|
29
|
+
const events = invocations.map(toExportableEvent);
|
|
30
|
+
download(toJson(events), filename('json'));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="flex flex-col h-full overflow-hidden">
|
|
35
|
+
<ViewHeader title="Timeline" icon="Clock" />
|
|
36
|
+
<FilterBar options={filterOptions} resultCount={invocations.length} onExport={handleExport} />
|
|
37
|
+
<Timeline
|
|
38
|
+
invocations={invocations}
|
|
39
|
+
expandedIds={expandedIds}
|
|
40
|
+
onToggle={toggleExpanded}
|
|
41
|
+
onMouseEnter={onMouseEnter}
|
|
42
|
+
onMouseLeave={onMouseLeave}
|
|
43
|
+
/>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react';
|
|
2
|
+
import type { FilterState } from '../lib/types';
|
|
3
|
+
import { DEFAULT_FILTER_STATE } from '../lib/types';
|
|
4
|
+
|
|
5
|
+
interface FilterContextValue {
|
|
6
|
+
filters: FilterState;
|
|
7
|
+
setFilters: (updater: Partial<FilterState> | ((prev: FilterState) => FilterState)) => void;
|
|
8
|
+
resetFilters: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const FilterContext = createContext<FilterContextValue | null>(null);
|
|
12
|
+
|
|
13
|
+
export function FilterProvider({ children }: { children: ReactNode }) {
|
|
14
|
+
const [filters, setFiltersState] = useState<FilterState>(DEFAULT_FILTER_STATE);
|
|
15
|
+
|
|
16
|
+
const setFilters = useCallback(
|
|
17
|
+
(updater: Partial<FilterState> | ((prev: FilterState) => FilterState)) => {
|
|
18
|
+
setFiltersState((prev) => {
|
|
19
|
+
if (typeof updater === 'function') {
|
|
20
|
+
return (updater as (prev: FilterState) => FilterState)(prev);
|
|
21
|
+
}
|
|
22
|
+
return { ...prev, ...updater };
|
|
23
|
+
});
|
|
24
|
+
},
|
|
25
|
+
[]
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const resetFilters = useCallback(() => setFiltersState(DEFAULT_FILTER_STATE), []);
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<FilterContext.Provider value={{ filters, setFilters, resetFilters }}>
|
|
32
|
+
{children}
|
|
33
|
+
</FilterContext.Provider>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function useFilters(): FilterContextValue {
|
|
38
|
+
const ctx = useContext(FilterContext);
|
|
39
|
+
if (!ctx) throw new Error('useFilters must be used within FilterProvider');
|
|
40
|
+
return ctx;
|
|
41
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react';
|
|
2
|
+
import type { PinnedSession } from '../lib/types';
|
|
3
|
+
|
|
4
|
+
interface PinnedSessionsContextValue {
|
|
5
|
+
pins: PinnedSession[];
|
|
6
|
+
addPin: (id: string) => void;
|
|
7
|
+
removePin: (id: string) => void;
|
|
8
|
+
togglePin: (id: string) => void;
|
|
9
|
+
isPinned: (id: string) => boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const STORAGE_KEY = 'crewloop-dashboard-pins';
|
|
13
|
+
|
|
14
|
+
const PinnedSessionsContext = createContext<PinnedSessionsContextValue | null>(null);
|
|
15
|
+
|
|
16
|
+
function loadPins(): PinnedSession[] {
|
|
17
|
+
try {
|
|
18
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
19
|
+
if (!raw) return [];
|
|
20
|
+
const parsed = JSON.parse(raw);
|
|
21
|
+
if (Array.isArray(parsed)) return parsed.filter((p): p is PinnedSession => p && typeof p.id === 'string' && typeof p.pinnedAt === 'number');
|
|
22
|
+
} catch {
|
|
23
|
+
// ignore
|
|
24
|
+
}
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function savePins(pins: PinnedSession[]): void {
|
|
29
|
+
try {
|
|
30
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(pins));
|
|
31
|
+
} catch {
|
|
32
|
+
// ignore
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function PinnedSessionsProvider({ children }: { children: ReactNode }) {
|
|
37
|
+
const [pins, setPins] = useState<PinnedSession[]>(() => loadPins());
|
|
38
|
+
|
|
39
|
+
const addPin = useCallback((id: string) => {
|
|
40
|
+
setPins((prev) => {
|
|
41
|
+
if (prev.some((p) => p.id === id)) return prev;
|
|
42
|
+
const next = [...prev, { id, pinnedAt: Date.now() }];
|
|
43
|
+
savePins(next);
|
|
44
|
+
return next;
|
|
45
|
+
});
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
const removePin = useCallback((id: string) => {
|
|
49
|
+
setPins((prev) => {
|
|
50
|
+
const next = prev.filter((p) => p.id !== id);
|
|
51
|
+
savePins(next);
|
|
52
|
+
return next;
|
|
53
|
+
});
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
const togglePin = useCallback(
|
|
57
|
+
(id: string) => {
|
|
58
|
+
if (pins.some((p) => p.id === id)) removePin(id);
|
|
59
|
+
else addPin(id);
|
|
60
|
+
},
|
|
61
|
+
[pins, addPin, removePin]
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const isPinned = useCallback(
|
|
65
|
+
(id: string) => pins.some((p) => p.id === id),
|
|
66
|
+
[pins]
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<PinnedSessionsContext.Provider value={{ pins, addPin, removePin, togglePin, isPinned }}>
|
|
71
|
+
{children}
|
|
72
|
+
</PinnedSessionsContext.Provider>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function usePinnedSessions(): PinnedSessionsContextValue {
|
|
77
|
+
const ctx = useContext(PinnedSessionsContext);
|
|
78
|
+
if (!ctx) throw new Error('usePinnedSessions must be used within PinnedSessionsProvider');
|
|
79
|
+
return ctx;
|
|
80
|
+
}
|