tenbo-dashboard 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 +32 -0
- package/bin/tenbo-dashboard.mjs +67 -0
- package/index.html +12 -0
- package/package.json +75 -0
- package/scripts/compute-metrics.test.ts +35 -0
- package/scripts/compute-metrics.ts +46 -0
- package/scripts/next-id.test.ts +128 -0
- package/scripts/next-id.ts +183 -0
- package/scripts/validate-cli.test.ts +152 -0
- package/scripts/validate-cli.ts +181 -0
- package/src/App.tsx +152 -0
- package/src/ErrorBoundary.tsx +25 -0
- package/src/api/client.ts +41 -0
- package/src/api/lib/frontmatterScan.ts +37 -0
- package/src/api/lib/health/agingTodos.test.ts +32 -0
- package/src/api/lib/health/agingTodos.ts +97 -0
- package/src/api/lib/health/archCompliance.test.ts +26 -0
- package/src/api/lib/health/archCompliance.ts +53 -0
- package/src/api/lib/health/collectAll.test.ts +49 -0
- package/src/api/lib/health/collectAll.ts +78 -0
- package/src/api/lib/health/config.test.ts +45 -0
- package/src/api/lib/health/config.ts +54 -0
- package/src/api/lib/health/coupling.test.ts +40 -0
- package/src/api/lib/health/coupling.ts +66 -0
- package/src/api/lib/health/deadCode.test.ts +23 -0
- package/src/api/lib/health/deadCode.ts +76 -0
- package/src/api/lib/health/docDrift.test.ts +54 -0
- package/src/api/lib/health/docDrift.ts +99 -0
- package/src/api/lib/health/hotspotFiles.test.ts +49 -0
- package/src/api/lib/health/hotspotFiles.ts +70 -0
- package/src/api/lib/health/importGraph.test.ts +29 -0
- package/src/api/lib/health/importGraph.ts +51 -0
- package/src/api/lib/health/layerFiles.test.ts +46 -0
- package/src/api/lib/health/layerFiles.ts +40 -0
- package/src/api/lib/health/redundancy.test.ts +24 -0
- package/src/api/lib/health/redundancy.ts +162 -0
- package/src/api/lib/health/testCoverage.test.ts +33 -0
- package/src/api/lib/health/testCoverage.ts +55 -0
- package/src/api/lib/health/types.test.ts +29 -0
- package/src/api/lib/health/types.ts +85 -0
- package/src/api/lib/http.ts +34 -0
- package/src/api/lib/metrics.test.ts +81 -0
- package/src/api/lib/metrics.ts +78 -0
- package/src/api/lib/metricsRefresh.test.ts +54 -0
- package/src/api/lib/metricsRefresh.ts +147 -0
- package/src/api/lib/phases.test.ts +76 -0
- package/src/api/lib/phases.ts +48 -0
- package/src/api/lib/relationships.ts +65 -0
- package/src/api/lib/repoRoot.ts +12 -0
- package/src/api/lib/tenboFs.layerContent.test.ts +36 -0
- package/src/api/lib/tenboFs.ts +237 -0
- package/src/api/lib/tenboFs.v2.test.ts +86 -0
- package/src/api/lib/tenboFs.workspaceContent.test.ts +42 -0
- package/src/api/lib/validator.docUpdate.test.ts +99 -0
- package/src/api/lib/validator.phases.test.ts +98 -0
- package/src/api/lib/validator.relationships.test.ts +126 -0
- package/src/api/lib/validator.specLinks.test.ts +96 -0
- package/src/api/lib/validator.ts +311 -0
- package/src/api/lib/validator.v2.test.ts +55 -0
- package/src/api/lib/yamlOrdered.ts +60 -0
- package/src/api/plugin.ts +31 -0
- package/src/api/routes/items.ts +25 -0
- package/src/api/routes/layerContent.ts +22 -0
- package/src/api/routes/layerDocs.ts +22 -0
- package/src/api/routes/open.ts +18 -0
- package/src/api/routes/related.ts +11 -0
- package/src/api/routes/reorder.ts +15 -0
- package/src/api/routes/state.ts +23 -0
- package/src/api/routes/watch.ts +31 -0
- package/src/css-modules.d.ts +4 -0
- package/src/hooks/useApiPatch.ts +9 -0
- package/src/hooks/useLayerContent.ts +25 -0
- package/src/hooks/usePrompt.ts +23 -0
- package/src/hooks/useTenboState.ts +35 -0
- package/src/main.tsx +13 -0
- package/src/prompts/populateLayer.ts +12 -0
- package/src/router/routes.ts +98 -0
- package/src/router/useHashRoute.test.ts +67 -0
- package/src/router/useHashRoute.ts +15 -0
- package/src/styles.css +228 -0
- package/src/test-setup.ts +1 -0
- package/src/types.ts +202 -0
- package/src/ui/DotGrid.module.css +7 -0
- package/src/ui/DotGrid.tsx +87 -0
- package/src/ui/EmptyState.module.css +11 -0
- package/src/ui/EmptyState.tsx +10 -0
- package/src/ui/FindingModal/EvidenceSection.tsx +51 -0
- package/src/ui/FindingModal/FindingModal.module.css +31 -0
- package/src/ui/FindingModal/FindingModal.test.tsx +36 -0
- package/src/ui/FindingModal/HeadlineSection.tsx +17 -0
- package/src/ui/FindingModal/RelatedFindingsSection.tsx +29 -0
- package/src/ui/FindingModal/SuggestionSection.tsx +13 -0
- package/src/ui/FindingModal/index.tsx +36 -0
- package/src/ui/HealthPage/Digest.tsx +29 -0
- package/src/ui/HealthPage/FindingRow.tsx +21 -0
- package/src/ui/HealthPage/HealthPage.module.css +45 -0
- package/src/ui/HealthPage/HealthPage.test.tsx +49 -0
- package/src/ui/HealthPage/HealthPage.tsx +66 -0
- package/src/ui/HealthPage/LayerCard.tsx +36 -0
- package/src/ui/HealthPage/SeverityIcon.tsx +29 -0
- package/src/ui/HealthPage/SeverityLegend.tsx +27 -0
- package/src/ui/HealthPage/severity.test.ts +46 -0
- package/src/ui/HealthPage/severity.ts +21 -0
- package/src/ui/ItemCard.module.css +131 -0
- package/src/ui/ItemCard.tsx +117 -0
- package/src/ui/ItemModal/DescriptionField.tsx +22 -0
- package/src/ui/ItemModal/EnrichmentSections.tsx +49 -0
- package/src/ui/ItemModal/ItemModal.module.css +200 -0
- package/src/ui/ItemModal/LinksSection.tsx +26 -0
- package/src/ui/ItemModal/NotesSection.tsx +18 -0
- package/src/ui/ItemModal/PhasesSection.tsx +79 -0
- package/src/ui/ItemModal/RelatedSection.tsx +26 -0
- package/src/ui/ItemModal/RelationshipsSection.tsx +69 -0
- package/src/ui/ItemModal/StatusLayerControls.tsx +43 -0
- package/src/ui/ItemModal/TitleField.tsx +21 -0
- package/src/ui/ItemModal/index.tsx +117 -0
- package/src/ui/KanbanColumn.module.css +20 -0
- package/src/ui/KanbanColumn.tsx +36 -0
- package/src/ui/LayerDetailPage/LayerDetailPage.module.css +11 -0
- package/src/ui/LayerDetailPage/LayerDetailPage.test.tsx +30 -0
- package/src/ui/LayerDetailPage/LayerDetailPage.tsx +59 -0
- package/src/ui/LayerDetailPage/SignalSection.tsx +33 -0
- package/src/ui/LayerDrawer.module.css +60 -0
- package/src/ui/LayerDrawer.tsx +69 -0
- package/src/ui/LayerKanban.module.css +45 -0
- package/src/ui/LayerKanban.tsx +65 -0
- package/src/ui/LayerPage/FillThisInButton.tsx +11 -0
- package/src/ui/LayerPage/LayerPage.module.css +52 -0
- package/src/ui/LayerPage/LayerPage.tsx +76 -0
- package/src/ui/LayerPage/MetricsBadges.tsx +18 -0
- package/src/ui/LayerPage/isTemplateOnly.test.ts +45 -0
- package/src/ui/LayerPage/isTemplateOnly.ts +19 -0
- package/src/ui/LayerPage/tabs/CodeMapTab.tsx +19 -0
- package/src/ui/LayerPage/tabs/DeepDivesTab.tsx +35 -0
- package/src/ui/LayerPage/tabs/IntentTab.tsx +19 -0
- package/src/ui/LayerPage/tabs/PlainEnglishTab.tsx +19 -0
- package/src/ui/Markdown.module.css +115 -0
- package/src/ui/Markdown.tsx +27 -0
- package/src/ui/RoadmapPage/RoadmapPage.module.css +93 -0
- package/src/ui/RoadmapPage/RoadmapPage.tsx +255 -0
- package/src/ui/ScopePage/ScopePage.module.css +72 -0
- package/src/ui/ScopePage/ScopePage.tsx +131 -0
- package/src/ui/SummaryStrip.module.css +18 -0
- package/src/ui/SummaryStrip.tsx +16 -0
- package/src/ui/TaskList.module.css +136 -0
- package/src/ui/TaskList.tsx +91 -0
- package/src/ui/TopBar.module.css +78 -0
- package/src/ui/TopBar.tsx +48 -0
- package/src/ui/WorkspacePage/WorkspacePage.module.css +24 -0
- package/src/ui/WorkspacePage/WorkspacePage.tsx +36 -0
- package/src/ui/WorkspacePage/tabs/GlossaryTab.tsx +6 -0
- package/src/ui/WorkspacePage/tabs/OverviewTab.tsx +28 -0
- package/src/ui/WorkspacePage/tabs/PrinciplesTab.tsx +6 -0
- package/tsconfig.json +18 -0
- package/vite.config.ts +9 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Plugin } from 'vite';
|
|
2
|
+
import { findRepoRoot } from './lib/repoRoot';
|
|
3
|
+
import { stateRoute } from './routes/state';
|
|
4
|
+
import { itemsRoute } from './routes/items';
|
|
5
|
+
import { reorderRoute } from './routes/reorder';
|
|
6
|
+
import { relatedRoute } from './routes/related';
|
|
7
|
+
import { openRoute } from './routes/open';
|
|
8
|
+
import { watchRoute } from './routes/watch';
|
|
9
|
+
import { layerDocsRoute } from './routes/layerDocs';
|
|
10
|
+
import { layerContentRoute } from './routes/layerContent';
|
|
11
|
+
|
|
12
|
+
export function tenboApiPlugin(): Plugin {
|
|
13
|
+
return {
|
|
14
|
+
name: 'tenbo-api',
|
|
15
|
+
configureServer(server) {
|
|
16
|
+
const repoRoot = findRepoRoot(process.cwd());
|
|
17
|
+
if (!repoRoot) {
|
|
18
|
+
console.error('[tenbo-dashboard] no git repo root found from cwd');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
server.middlewares.use(stateRoute(repoRoot));
|
|
22
|
+
server.middlewares.use(itemsRoute(repoRoot));
|
|
23
|
+
server.middlewares.use(reorderRoute(repoRoot));
|
|
24
|
+
server.middlewares.use(relatedRoute(repoRoot));
|
|
25
|
+
server.middlewares.use(openRoute(repoRoot));
|
|
26
|
+
server.middlewares.use(watchRoute(repoRoot));
|
|
27
|
+
server.middlewares.use(layerDocsRoute(repoRoot));
|
|
28
|
+
server.middlewares.use(layerContentRoute(repoRoot));
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Connect } from 'vite';
|
|
2
|
+
import { patchItem } from '../lib/tenboFs';
|
|
3
|
+
import { readBody, json, error, withErrorHandling } from '../lib/http';
|
|
4
|
+
|
|
5
|
+
const ALLOWED = new Set(['title', 'description', 'status', 'layer', 'notes', 'links', 'priority', 'done_when', 'files_to_read', 'risks']);
|
|
6
|
+
|
|
7
|
+
export function itemsRoute(repoRoot: string): Connect.NextHandleFunction {
|
|
8
|
+
return withErrorHandling(async (req, res, next) => {
|
|
9
|
+
const m = req.url?.match(/^\/api\/items\/([^/?]+)(\?.*)?$/);
|
|
10
|
+
if (!m || req.method !== 'PATCH') return next();
|
|
11
|
+
const itemId = m[1];
|
|
12
|
+
const body = await readBody<{ scope?: string; patch?: Record<string, unknown> }>(req);
|
|
13
|
+
const scopeId = body.scope;
|
|
14
|
+
const patch = body.patch;
|
|
15
|
+
if (!scopeId || !patch) {
|
|
16
|
+
return error(res, 400, 'scope and patch required');
|
|
17
|
+
}
|
|
18
|
+
const filtered: Record<string, unknown> = {};
|
|
19
|
+
for (const k of Object.keys(patch)) {
|
|
20
|
+
if (ALLOWED.has(k)) filtered[k] = patch[k];
|
|
21
|
+
}
|
|
22
|
+
patchItem(repoRoot, scopeId, itemId, filtered);
|
|
23
|
+
json(res, { ok: true });
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Connect } from 'vite';
|
|
2
|
+
import { readLayerContent } from '../lib/tenboFs';
|
|
3
|
+
import { json, error } from '../lib/http';
|
|
4
|
+
|
|
5
|
+
export function layerContentRoute(repoRoot: string): Connect.NextHandleFunction {
|
|
6
|
+
return (req, res, next) => {
|
|
7
|
+
if (req.method !== 'GET' || !req.url?.startsWith('/api/layer-content')) return next();
|
|
8
|
+
const url = new URL(req.url, 'http://localhost');
|
|
9
|
+
const scope = url.searchParams.get('scope');
|
|
10
|
+
const layer = url.searchParams.get('layer');
|
|
11
|
+
if (!scope || !layer) {
|
|
12
|
+
error(res, 400, 'scope and layer query parameters are required');
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const content = readLayerContent(repoRoot, scope, layer);
|
|
17
|
+
json(res, content);
|
|
18
|
+
} catch (err) {
|
|
19
|
+
error(res, 500, String(err));
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Connect } from 'vite';
|
|
2
|
+
import { listLayerDocs } from '../lib/tenboFs';
|
|
3
|
+
import { json, error } from '../lib/http';
|
|
4
|
+
|
|
5
|
+
export function layerDocsRoute(repoRoot: string): Connect.NextHandleFunction {
|
|
6
|
+
return (req, res, next) => {
|
|
7
|
+
if (req.method !== 'GET' || !req.url?.startsWith('/api/layer-docs')) return next();
|
|
8
|
+
const url = new URL(req.url, 'http://localhost');
|
|
9
|
+
const scopeId = url.searchParams.get('scope');
|
|
10
|
+
const layerId = url.searchParams.get('layer');
|
|
11
|
+
if (!scopeId || !layerId) {
|
|
12
|
+
error(res, 400, 'scope and layer query params required');
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const docs = listLayerDocs(repoRoot, scopeId, layerId);
|
|
17
|
+
json(res, docs);
|
|
18
|
+
} catch (err) {
|
|
19
|
+
error(res, 500, String(err));
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Connect } from 'vite';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { readBody, json, error, withErrorHandling } from '../lib/http';
|
|
5
|
+
|
|
6
|
+
export function openRoute(repoRoot: string): Connect.NextHandleFunction {
|
|
7
|
+
return withErrorHandling(async (req, res, next) => {
|
|
8
|
+
if (req.method !== 'POST' || req.url !== '/api/open') return next();
|
|
9
|
+
const { path: rel } = await readBody<{ path?: unknown }>(req);
|
|
10
|
+
if (typeof rel !== 'string') {
|
|
11
|
+
return error(res, 400, 'path required');
|
|
12
|
+
}
|
|
13
|
+
const abs = path.resolve(repoRoot, rel);
|
|
14
|
+
// VS Code / Cursor; if `code` not on PATH, this just fails silently in spawn
|
|
15
|
+
spawn('code', ['-g', abs], { detached: true, stdio: 'ignore' }).unref();
|
|
16
|
+
json(res, { ok: true });
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Connect } from 'vite';
|
|
2
|
+
import { scanForRelated } from '../lib/frontmatterScan';
|
|
3
|
+
import { json, withErrorHandling } from '../lib/http';
|
|
4
|
+
|
|
5
|
+
export function relatedRoute(repoRoot: string): Connect.NextHandleFunction {
|
|
6
|
+
return withErrorHandling((req, res, next) => {
|
|
7
|
+
if (req.method !== 'GET' || req.url !== '/api/related') return next();
|
|
8
|
+
const docs = scanForRelated(repoRoot);
|
|
9
|
+
json(res, docs);
|
|
10
|
+
});
|
|
11
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Connect } from 'vite';
|
|
2
|
+
import { reorderItems } from '../lib/tenboFs';
|
|
3
|
+
import { readBody, json, error, withErrorHandling } from '../lib/http';
|
|
4
|
+
|
|
5
|
+
export function reorderRoute(repoRoot: string): Connect.NextHandleFunction {
|
|
6
|
+
return withErrorHandling(async (req, res, next) => {
|
|
7
|
+
if (req.method !== 'POST' || req.url !== '/api/items/reorder') return next();
|
|
8
|
+
const { scope, ids } = await readBody<{ scope?: string; ids?: unknown }>(req);
|
|
9
|
+
if (!scope || !Array.isArray(ids)) {
|
|
10
|
+
return error(res, 400, 'scope and ids required');
|
|
11
|
+
}
|
|
12
|
+
reorderItems(repoRoot, scope, ids as string[]);
|
|
13
|
+
json(res, { ok: true });
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Connect } from 'vite';
|
|
2
|
+
import { readState, readWorkspace, tenboExists } from '../lib/tenboFs';
|
|
3
|
+
import { ensureFresh } from '../lib/metricsRefresh';
|
|
4
|
+
import { json, error, withErrorHandling } from '../lib/http';
|
|
5
|
+
|
|
6
|
+
export function stateRoute(repoRoot: string): Connect.NextHandleFunction {
|
|
7
|
+
return withErrorHandling(async (req, res, next) => {
|
|
8
|
+
if (req.method !== 'GET' || req.url !== '/api/state') return next();
|
|
9
|
+
if (!tenboExists(repoRoot)) {
|
|
10
|
+
return error(res, 404, 'no .tenbo/ found at repo root');
|
|
11
|
+
}
|
|
12
|
+
const { scopeRefs } = readWorkspace(repoRoot);
|
|
13
|
+
for (const ref of scopeRefs) {
|
|
14
|
+
try {
|
|
15
|
+
await ensureFresh(repoRoot, ref.id);
|
|
16
|
+
} catch (e) {
|
|
17
|
+
console.warn(`tenbo: metric refresh failed for scope ${ref.id}:`, e);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const state = readState(repoRoot);
|
|
21
|
+
json(res, state);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Connect } from 'vite';
|
|
2
|
+
import chokidar from 'chokidar';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export function watchRoute(repoRoot: string): Connect.NextHandleFunction {
|
|
6
|
+
// One watcher per process; multiple SSE clients share it.
|
|
7
|
+
const watchPath = path.join(repoRoot, '.tenbo');
|
|
8
|
+
const docsPath = path.join(repoRoot, 'docs/superpowers');
|
|
9
|
+
const watcher = chokidar.watch([watchPath, docsPath], {
|
|
10
|
+
ignoreInitial: true,
|
|
11
|
+
awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
type Client = { write: (data: string) => void; end: () => void };
|
|
15
|
+
const clients = new Set<Client>();
|
|
16
|
+
watcher.on('all', (event, file) => {
|
|
17
|
+
const payload = `data: ${JSON.stringify({ event, path: path.relative(repoRoot, file) })}\n\n`;
|
|
18
|
+
for (const c of clients) c.write(payload);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
return (req, res, next) => {
|
|
22
|
+
if (req.method !== 'GET' || req.url !== '/api/watch') return next();
|
|
23
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
24
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
25
|
+
res.setHeader('Connection', 'keep-alive');
|
|
26
|
+
res.write(': connected\n\n');
|
|
27
|
+
const client: Client = { write: (d) => res.write(d), end: () => res.end() };
|
|
28
|
+
clients.add(client);
|
|
29
|
+
req.on('close', () => clients.delete(client));
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { useCallback } from 'react';
|
|
2
|
+
import type { Item } from '../types';
|
|
3
|
+
import { tenboApi } from '../api/client';
|
|
4
|
+
|
|
5
|
+
export function useApiPatch() {
|
|
6
|
+
return useCallback((scopeId: string, itemId: string, patch: Partial<Item>) => {
|
|
7
|
+
return tenboApi.patchItem(scopeId, itemId, patch);
|
|
8
|
+
}, []);
|
|
9
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import type { LayerContent } from '../types';
|
|
3
|
+
|
|
4
|
+
export function useLayerContent(
|
|
5
|
+
scopeId: string,
|
|
6
|
+
layerId: string,
|
|
7
|
+
generation: number,
|
|
8
|
+
): { content: LayerContent | null; error: string | null } {
|
|
9
|
+
const [content, setContent] = useState<LayerContent | null>(null);
|
|
10
|
+
const [error, setError] = useState<string | null>(null);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
let cancelled = false;
|
|
13
|
+
setContent(null);
|
|
14
|
+
setError(null);
|
|
15
|
+
fetch(`/api/layer-content?scope=${encodeURIComponent(scopeId)}&layer=${encodeURIComponent(layerId)}`)
|
|
16
|
+
.then(async (r) => {
|
|
17
|
+
if (!r.ok) throw new Error(`GET /api/layer-content -> ${r.status}`);
|
|
18
|
+
return r.json();
|
|
19
|
+
})
|
|
20
|
+
.then((c) => { if (!cancelled) setContent(c); })
|
|
21
|
+
.catch((e) => { if (!cancelled) setError(String(e)); });
|
|
22
|
+
return () => { cancelled = true; };
|
|
23
|
+
}, [scopeId, layerId, generation]);
|
|
24
|
+
return { content, error };
|
|
25
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
export function usePrompt() {
|
|
4
|
+
const [toast, setToast] = useState<string | null>(null);
|
|
5
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
6
|
+
|
|
7
|
+
useEffect(() => () => {
|
|
8
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
9
|
+
}, []);
|
|
10
|
+
|
|
11
|
+
const copy = useCallback(async (text: string, label: string) => {
|
|
12
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
13
|
+
try {
|
|
14
|
+
await navigator.clipboard.writeText(text);
|
|
15
|
+
setToast(`${label} copied. Paste into Claude Code in the repo root.`);
|
|
16
|
+
} catch {
|
|
17
|
+
setToast('Copy failed. Select and copy manually.');
|
|
18
|
+
}
|
|
19
|
+
timerRef.current = setTimeout(() => setToast(null), 3000);
|
|
20
|
+
}, []);
|
|
21
|
+
|
|
22
|
+
return { copy, toast };
|
|
23
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useEffect, useState, useCallback, useRef } from 'react';
|
|
2
|
+
import type { TenboState } from '../types';
|
|
3
|
+
import { tenboApi } from '../api/client';
|
|
4
|
+
|
|
5
|
+
export function useTenboState() {
|
|
6
|
+
const [state, setState] = useState<TenboState | null>(null);
|
|
7
|
+
const [loadError, setLoadError] = useState<string | null>(null);
|
|
8
|
+
const [generation, setGeneration] = useState<number>(0);
|
|
9
|
+
const reloadDebounce = useRef<number | null>(null);
|
|
10
|
+
|
|
11
|
+
const reload = useCallback(async () => {
|
|
12
|
+
try {
|
|
13
|
+
setState(await tenboApi.getState());
|
|
14
|
+
setGeneration(n => n + 1);
|
|
15
|
+
setLoadError(null);
|
|
16
|
+
} catch (e) {
|
|
17
|
+
setLoadError(String(e));
|
|
18
|
+
}
|
|
19
|
+
}, []);
|
|
20
|
+
|
|
21
|
+
useEffect(() => { reload(); }, [reload]);
|
|
22
|
+
|
|
23
|
+
// SSE: debounced reload on any file change
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const es = new EventSource('/api/watch');
|
|
26
|
+
es.onmessage = () => {
|
|
27
|
+
if (reloadDebounce.current) window.clearTimeout(reloadDebounce.current);
|
|
28
|
+
reloadDebounce.current = window.setTimeout(() => { reload(); }, 250);
|
|
29
|
+
};
|
|
30
|
+
es.onerror = () => { /* browser auto-reconnects */ };
|
|
31
|
+
return () => es.close();
|
|
32
|
+
}, [reload]);
|
|
33
|
+
|
|
34
|
+
return { state, loadError, reload, generation };
|
|
35
|
+
}
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import ReactDOM from 'react-dom/client';
|
|
3
|
+
import App from './App';
|
|
4
|
+
import { ErrorBoundary } from './ErrorBoundary';
|
|
5
|
+
import './styles.css';
|
|
6
|
+
|
|
7
|
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
8
|
+
<React.StrictMode>
|
|
9
|
+
<ErrorBoundary>
|
|
10
|
+
<App />
|
|
11
|
+
</ErrorBoundary>
|
|
12
|
+
</React.StrictMode>
|
|
13
|
+
);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function populateLayerPrompt(
|
|
2
|
+
target: 'intent' | 'codeMap' | 'readme',
|
|
3
|
+
scopeId: string,
|
|
4
|
+
layerId: string,
|
|
5
|
+
): string {
|
|
6
|
+
const file = target === 'intent' ? 'intent.md' : target === 'codeMap' ? 'code-map.md' : 'README.md';
|
|
7
|
+
return [
|
|
8
|
+
`Run the tenbo populate-architecture behavior for scope \`${scopeId}\`, layer \`${layerId}\`.`,
|
|
9
|
+
`Focus on \`${file}\`.`,
|
|
10
|
+
`Use the universal prompting rule: ask, don't guess.`,
|
|
11
|
+
].join('\n');
|
|
12
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export type WorkspaceTab = typeof WORKSPACE_TABS[number];
|
|
2
|
+
export type LayerTab = typeof LAYER_TABS[number];
|
|
3
|
+
export type Mode = 'docs' | 'roadmap' | 'health';
|
|
4
|
+
|
|
5
|
+
const WORKSPACE_TABS = ['overview', 'principles', 'glossary'] as const;
|
|
6
|
+
const LAYER_TABS = ['overview', 'purpose', 'files', 'docs'] as const;
|
|
7
|
+
|
|
8
|
+
export type Route =
|
|
9
|
+
| { kind: 'docs-project'; tab: WorkspaceTab }
|
|
10
|
+
| { kind: 'docs-scope'; scopeId: string }
|
|
11
|
+
| { kind: 'docs-layer'; scopeId: string; layerId: string; tab: LayerTab }
|
|
12
|
+
| { kind: 'roadmap'; scope?: string; layer?: string }
|
|
13
|
+
| { kind: 'health' }
|
|
14
|
+
| { kind: 'health-layer'; scopeId: string; layerId: string }
|
|
15
|
+
| { kind: 'item'; itemId: string }
|
|
16
|
+
| { kind: 'unknown' };
|
|
17
|
+
|
|
18
|
+
function isWorkspaceTab(s: string | undefined): s is WorkspaceTab {
|
|
19
|
+
return WORKSPACE_TABS.includes(s as WorkspaceTab);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isLayerTab(s: string | undefined): s is LayerTab {
|
|
23
|
+
return LAYER_TABS.includes(s as LayerTab);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function splitPathAndQuery(raw: string): { parts: string[]; query: URLSearchParams } {
|
|
27
|
+
const stripped = raw.replace(/^#\/?/, '');
|
|
28
|
+
const [pathPart, queryPart = ''] = stripped.split('?');
|
|
29
|
+
const parts = pathPart.split('/').filter(Boolean);
|
|
30
|
+
const query = new URLSearchParams(queryPart);
|
|
31
|
+
return { parts, query };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function parseHash(hash: string): Route {
|
|
35
|
+
const { parts, query } = splitPathAndQuery(hash);
|
|
36
|
+
if (parts.length === 0) return { kind: 'roadmap' };
|
|
37
|
+
|
|
38
|
+
// Backward-compat redirects from the pre-Phase-1 route shapes.
|
|
39
|
+
if (parts[0] === 'workspace') {
|
|
40
|
+
if (parts[1] === 'cross-cutting') return { kind: 'roadmap' };
|
|
41
|
+
const tab: WorkspaceTab = isWorkspaceTab(parts[1]) ? parts[1] : 'overview';
|
|
42
|
+
return { kind: 'docs-project', tab };
|
|
43
|
+
}
|
|
44
|
+
if (parts[0] === 'scope' && parts[1]) {
|
|
45
|
+
if (parts[2] === 'layer' && parts[3]) {
|
|
46
|
+
const tab: LayerTab = isLayerTab(parts[4]) ? parts[4] : 'overview';
|
|
47
|
+
return { kind: 'docs-layer', scopeId: parts[1], layerId: parts[3], tab };
|
|
48
|
+
}
|
|
49
|
+
return { kind: 'roadmap', scope: parts[1] };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (parts[0] === 'docs') {
|
|
53
|
+
if (parts.length === 1) return { kind: 'docs-project', tab: 'overview' };
|
|
54
|
+
if (parts[1] === 'scope' && parts[2]) {
|
|
55
|
+
if (parts[3] === 'layer' && parts[4]) {
|
|
56
|
+
const tab: LayerTab = isLayerTab(parts[5]) ? parts[5] : 'overview';
|
|
57
|
+
return { kind: 'docs-layer', scopeId: parts[2], layerId: parts[4], tab };
|
|
58
|
+
}
|
|
59
|
+
return { kind: 'docs-scope', scopeId: parts[2] };
|
|
60
|
+
}
|
|
61
|
+
const tab: WorkspaceTab = isWorkspaceTab(parts[1]) ? parts[1] : 'overview';
|
|
62
|
+
return { kind: 'docs-project', tab };
|
|
63
|
+
}
|
|
64
|
+
if (parts[0] === 'roadmap') {
|
|
65
|
+
const scope = query.get('scope') ?? undefined;
|
|
66
|
+
const layer = query.get('layer') ?? undefined;
|
|
67
|
+
return { kind: 'roadmap', scope, layer };
|
|
68
|
+
}
|
|
69
|
+
if (parts[0] === 'item' && parts[1]) return { kind: 'item', itemId: parts[1] };
|
|
70
|
+
if (parts[0] === 'health' && parts[1] && parts[2]) return { kind: 'health-layer', scopeId: parts[1], layerId: parts[2] };
|
|
71
|
+
if (parts[0] === 'health') return { kind: 'health' };
|
|
72
|
+
return { kind: 'unknown' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function formatRoute(r: Route): string {
|
|
76
|
+
switch (r.kind) {
|
|
77
|
+
case 'docs-project': return r.tab === 'overview' ? '#/docs' : `#/docs/${r.tab}`;
|
|
78
|
+
case 'docs-scope': return `#/docs/scope/${r.scopeId}`;
|
|
79
|
+
case 'docs-layer': return `#/docs/scope/${r.scopeId}/layer/${r.layerId}/${r.tab}`;
|
|
80
|
+
case 'roadmap': {
|
|
81
|
+
const params = new URLSearchParams();
|
|
82
|
+
if (r.scope) params.set('scope', r.scope);
|
|
83
|
+
if (r.layer) params.set('layer', r.layer);
|
|
84
|
+
const q = params.toString();
|
|
85
|
+
return q ? `#/roadmap?${q}` : '#/roadmap';
|
|
86
|
+
}
|
|
87
|
+
case 'item': return `#/item/${r.itemId}`;
|
|
88
|
+
case 'health': return '#/health';
|
|
89
|
+
case 'health-layer': return `#/health/${r.scopeId}/${r.layerId}`;
|
|
90
|
+
default: return '#/';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function modeFromRoute(r: Route): Mode {
|
|
95
|
+
if (r.kind === 'roadmap') return 'roadmap';
|
|
96
|
+
if (r.kind === 'health' || r.kind === 'health-layer') return 'health';
|
|
97
|
+
return 'docs';
|
|
98
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseHash, formatRoute } from './routes.js';
|
|
3
|
+
|
|
4
|
+
describe('parseHash', () => {
|
|
5
|
+
it('defaults to docs project overview on empty hash', () => {
|
|
6
|
+
expect(parseHash('')).toEqual({ kind: 'docs-project', tab: 'overview' });
|
|
7
|
+
expect(parseHash('#/')).toEqual({ kind: 'docs-project', tab: 'overview' });
|
|
8
|
+
expect(parseHash('#/docs')).toEqual({ kind: 'docs-project', tab: 'overview' });
|
|
9
|
+
});
|
|
10
|
+
it('parses docs project tab', () => {
|
|
11
|
+
expect(parseHash('#/docs/glossary')).toEqual({ kind: 'docs-project', tab: 'glossary' });
|
|
12
|
+
});
|
|
13
|
+
it('parses docs scope', () => {
|
|
14
|
+
expect(parseHash('#/docs/scope/editor')).toEqual({ kind: 'docs-scope', scopeId: 'editor' });
|
|
15
|
+
});
|
|
16
|
+
it('parses docs layer with default tab', () => {
|
|
17
|
+
expect(parseHash('#/docs/scope/editor/layer/foo')).toEqual({ kind: 'docs-layer', scopeId: 'editor', layerId: 'foo', tab: 'overview' });
|
|
18
|
+
});
|
|
19
|
+
it('parses docs layer with explicit tab', () => {
|
|
20
|
+
expect(parseHash('#/docs/scope/editor/layer/foo/purpose')).toEqual({ kind: 'docs-layer', scopeId: 'editor', layerId: 'foo', tab: 'purpose' });
|
|
21
|
+
});
|
|
22
|
+
it('parses roadmap with no filter', () => {
|
|
23
|
+
expect(parseHash('#/roadmap')).toEqual({ kind: 'roadmap', scope: undefined, layer: undefined });
|
|
24
|
+
});
|
|
25
|
+
it('parses roadmap with scope and layer filters', () => {
|
|
26
|
+
expect(parseHash('#/roadmap?scope=editor&layer=ai')).toEqual({ kind: 'roadmap', scope: 'editor', layer: 'ai' });
|
|
27
|
+
});
|
|
28
|
+
it('parses item', () => {
|
|
29
|
+
expect(parseHash('#/item/cmp-001')).toEqual({ kind: 'item', itemId: 'cmp-001' });
|
|
30
|
+
});
|
|
31
|
+
it('redirects legacy workspace tab to docs', () => {
|
|
32
|
+
expect(parseHash('#/workspace/principles')).toEqual({ kind: 'docs-project', tab: 'principles' });
|
|
33
|
+
});
|
|
34
|
+
it('redirects legacy workspace cross-cutting to roadmap', () => {
|
|
35
|
+
expect(parseHash('#/workspace/cross-cutting')).toEqual({ kind: 'roadmap' });
|
|
36
|
+
});
|
|
37
|
+
it('redirects legacy scope to roadmap with filter', () => {
|
|
38
|
+
expect(parseHash('#/scope/editor')).toEqual({ kind: 'roadmap', scope: 'editor' });
|
|
39
|
+
});
|
|
40
|
+
it('redirects legacy layer to docs-layer', () => {
|
|
41
|
+
expect(parseHash('#/scope/editor/layer/foo/purpose')).toEqual({ kind: 'docs-layer', scopeId: 'editor', layerId: 'foo', tab: 'purpose' });
|
|
42
|
+
});
|
|
43
|
+
it('parses /health/<scope>/<layer>', () => {
|
|
44
|
+
expect(parseHash('#/health/editor/visual-canvas')).toEqual({
|
|
45
|
+
kind: 'health-layer', scopeId: 'editor', layerId: 'visual-canvas',
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('formatRoute', () => {
|
|
51
|
+
it('round-trips docs-layer', () => {
|
|
52
|
+
expect(formatRoute({ kind: 'docs-layer', scopeId: 'editor', layerId: 'foo', tab: 'purpose' })).toBe('#/docs/scope/editor/layer/foo/purpose');
|
|
53
|
+
});
|
|
54
|
+
it('formats roadmap with filters', () => {
|
|
55
|
+
expect(formatRoute({ kind: 'roadmap', scope: 'editor', layer: 'ai' })).toBe('#/roadmap?scope=editor&layer=ai');
|
|
56
|
+
});
|
|
57
|
+
it('formats roadmap without filters', () => {
|
|
58
|
+
expect(formatRoute({ kind: 'roadmap' })).toBe('#/roadmap');
|
|
59
|
+
});
|
|
60
|
+
it('formats docs-project default tab compactly', () => {
|
|
61
|
+
expect(formatRoute({ kind: 'docs-project', tab: 'overview' })).toBe('#/docs');
|
|
62
|
+
expect(formatRoute({ kind: 'docs-project', tab: 'glossary' })).toBe('#/docs/glossary');
|
|
63
|
+
});
|
|
64
|
+
it('formats health-layer', () => {
|
|
65
|
+
expect(formatRoute({ kind: 'health-layer', scopeId: 'editor', layerId: 'visual-canvas' })).toBe('#/health/editor/visual-canvas');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useEffect, useState, useCallback } from 'react';
|
|
2
|
+
import { parseHash, formatRoute, type Route } from './routes.js';
|
|
3
|
+
|
|
4
|
+
export function useHashRoute(): { route: Route; navigate: (r: Route) => void } {
|
|
5
|
+
const [route, setRoute] = useState<Route>(() => parseHash(window.location.hash));
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
const onHash = () => setRoute(parseHash(window.location.hash));
|
|
8
|
+
window.addEventListener('hashchange', onHash);
|
|
9
|
+
return () => window.removeEventListener('hashchange', onHash);
|
|
10
|
+
}, []);
|
|
11
|
+
const navigate = useCallback((r: Route) => {
|
|
12
|
+
window.location.hash = formatRoute(r);
|
|
13
|
+
}, []);
|
|
14
|
+
return { route, navigate };
|
|
15
|
+
}
|