@assistkick/create 1.0.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/dist/bin/create.d.ts +2 -0
- package/dist/bin/create.js +25 -0
- package/dist/bin/create.js.map +1 -0
- package/dist/src/scaffolder.d.ts +22 -0
- package/dist/src/scaffolder.js +120 -0
- package/dist/src/scaffolder.js.map +1 -0
- package/package.json +24 -0
- package/templates/product-system/.env.example +8 -0
- package/templates/product-system/CLAUDE.md +45 -0
- package/templates/product-system/package.json +32 -0
- package/templates/product-system/packages/backend/package.json +37 -0
- package/templates/product-system/packages/backend/src/middleware/auth_middleware.test.ts +86 -0
- package/templates/product-system/packages/backend/src/middleware/auth_middleware.ts +35 -0
- package/templates/product-system/packages/backend/src/routes/auth.ts +463 -0
- package/templates/product-system/packages/backend/src/routes/coherence.ts +187 -0
- package/templates/product-system/packages/backend/src/routes/graph.ts +67 -0
- package/templates/product-system/packages/backend/src/routes/kanban.ts +201 -0
- package/templates/product-system/packages/backend/src/routes/pipeline.ts +41 -0
- package/templates/product-system/packages/backend/src/routes/projects.ts +122 -0
- package/templates/product-system/packages/backend/src/routes/users.ts +97 -0
- package/templates/product-system/packages/backend/src/server.ts +159 -0
- package/templates/product-system/packages/backend/src/services/auth_service.test.ts +115 -0
- package/templates/product-system/packages/backend/src/services/auth_service.ts +82 -0
- package/templates/product-system/packages/backend/src/services/coherence-review.ts +339 -0
- package/templates/product-system/packages/backend/src/services/email_service.ts +75 -0
- package/templates/product-system/packages/backend/src/services/init.ts +80 -0
- package/templates/product-system/packages/backend/src/services/invitation_service.test.ts +235 -0
- package/templates/product-system/packages/backend/src/services/invitation_service.ts +193 -0
- package/templates/product-system/packages/backend/src/services/password_reset_service.test.ts +151 -0
- package/templates/product-system/packages/backend/src/services/password_reset_service.ts +135 -0
- package/templates/product-system/packages/backend/src/services/project_service.test.ts +215 -0
- package/templates/product-system/packages/backend/src/services/project_service.ts +171 -0
- package/templates/product-system/packages/backend/src/services/pty_session_manager.test.ts +88 -0
- package/templates/product-system/packages/backend/src/services/pty_session_manager.ts +279 -0
- package/templates/product-system/packages/backend/src/services/terminal_ws_handler.ts +133 -0
- package/templates/product-system/packages/backend/src/services/user_management_service.test.ts +158 -0
- package/templates/product-system/packages/backend/src/services/user_management_service.ts +128 -0
- package/templates/product-system/packages/backend/tsconfig.json +22 -0
- package/templates/product-system/packages/frontend/index.html +13 -0
- package/templates/product-system/packages/frontend/package-lock.json +2666 -0
- package/templates/product-system/packages/frontend/package.json +30 -0
- package/templates/product-system/packages/frontend/public/favicon.svg +16 -0
- package/templates/product-system/packages/frontend/src/App.tsx +29 -0
- package/templates/product-system/packages/frontend/src/api/client.ts +386 -0
- package/templates/product-system/packages/frontend/src/api/client_projects.test.ts +104 -0
- package/templates/product-system/packages/frontend/src/api/client_refresh.test.ts +145 -0
- package/templates/product-system/packages/frontend/src/components/CoherenceView.tsx +414 -0
- package/templates/product-system/packages/frontend/src/components/GraphLegend.tsx +124 -0
- package/templates/product-system/packages/frontend/src/components/GraphSettings.tsx +112 -0
- package/templates/product-system/packages/frontend/src/components/GraphView.tsx +370 -0
- package/templates/product-system/packages/frontend/src/components/InviteUserDialog.tsx +85 -0
- package/templates/product-system/packages/frontend/src/components/KanbanView.tsx +470 -0
- package/templates/product-system/packages/frontend/src/components/LoginPage.tsx +116 -0
- package/templates/product-system/packages/frontend/src/components/ProjectSelector.tsx +187 -0
- package/templates/product-system/packages/frontend/src/components/QaIssueSheet.tsx +192 -0
- package/templates/product-system/packages/frontend/src/components/SidePanel.tsx +231 -0
- package/templates/product-system/packages/frontend/src/components/TerminalView.tsx +200 -0
- package/templates/product-system/packages/frontend/src/components/Toolbar.tsx +84 -0
- package/templates/product-system/packages/frontend/src/components/UsersView.tsx +249 -0
- package/templates/product-system/packages/frontend/src/constants/graph.ts +191 -0
- package/templates/product-system/packages/frontend/src/hooks/useAuth.tsx +54 -0
- package/templates/product-system/packages/frontend/src/hooks/useGraph.ts +27 -0
- package/templates/product-system/packages/frontend/src/hooks/useKanban.ts +21 -0
- package/templates/product-system/packages/frontend/src/hooks/useProjects.ts +86 -0
- package/templates/product-system/packages/frontend/src/hooks/useTheme.ts +26 -0
- package/templates/product-system/packages/frontend/src/hooks/useToast.tsx +62 -0
- package/templates/product-system/packages/frontend/src/hooks/use_projects_logic.test.ts +61 -0
- package/templates/product-system/packages/frontend/src/main.tsx +12 -0
- package/templates/product-system/packages/frontend/src/pages/accept_invitation_page.tsx +167 -0
- package/templates/product-system/packages/frontend/src/pages/forgot_password_page.tsx +100 -0
- package/templates/product-system/packages/frontend/src/pages/register_page.tsx +137 -0
- package/templates/product-system/packages/frontend/src/pages/reset_password_page.tsx +146 -0
- package/templates/product-system/packages/frontend/src/routes/ProtectedRoute.tsx +12 -0
- package/templates/product-system/packages/frontend/src/routes/accept_invitation.tsx +14 -0
- package/templates/product-system/packages/frontend/src/routes/dashboard.tsx +221 -0
- package/templates/product-system/packages/frontend/src/routes/forgot_password.tsx +13 -0
- package/templates/product-system/packages/frontend/src/routes/login.tsx +14 -0
- package/templates/product-system/packages/frontend/src/routes/register.tsx +14 -0
- package/templates/product-system/packages/frontend/src/routes/reset_password.tsx +13 -0
- package/templates/product-system/packages/frontend/src/styles/index.css +3358 -0
- package/templates/product-system/packages/frontend/src/utils/auth_validation.test.ts +51 -0
- package/templates/product-system/packages/frontend/src/utils/auth_validation.ts +19 -0
- package/templates/product-system/packages/frontend/src/utils/login_validation.test.ts +61 -0
- package/templates/product-system/packages/frontend/src/utils/login_validation.ts +24 -0
- package/templates/product-system/packages/frontend/src/utils/logout.test.ts +63 -0
- package/templates/product-system/packages/frontend/src/utils/node_sizing.test.ts +62 -0
- package/templates/product-system/packages/frontend/src/utils/node_sizing.ts +24 -0
- package/templates/product-system/packages/frontend/src/utils/task_status.test.ts +53 -0
- package/templates/product-system/packages/frontend/src/utils/task_status.ts +14 -0
- package/templates/product-system/packages/frontend/tsconfig.json +21 -0
- package/templates/product-system/packages/frontend/vite.config.ts +20 -0
- package/templates/product-system/packages/shared/.env.example +3 -0
- package/templates/product-system/packages/shared/README.md +1 -0
- package/templates/product-system/packages/shared/db/migrate.ts +32 -0
- package/templates/product-system/packages/shared/db/migrations/0000_dashing_gorgon.sql +128 -0
- package/templates/product-system/packages/shared/db/migrations/meta/0000_snapshot.json +819 -0
- package/templates/product-system/packages/shared/db/migrations/meta/_journal.json +13 -0
- package/templates/product-system/packages/shared/db/schema.ts +137 -0
- package/templates/product-system/packages/shared/drizzle.config.js +14 -0
- package/templates/product-system/packages/shared/lib/claude-service.ts +215 -0
- package/templates/product-system/packages/shared/lib/coherence.ts +278 -0
- package/templates/product-system/packages/shared/lib/completeness.ts +30 -0
- package/templates/product-system/packages/shared/lib/constants.ts +327 -0
- package/templates/product-system/packages/shared/lib/db.ts +81 -0
- package/templates/product-system/packages/shared/lib/git_workflow.ts +110 -0
- package/templates/product-system/packages/shared/lib/graph.ts +186 -0
- package/templates/product-system/packages/shared/lib/kanban.ts +161 -0
- package/templates/product-system/packages/shared/lib/markdown.ts +205 -0
- package/templates/product-system/packages/shared/lib/pipeline-state-store.ts +124 -0
- package/templates/product-system/packages/shared/lib/pipeline.ts +489 -0
- package/templates/product-system/packages/shared/lib/prompt_builder.ts +170 -0
- package/templates/product-system/packages/shared/lib/relevance_search.ts +159 -0
- package/templates/product-system/packages/shared/lib/session.ts +152 -0
- package/templates/product-system/packages/shared/lib/validator.ts +117 -0
- package/templates/product-system/packages/shared/lib/work_summary_parser.ts +130 -0
- package/templates/product-system/packages/shared/package.json +30 -0
- package/templates/product-system/packages/shared/scripts/assign-project.ts +52 -0
- package/templates/product-system/packages/shared/tools/add_edge.ts +61 -0
- package/templates/product-system/packages/shared/tools/add_node.ts +101 -0
- package/templates/product-system/packages/shared/tools/end_session.ts +87 -0
- package/templates/product-system/packages/shared/tools/get_gaps.ts +87 -0
- package/templates/product-system/packages/shared/tools/get_kanban.ts +125 -0
- package/templates/product-system/packages/shared/tools/get_node.ts +78 -0
- package/templates/product-system/packages/shared/tools/get_status.ts +98 -0
- package/templates/product-system/packages/shared/tools/migrate_to_turso.ts +385 -0
- package/templates/product-system/packages/shared/tools/move_card.ts +143 -0
- package/templates/product-system/packages/shared/tools/rebuild_index.ts +77 -0
- package/templates/product-system/packages/shared/tools/remove_edge.ts +59 -0
- package/templates/product-system/packages/shared/tools/remove_node.ts +96 -0
- package/templates/product-system/packages/shared/tools/resolve_question.ts +75 -0
- package/templates/product-system/packages/shared/tools/search_nodes.ts +106 -0
- package/templates/product-system/packages/shared/tools/start_session.ts +144 -0
- package/templates/product-system/packages/shared/tools/update_node.ts +133 -0
- package/templates/product-system/packages/shared/tsconfig.json +24 -0
- package/templates/product-system/pnpm-workspace.yaml +2 -0
- package/templates/product-system/smoke_test.ts +219 -0
- package/templates/product-system/tests/coherence_review.test.ts +562 -0
- package/templates/product-system/tests/db_sqlite_fallback.test.ts +75 -0
- package/templates/product-system/tests/edge_type_color_coding.test.ts +147 -0
- package/templates/product-system/tests/emit-tool-use-events.test.ts +85 -0
- package/templates/product-system/tests/feature_kind.test.ts +139 -0
- package/templates/product-system/tests/gap_indicators.test.ts +199 -0
- package/templates/product-system/tests/graceful_init.test.ts +142 -0
- package/templates/product-system/tests/graph_legend.test.ts +314 -0
- package/templates/product-system/tests/graph_settings_sheet.test.ts +804 -0
- package/templates/product-system/tests/hide_defined_filter.test.ts +205 -0
- package/templates/product-system/tests/kanban.test.ts +529 -0
- package/templates/product-system/tests/neighborhood_focus.test.ts +132 -0
- package/templates/product-system/tests/node_search.test.ts +340 -0
- package/templates/product-system/tests/node_sizing.test.ts +170 -0
- package/templates/product-system/tests/node_type_toggle_filters.test.ts +285 -0
- package/templates/product-system/tests/node_type_visual_encoding.test.ts +103 -0
- package/templates/product-system/tests/pipeline-state-store.test.ts +268 -0
- package/templates/product-system/tests/pipeline-unit.test.ts +593 -0
- package/templates/product-system/tests/pipeline.test.ts +195 -0
- package/templates/product-system/tests/pipeline_stats_all_cards.test.ts +193 -0
- package/templates/product-system/tests/play_all.test.ts +296 -0
- package/templates/product-system/tests/qa_issue_sheet.test.ts +464 -0
- package/templates/product-system/tests/relevance_search.test.ts +186 -0
- package/templates/product-system/tests/search_reorder.test.ts +88 -0
- package/templates/product-system/tests/serve_ui.test.ts +281 -0
- package/templates/product-system/tests/serve_ui_drizzle.test.ts +114 -0
- package/templates/product-system/tests/session_context_recall.test.ts +135 -0
- package/templates/product-system/tests/side_panel.test.ts +345 -0
- package/templates/product-system/tests/spec_completeness_label.test.ts +69 -0
- package/templates/product-system/tests/url_routing_test.ts +122 -0
- package/templates/product-system/tests/user_login.test.ts +150 -0
- package/templates/product-system/tests/user_registration.test.ts +205 -0
- package/templates/product-system/tests/web_terminal.test.ts +572 -0
- package/templates/product-system/tests/work_summary.test.ts +211 -0
- package/templates/product-system/tests/zoom_pan.test.ts +43 -0
- package/templates/product-system/tsconfig.json +24 -0
- package/templates/skills/product-bootstrap/SKILL.md +312 -0
- package/templates/skills/product-code-reviewer/SKILL.md +147 -0
- package/templates/skills/product-debugger/SKILL.md +206 -0
- package/templates/skills/product-debugger/references/agent-browser.md +1156 -0
- package/templates/skills/product-developer/SKILL.md +182 -0
- package/templates/skills/product-interview/SKILL.md +220 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, it, mock, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { ApiClient } from './client.ts';
|
|
4
|
+
|
|
5
|
+
describe('ApiClient auto-refresh', () => {
|
|
6
|
+
let originalFetch: typeof globalThis.fetch;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
originalFetch = globalThis.fetch;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
globalThis.fetch = originalFetch;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('retries request after successful token refresh on 401', async () => {
|
|
17
|
+
const calls: { url: string; method?: string }[] = [];
|
|
18
|
+
let callIndex = 0;
|
|
19
|
+
|
|
20
|
+
globalThis.fetch = mock.fn(async (url: any, init?: any) => {
|
|
21
|
+
calls.push({ url, method: init?.method });
|
|
22
|
+
callIndex++;
|
|
23
|
+
|
|
24
|
+
// First call: GET /api/graph → 401
|
|
25
|
+
if (callIndex === 1) {
|
|
26
|
+
return { ok: false, status: 401 } as Response;
|
|
27
|
+
}
|
|
28
|
+
// Second call: POST /api/auth/refresh → 200
|
|
29
|
+
if (callIndex === 2) {
|
|
30
|
+
return { ok: true, json: async () => ({ message: 'Token refreshed' }) } as Response;
|
|
31
|
+
}
|
|
32
|
+
// Third call: GET /api/graph retry → 200
|
|
33
|
+
if (callIndex === 3) {
|
|
34
|
+
return { ok: true, json: async () => ({ nodes: [], edges: [] }) } as Response;
|
|
35
|
+
}
|
|
36
|
+
return { ok: false, status: 500 } as Response;
|
|
37
|
+
}) as any;
|
|
38
|
+
|
|
39
|
+
const client = new ApiClient();
|
|
40
|
+
const result = await client.fetchGraph();
|
|
41
|
+
|
|
42
|
+
assert.deepEqual(result, { nodes: [], edges: [] });
|
|
43
|
+
assert.equal(calls.length, 3);
|
|
44
|
+
assert.equal(calls[0].url, '/api/graph');
|
|
45
|
+
assert.equal(calls[1].url, '/api/auth/refresh');
|
|
46
|
+
assert.equal(calls[1].method, 'POST');
|
|
47
|
+
assert.equal(calls[2].url, '/api/graph');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('does not retry when refresh fails', async () => {
|
|
51
|
+
const calls: string[] = [];
|
|
52
|
+
let callIndex = 0;
|
|
53
|
+
|
|
54
|
+
globalThis.fetch = mock.fn(async (url: any) => {
|
|
55
|
+
calls.push(url);
|
|
56
|
+
callIndex++;
|
|
57
|
+
|
|
58
|
+
if (callIndex === 1) {
|
|
59
|
+
return { ok: false, status: 401 } as Response;
|
|
60
|
+
}
|
|
61
|
+
// Refresh fails
|
|
62
|
+
if (callIndex === 2) {
|
|
63
|
+
return { ok: false, status: 401 } as Response;
|
|
64
|
+
}
|
|
65
|
+
return { ok: true, json: async () => ({}) } as Response;
|
|
66
|
+
}) as any;
|
|
67
|
+
|
|
68
|
+
const client = new ApiClient();
|
|
69
|
+
await assert.rejects(
|
|
70
|
+
() => client.fetchGraph(),
|
|
71
|
+
(err: Error) => {
|
|
72
|
+
assert.match(err.message, /Failed to fetch graph: 401/);
|
|
73
|
+
return true;
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Only 2 calls: original + refresh attempt (no retry)
|
|
78
|
+
assert.equal(calls.length, 2);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('does not attempt refresh for non-401 errors', async () => {
|
|
82
|
+
const calls: string[] = [];
|
|
83
|
+
|
|
84
|
+
globalThis.fetch = mock.fn(async (url: any) => {
|
|
85
|
+
calls.push(url);
|
|
86
|
+
return { ok: false, status: 500 } as Response;
|
|
87
|
+
}) as any;
|
|
88
|
+
|
|
89
|
+
const client = new ApiClient();
|
|
90
|
+
await assert.rejects(
|
|
91
|
+
() => client.fetchGraph(),
|
|
92
|
+
(err: Error) => {
|
|
93
|
+
assert.match(err.message, /Failed to fetch graph: 500/);
|
|
94
|
+
return true;
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// Only 1 call — no refresh attempt
|
|
99
|
+
assert.equal(calls.length, 1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('deduplicates concurrent refresh attempts', async () => {
|
|
103
|
+
let callIndex = 0;
|
|
104
|
+
const refreshCalls: string[] = [];
|
|
105
|
+
|
|
106
|
+
globalThis.fetch = mock.fn(async (url: any, init?: any) => {
|
|
107
|
+
callIndex++;
|
|
108
|
+
|
|
109
|
+
if (url.includes('/api/auth/refresh')) {
|
|
110
|
+
refreshCalls.push(url);
|
|
111
|
+
return { ok: true, json: async () => ({ message: 'Token refreshed' }) } as Response;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// First two calls are 401s (concurrent requests)
|
|
115
|
+
if (callIndex <= 2) {
|
|
116
|
+
return { ok: false, status: 401 } as Response;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { ok: true, json: async () => ({ data: 'ok' }) } as Response;
|
|
120
|
+
}) as any;
|
|
121
|
+
|
|
122
|
+
const client = new ApiClient();
|
|
123
|
+
const [result1, result2] = await Promise.all([
|
|
124
|
+
client.fetchGraph(),
|
|
125
|
+
client.fetchKanban(),
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
// Only one refresh call should have been made
|
|
129
|
+
assert.equal(refreshCalls.length, 1);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('passes credentials: same-origin to all authenticated requests', async () => {
|
|
133
|
+
let capturedCredentials: string | undefined;
|
|
134
|
+
|
|
135
|
+
globalThis.fetch = mock.fn(async (_url: any, init?: any) => {
|
|
136
|
+
capturedCredentials = init?.credentials;
|
|
137
|
+
return { ok: true, json: async () => ({ nodes: [], edges: [] }) } as Response;
|
|
138
|
+
}) as any;
|
|
139
|
+
|
|
140
|
+
const client = new ApiClient();
|
|
141
|
+
await client.fetchGraph();
|
|
142
|
+
|
|
143
|
+
assert.equal(capturedCredentials, 'same-origin');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
2
|
+
import { apiClient } from '../api/client';
|
|
3
|
+
|
|
4
|
+
const TYPE_ORDER = ['deprecate_node', 'remove_edge', 'update_description', 'add_edge', 'add_node'];
|
|
5
|
+
const TYPE_LABELS: Record<string, string> = {
|
|
6
|
+
add_edge: 'Add Edge', add_node: 'Add Node', remove_edge: 'Remove Edge',
|
|
7
|
+
deprecate_node: 'Remove Node', update_description: 'Update Description',
|
|
8
|
+
edge: 'Add Edge', node: 'Add Node',
|
|
9
|
+
};
|
|
10
|
+
const TYPE_CSS: Record<string, string> = {
|
|
11
|
+
add_edge: 'add-edge', add_node: 'add-node', remove_edge: 'remove-edge',
|
|
12
|
+
deprecate_node: 'deprecate-node', update_description: 'update-desc',
|
|
13
|
+
edge: 'add-edge', node: 'add-node',
|
|
14
|
+
};
|
|
15
|
+
const CONFIDENCE_LABELS: Record<string, string> = { high: 'High', medium: 'Medium', low: 'Low' };
|
|
16
|
+
|
|
17
|
+
interface CoherenceViewProps {
|
|
18
|
+
graphData: any;
|
|
19
|
+
onNodeClick: (node: any) => void;
|
|
20
|
+
projectId?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function CoherenceView({ graphData, onNodeClick, projectId }: CoherenceViewProps) {
|
|
24
|
+
const [data, setData] = useState<any>(null);
|
|
25
|
+
const [error, setError] = useState<string | null>(null);
|
|
26
|
+
const [expandedBatches, setExpandedBatches] = useState<Set<string>>(new Set());
|
|
27
|
+
const [resolvedExpanded, setResolvedExpanded] = useState(false);
|
|
28
|
+
const [applyingIds, setApplyingIds] = useState<Set<string>>(new Set());
|
|
29
|
+
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
30
|
+
|
|
31
|
+
const fetchData = useCallback(async () => {
|
|
32
|
+
try {
|
|
33
|
+
const result = await apiClient.fetchCoherence(projectId);
|
|
34
|
+
setData(result);
|
|
35
|
+
setError(null);
|
|
36
|
+
return result;
|
|
37
|
+
} catch (err: any) {
|
|
38
|
+
setError(err.message || 'Failed to load coherence data');
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}, [projectId]);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
fetchData();
|
|
45
|
+
return () => { if (pollRef.current) clearInterval(pollRef.current); };
|
|
46
|
+
}, [fetchData]);
|
|
47
|
+
|
|
48
|
+
// Start polling when review is running
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!data || data.review_status !== 'running') {
|
|
51
|
+
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (pollRef.current) return;
|
|
55
|
+
pollRef.current = setInterval(async () => {
|
|
56
|
+
const result = await fetchData();
|
|
57
|
+
if (result && result.review_status !== 'running') {
|
|
58
|
+
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
|
59
|
+
}
|
|
60
|
+
}, 3000);
|
|
61
|
+
}, [data?.review_status, fetchData]);
|
|
62
|
+
|
|
63
|
+
const findNode = (nodeId: string) => graphData?.nodes.find((n: any) => n.id === nodeId) || null;
|
|
64
|
+
|
|
65
|
+
const sortByType = (proposals: any[]) => {
|
|
66
|
+
return [...proposals].sort((a, b) => {
|
|
67
|
+
const aIdx = TYPE_ORDER.indexOf(a.type);
|
|
68
|
+
const bIdx = TYPE_ORDER.indexOf(b.type);
|
|
69
|
+
return (aIdx >= 0 ? aIdx : TYPE_ORDER.length) - (bIdx >= 0 ? bIdx : TYPE_ORDER.length);
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const groupByBatch = (proposals: any[]) => {
|
|
74
|
+
const batched = new Map<string, any[]>();
|
|
75
|
+
const unbatched: any[] = [];
|
|
76
|
+
for (const p of proposals) {
|
|
77
|
+
if (p.batch_id) {
|
|
78
|
+
if (!batched.has(p.batch_id)) batched.set(p.batch_id, []);
|
|
79
|
+
batched.get(p.batch_id)!.push(p);
|
|
80
|
+
} else {
|
|
81
|
+
unbatched.push(p);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const realBatches = new Map<string, any[]>();
|
|
85
|
+
for (const [batchId, items] of batched) {
|
|
86
|
+
if (items.length >= 2) realBatches.set(batchId, items);
|
|
87
|
+
else unbatched.push(...items);
|
|
88
|
+
}
|
|
89
|
+
return { batched: realBatches, unbatched };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const handleRunReview = async () => {
|
|
93
|
+
try {
|
|
94
|
+
await apiClient.runCoherenceReview(projectId);
|
|
95
|
+
await fetchData();
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.error('Failed to start coherence review:', err);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const handleApprove = async (id: string) => {
|
|
102
|
+
setApplyingIds(prev => new Set(prev).add(id));
|
|
103
|
+
try {
|
|
104
|
+
await apiClient.approveProposal(id, projectId);
|
|
105
|
+
await fetchData();
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.error('Failed to approve proposal:', err);
|
|
108
|
+
} finally {
|
|
109
|
+
setApplyingIds(prev => { const n = new Set(prev); n.delete(id); return n; });
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const handleDismiss = async (id: string) => {
|
|
114
|
+
try {
|
|
115
|
+
await apiClient.dismissProposal(id, projectId);
|
|
116
|
+
await fetchData();
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.error('Failed to dismiss proposal:', err);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const handleBatchApprove = async (batchId: string) => {
|
|
123
|
+
setApplyingIds(prev => new Set(prev).add(batchId));
|
|
124
|
+
try {
|
|
125
|
+
await apiClient.batchApprove(batchId, [], projectId);
|
|
126
|
+
await fetchData();
|
|
127
|
+
} catch (err) {
|
|
128
|
+
console.error('Failed to batch approve:', err);
|
|
129
|
+
} finally {
|
|
130
|
+
setApplyingIds(prev => { const n = new Set(prev); n.delete(batchId); return n; });
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const toggleBatch = (batchId: string) => {
|
|
135
|
+
setExpandedBatches(prev => {
|
|
136
|
+
const next = new Set(prev);
|
|
137
|
+
if (next.has(batchId)) next.delete(batchId);
|
|
138
|
+
else next.add(batchId);
|
|
139
|
+
return next;
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const getProposalSummary = (p: any) => {
|
|
144
|
+
switch (p.type) {
|
|
145
|
+
case 'add_edge': case 'edge': case 'remove_edge': {
|
|
146
|
+
const fromName = findNode(p.from_id)?.name || p.from_id;
|
|
147
|
+
const toName = findNode(p.to_id)?.name || p.to_id;
|
|
148
|
+
return `${fromName} --${p.relation}--> ${toName}`;
|
|
149
|
+
}
|
|
150
|
+
case 'add_node': case 'node':
|
|
151
|
+
return `${p.proposed_node_type} "${p.proposed_node_name}"`;
|
|
152
|
+
case 'deprecate_node':
|
|
153
|
+
return `Remove ${findNode(p.target_node_id)?.name || p.target_node_id}`;
|
|
154
|
+
case 'update_description':
|
|
155
|
+
return `Update ${findNode(p.target_node_id)?.name || p.target_node_id}`;
|
|
156
|
+
default: return p.type;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const NodeRef = ({ nodeId }: { nodeId: string }) => {
|
|
161
|
+
const node = findNode(nodeId);
|
|
162
|
+
return (
|
|
163
|
+
<span
|
|
164
|
+
className="coherence-node-ref"
|
|
165
|
+
onClick={() => { if (node) onNodeClick(node); }}
|
|
166
|
+
style={{ cursor: node ? 'pointer' : 'default' }}
|
|
167
|
+
>
|
|
168
|
+
{node ? node.name : nodeId}
|
|
169
|
+
</span>
|
|
170
|
+
);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const ProposalContent = ({ proposal }: { proposal: any }) => {
|
|
174
|
+
switch (proposal.type) {
|
|
175
|
+
case 'add_edge': case 'edge':
|
|
176
|
+
return (
|
|
177
|
+
<div className="coherence-proposal-edge">
|
|
178
|
+
<NodeRef nodeId={proposal.from_id} />
|
|
179
|
+
<span className="coherence-edge-arrow"> --{proposal.relation}--> </span>
|
|
180
|
+
<NodeRef nodeId={proposal.to_id} />
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
case 'remove_edge':
|
|
184
|
+
return (
|
|
185
|
+
<div className="coherence-proposal-edge coherence-proposal-remove">
|
|
186
|
+
<NodeRef nodeId={proposal.from_id} />
|
|
187
|
+
<span className="coherence-edge-arrow"> --{proposal.relation}--> </span>
|
|
188
|
+
<NodeRef nodeId={proposal.to_id} />
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
case 'add_node': case 'node':
|
|
192
|
+
return (
|
|
193
|
+
<>
|
|
194
|
+
<div className="coherence-proposal-node">
|
|
195
|
+
<span className="coherence-node-type-badge">{proposal.proposed_node_type}</span>
|
|
196
|
+
<span className="coherence-node-name">{proposal.proposed_node_name}</span>
|
|
197
|
+
</div>
|
|
198
|
+
{proposal.suggested_edges?.length > 0 && (
|
|
199
|
+
<div className="coherence-suggested-edges">
|
|
200
|
+
<span className="coherence-suggested-label">Suggested edges:</span>
|
|
201
|
+
{proposal.suggested_edges.map((edge: any, i: number) => {
|
|
202
|
+
const fromName = edge.from_id === 'NEW' ? proposal.proposed_node_name : (findNode(edge.from_id)?.name || edge.from_id);
|
|
203
|
+
const toName = edge.to_id === 'NEW' ? proposal.proposed_node_name : (findNode(edge.to_id)?.name || edge.to_id);
|
|
204
|
+
return <div key={i} className="coherence-suggested-edge">{fromName} --{edge.relation}--> {toName}</div>;
|
|
205
|
+
})}
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
208
|
+
</>
|
|
209
|
+
);
|
|
210
|
+
case 'deprecate_node':
|
|
211
|
+
return (
|
|
212
|
+
<>
|
|
213
|
+
<div className="coherence-proposal-node coherence-proposal-remove">
|
|
214
|
+
<NodeRef nodeId={proposal.target_node_id} />
|
|
215
|
+
</div>
|
|
216
|
+
{proposal.affected_edges?.length > 0 && (
|
|
217
|
+
<div className="coherence-affected-edges">
|
|
218
|
+
<span className="coherence-suggested-label">
|
|
219
|
+
Will also remove {proposal.affected_edges.length} edge{proposal.affected_edges.length > 1 ? 's' : ''}:
|
|
220
|
+
</span>
|
|
221
|
+
{proposal.affected_edges.map((edge: any, i: number) => (
|
|
222
|
+
<div key={i} className="coherence-suggested-edge coherence-removal-edge">
|
|
223
|
+
{findNode(edge.from)?.name || edge.from} --{edge.relation}--> {findNode(edge.to)?.name || edge.to}
|
|
224
|
+
</div>
|
|
225
|
+
))}
|
|
226
|
+
</div>
|
|
227
|
+
)}
|
|
228
|
+
</>
|
|
229
|
+
);
|
|
230
|
+
case 'update_description':
|
|
231
|
+
return (
|
|
232
|
+
<>
|
|
233
|
+
<div className="coherence-proposal-update">
|
|
234
|
+
<NodeRef nodeId={proposal.target_node_id} />
|
|
235
|
+
</div>
|
|
236
|
+
{proposal.proposed_description && (
|
|
237
|
+
<div className="coherence-proposed-desc">
|
|
238
|
+
<span className="coherence-suggested-label">Suggested description:</span>
|
|
239
|
+
<div className="coherence-desc-preview">{proposal.proposed_description}</div>
|
|
240
|
+
</div>
|
|
241
|
+
)}
|
|
242
|
+
</>
|
|
243
|
+
);
|
|
244
|
+
default:
|
|
245
|
+
return <div className="coherence-proposal-generic">{JSON.stringify(proposal)}</div>;
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const ProposalCard = ({ proposal, resolved, conflicts, inBatch }: { proposal: any; resolved: boolean; conflicts: any; inBatch?: boolean }) => {
|
|
250
|
+
const hasConflict = conflicts?.[proposal.id]?.length > 0;
|
|
251
|
+
const statusClass = proposal.status === 'approved' ? 'approved' : proposal.status === 'dismissed' ? 'dismissed' : '';
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
<div className={`coherence-proposal ${statusClass}${hasConflict ? ' coherence-proposal-conflict' : ''}`} data-id={proposal.id}>
|
|
255
|
+
<div className="coherence-proposal-header">
|
|
256
|
+
<span className={`coherence-proposal-type coherence-type-${TYPE_CSS[proposal.type] || 'default'}`}>
|
|
257
|
+
{TYPE_LABELS[proposal.type] || proposal.type}
|
|
258
|
+
</span>
|
|
259
|
+
{proposal.confidence && (
|
|
260
|
+
<span className={`coherence-confidence coherence-confidence-${proposal.confidence}`}>
|
|
261
|
+
{CONFIDENCE_LABELS[proposal.confidence] || proposal.confidence}
|
|
262
|
+
</span>
|
|
263
|
+
)}
|
|
264
|
+
{resolved && (
|
|
265
|
+
<span className={`coherence-proposal-status-badge coherence-status-${proposal.status}`}>{proposal.status}</span>
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
{hasConflict && (
|
|
269
|
+
<div className="coherence-conflict-warning">Conflicts with other proposals — resolve the conflict before approving.</div>
|
|
270
|
+
)}
|
|
271
|
+
<ProposalContent proposal={proposal} />
|
|
272
|
+
{!inBatch && <div className="coherence-proposal-reasoning">{proposal.reasoning}</div>}
|
|
273
|
+
{!resolved && (
|
|
274
|
+
<div className="coherence-proposal-actions">
|
|
275
|
+
<button
|
|
276
|
+
className="coherence-approve-btn"
|
|
277
|
+
disabled={applyingIds.has(proposal.id)}
|
|
278
|
+
onClick={() => handleApprove(proposal.id)}
|
|
279
|
+
>
|
|
280
|
+
{applyingIds.has(proposal.id) ? 'Applying...' : 'Approve'}
|
|
281
|
+
</button>
|
|
282
|
+
<button className="coherence-dismiss-btn" onClick={() => handleDismiss(proposal.id)}>Dismiss</button>
|
|
283
|
+
</div>
|
|
284
|
+
)}
|
|
285
|
+
</div>
|
|
286
|
+
);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
if (error) return <div className="coherence-error">Failed to load coherence data: {error}</div>;
|
|
290
|
+
if (!data) return <div>Loading...</div>;
|
|
291
|
+
|
|
292
|
+
const isRunning = data.review_status === 'running';
|
|
293
|
+
const pendingProposals = data.proposals.filter((p: any) => p.status === 'pending');
|
|
294
|
+
const resolvedProposals = data.proposals.filter((p: any) => p.status !== 'pending');
|
|
295
|
+
const hasPending = pendingProposals.length > 0;
|
|
296
|
+
const runDisabled = isRunning || hasPending;
|
|
297
|
+
const conflicts = data.conflicts || {};
|
|
298
|
+
const lastReview = data.last_review_timestamp ? new Date(data.last_review_timestamp).toLocaleString() : 'Never';
|
|
299
|
+
|
|
300
|
+
const sortedPending = sortByType(pendingProposals);
|
|
301
|
+
const { batched, unbatched } = groupByBatch(sortedPending);
|
|
302
|
+
|
|
303
|
+
return (
|
|
304
|
+
<div className="coherence-panel">
|
|
305
|
+
<div className="coherence-header">
|
|
306
|
+
<div className="coherence-header-left">
|
|
307
|
+
<h2 className="coherence-title">Coherence Review</h2>
|
|
308
|
+
<span className="coherence-last-review">Last review: {lastReview}</span>
|
|
309
|
+
</div>
|
|
310
|
+
<button
|
|
311
|
+
className={`coherence-run-btn${isRunning ? ' running' : ''}`}
|
|
312
|
+
disabled={runDisabled}
|
|
313
|
+
title={hasPending && !isRunning ? 'Resolve all pending proposals first' : ''}
|
|
314
|
+
onClick={handleRunReview}
|
|
315
|
+
>
|
|
316
|
+
{isRunning ? 'Analyzing...' : 'Review Coherence'}
|
|
317
|
+
</button>
|
|
318
|
+
</div>
|
|
319
|
+
|
|
320
|
+
{data.partial_run && !isRunning && (
|
|
321
|
+
<div className="coherence-warning-banner">
|
|
322
|
+
The last review did not complete fully. Some proposals may be missing.
|
|
323
|
+
</div>
|
|
324
|
+
)}
|
|
325
|
+
|
|
326
|
+
{isRunning && (
|
|
327
|
+
<div className="coherence-running-banner">
|
|
328
|
+
{data.progress?.message || 'AI agent is analyzing the graph for inconsistencies...'}
|
|
329
|
+
</div>
|
|
330
|
+
)}
|
|
331
|
+
|
|
332
|
+
<div className="coherence-section">
|
|
333
|
+
<h3 className="coherence-section-title">
|
|
334
|
+
Pending Proposals{hasPending ? ` (${pendingProposals.length})` : ''}
|
|
335
|
+
</h3>
|
|
336
|
+
|
|
337
|
+
{!hasPending && !isRunning && (
|
|
338
|
+
<div className={`coherence-empty${data.last_review_timestamp && resolvedProposals.length === 0 ? ' coherence-no-issues' : ''}`}>
|
|
339
|
+
{data.last_review_timestamp && resolvedProposals.length === 0
|
|
340
|
+
? 'No issues found. The graph is coherent.'
|
|
341
|
+
: data.last_review_timestamp
|
|
342
|
+
? 'All proposals resolved. Click "Review Coherence" to run a new analysis.'
|
|
343
|
+
: 'No pending proposals. Click "Review Coherence" to analyze the graph.'}
|
|
344
|
+
</div>
|
|
345
|
+
)}
|
|
346
|
+
|
|
347
|
+
{Array.from(batched.entries()).map(([batchId, batchProposals]) => {
|
|
348
|
+
const isExpanded = expandedBatches.has(batchId);
|
|
349
|
+
const typeLabels = [...new Set(batchProposals.map((p: any) => TYPE_LABELS[p.type] || p.type))].join(', ');
|
|
350
|
+
|
|
351
|
+
return (
|
|
352
|
+
<div key={batchId} className="coherence-batch" data-batch={batchId}>
|
|
353
|
+
<div className="coherence-batch-header">
|
|
354
|
+
<div className="coherence-batch-info">
|
|
355
|
+
<span className="coherence-batch-badge">Batch ({batchProposals.length})</span>
|
|
356
|
+
<span className="coherence-batch-types">{typeLabels}</span>
|
|
357
|
+
</div>
|
|
358
|
+
<div className="coherence-batch-actions">
|
|
359
|
+
<button className="coherence-batch-expand-btn" onClick={() => toggleBatch(batchId)}>
|
|
360
|
+
{isExpanded ? 'Collapse' : 'Expand to cherry-pick'}
|
|
361
|
+
</button>
|
|
362
|
+
<button
|
|
363
|
+
className="coherence-approve-btn coherence-batch-approve-btn"
|
|
364
|
+
disabled={applyingIds.has(batchId)}
|
|
365
|
+
onClick={() => handleBatchApprove(batchId)}
|
|
366
|
+
>
|
|
367
|
+
{applyingIds.has(batchId) ? 'Applying...' : 'Approve All'}
|
|
368
|
+
</button>
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
<div className="coherence-proposal-reasoning">{batchProposals[0]?.reasoning || ''}</div>
|
|
372
|
+
{isExpanded ? (
|
|
373
|
+
<div className="coherence-batch-items">
|
|
374
|
+
{batchProposals.map((p: any) => (
|
|
375
|
+
<ProposalCard key={p.id} proposal={p} resolved={false} conflicts={conflicts} inBatch />
|
|
376
|
+
))}
|
|
377
|
+
</div>
|
|
378
|
+
) : (
|
|
379
|
+
<div className="coherence-batch-summary">
|
|
380
|
+
{batchProposals.map((p: any) => (
|
|
381
|
+
<div key={p.id} className="coherence-batch-summary-item">
|
|
382
|
+
<span className={`coherence-proposal-type coherence-type-${TYPE_CSS[p.type] || 'default'}`}>
|
|
383
|
+
{TYPE_LABELS[p.type] || p.type}
|
|
384
|
+
</span>
|
|
385
|
+
<span className="coherence-batch-summary-text">{getProposalSummary(p)}</span>
|
|
386
|
+
</div>
|
|
387
|
+
))}
|
|
388
|
+
</div>
|
|
389
|
+
)}
|
|
390
|
+
</div>
|
|
391
|
+
);
|
|
392
|
+
})}
|
|
393
|
+
|
|
394
|
+
{unbatched.map((p: any) => (
|
|
395
|
+
<ProposalCard key={p.id} proposal={p} resolved={false} conflicts={conflicts} />
|
|
396
|
+
))}
|
|
397
|
+
</div>
|
|
398
|
+
|
|
399
|
+
{resolvedProposals.length > 0 && (
|
|
400
|
+
<div className="coherence-section">
|
|
401
|
+
<button className="coherence-resolved-toggle" onClick={() => setResolvedExpanded(v => !v)}>
|
|
402
|
+
<span className="coherence-toggle-icon" dangerouslySetInnerHTML={{ __html: resolvedExpanded ? '▼' : '▶' }} />
|
|
403
|
+
{' '}Resolved ({resolvedProposals.length})
|
|
404
|
+
</button>
|
|
405
|
+
<div className={`coherence-resolved-list${resolvedExpanded ? '' : ' collapsed'}`}>
|
|
406
|
+
{resolvedProposals.map((p: any) => (
|
|
407
|
+
<ProposalCard key={p.id} proposal={p} resolved={true} conflicts={{}} />
|
|
408
|
+
))}
|
|
409
|
+
</div>
|
|
410
|
+
</div>
|
|
411
|
+
)}
|
|
412
|
+
</div>
|
|
413
|
+
);
|
|
414
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import React, { useState, useCallback } from 'react';
|
|
2
|
+
import { NODE_COLORS, EDGE_COLORS, EDGE_LABELS, LEGEND_LABELS, NODE_SHAPES, nodeShapePath } from '../constants/graph';
|
|
3
|
+
|
|
4
|
+
interface GraphLegendProps {
|
|
5
|
+
visible: boolean;
|
|
6
|
+
onTypeToggle: (hiddenTypes: Set<string>) => void;
|
|
7
|
+
onSearchChange: (query: string) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function GraphLegend({ visible, onTypeToggle, onSearchChange }: GraphLegendProps) {
|
|
11
|
+
const [hiddenTypes, setHiddenTypes] = useState<Set<string>>(new Set());
|
|
12
|
+
const [edgesCollapsed, setEdgesCollapsed] = useState(() => {
|
|
13
|
+
const stored = localStorage.getItem('legend-edges-collapsed');
|
|
14
|
+
return stored === null ? true : stored === 'true';
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const handleTypeClick = useCallback((type: string) => {
|
|
18
|
+
setHiddenTypes(prev => {
|
|
19
|
+
const next = new Set(prev);
|
|
20
|
+
if (next.has(type)) next.delete(type);
|
|
21
|
+
else next.add(type);
|
|
22
|
+
onTypeToggle(next);
|
|
23
|
+
return next;
|
|
24
|
+
});
|
|
25
|
+
}, [onTypeToggle]);
|
|
26
|
+
|
|
27
|
+
const toggleEdges = useCallback(() => {
|
|
28
|
+
setEdgesCollapsed(prev => {
|
|
29
|
+
const next = !prev;
|
|
30
|
+
localStorage.setItem('legend-edges-collapsed', String(next));
|
|
31
|
+
return next;
|
|
32
|
+
});
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
if (!visible) return null;
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div id="graph-legend">
|
|
39
|
+
<div className="graph-legend">
|
|
40
|
+
<div className="legend-search-container" id="search-container">
|
|
41
|
+
<input
|
|
42
|
+
type="text"
|
|
43
|
+
className="search-input"
|
|
44
|
+
placeholder="Search nodes..."
|
|
45
|
+
onChange={(e) => onSearchChange(e.target.value)}
|
|
46
|
+
/>
|
|
47
|
+
</div>
|
|
48
|
+
<div className="legend-header"><span>Legend</span></div>
|
|
49
|
+
<div className="legend-body">
|
|
50
|
+
<div className="legend-section">
|
|
51
|
+
<div className="legend-section-title">Node Types</div>
|
|
52
|
+
<div className="legend-items">
|
|
53
|
+
{Object.entries(NODE_COLORS).map(([type, color]) => {
|
|
54
|
+
const shape = NODE_SHAPES[type] || 'circle';
|
|
55
|
+
const pathD = nodeShapePath(shape, 6);
|
|
56
|
+
const active = !hiddenTypes.has(type);
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
key={type}
|
|
60
|
+
className={`legend-item legend-type-toggle${active ? ' active' : ''}`}
|
|
61
|
+
onClick={() => handleTypeClick(type)}
|
|
62
|
+
title={`Toggle ${LEGEND_LABELS[type]}`}
|
|
63
|
+
>
|
|
64
|
+
<svg className="legend-node-svg" width="16" height="16" viewBox="-8 -8 16 16">
|
|
65
|
+
<path d={pathD} fill={color} />
|
|
66
|
+
</svg>
|
|
67
|
+
<span className="legend-item-label">{LEGEND_LABELS[type] || type}</span>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
})}
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
<div className="legend-section">
|
|
74
|
+
<div className="legend-section-title">Node Size</div>
|
|
75
|
+
<div className="legend-size-explanation">
|
|
76
|
+
<span className="legend-size-small" />
|
|
77
|
+
<span className="legend-size-arrow">{'\u2192'}</span>
|
|
78
|
+
<span className="legend-size-large" />
|
|
79
|
+
<span className="legend-item-label">more connections = larger</span>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
<button className="legend-edges-toggle" onClick={toggleEdges} title="Toggle edge relations">
|
|
84
|
+
<span className="legend-toggle-icon" dangerouslySetInnerHTML={{ __html: edgesCollapsed ? '▶' : '▼' }} />
|
|
85
|
+
<span>Edge Relations</span>
|
|
86
|
+
</button>
|
|
87
|
+
<div className={`legend-edges-body${edgesCollapsed ? ' collapsed' : ''}`}>
|
|
88
|
+
<div className="legend-section">
|
|
89
|
+
<div className="legend-items">
|
|
90
|
+
{Object.entries(EDGE_COLORS).map(([relation, color]) => (
|
|
91
|
+
<div key={relation} className="legend-item">
|
|
92
|
+
<span className="legend-edge-swatch" style={{ background: color }} />
|
|
93
|
+
<span className="legend-item-label">{EDGE_LABELS[relation] || relation}</span>
|
|
94
|
+
</div>
|
|
95
|
+
))}
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
<div className="legend-section">
|
|
99
|
+
<div className="legend-section-title">Node Border</div>
|
|
100
|
+
<div className="legend-items">
|
|
101
|
+
<div className="legend-item">
|
|
102
|
+
<span className="legend-node-swatch legend-border-yellow" />
|
|
103
|
+
<span className="legend-item-label">incomplete</span>
|
|
104
|
+
</div>
|
|
105
|
+
<div className="legend-item">
|
|
106
|
+
<span className="legend-node-swatch legend-border-red" />
|
|
107
|
+
<span className="legend-item-label">has open questions</span>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
<div className="legend-section">
|
|
112
|
+
<div className="legend-section-title">Gap Indicators</div>
|
|
113
|
+
<div className="legend-items">
|
|
114
|
+
<div className="legend-item">
|
|
115
|
+
<span className="legend-node-swatch legend-gap-pulse" />
|
|
116
|
+
<span className="legend-item-label">{'\u26A0'} pulsing = open questions</span>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|