@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.
Files changed (178) hide show
  1. package/dist/bin/create.d.ts +2 -0
  2. package/dist/bin/create.js +25 -0
  3. package/dist/bin/create.js.map +1 -0
  4. package/dist/src/scaffolder.d.ts +22 -0
  5. package/dist/src/scaffolder.js +120 -0
  6. package/dist/src/scaffolder.js.map +1 -0
  7. package/package.json +24 -0
  8. package/templates/product-system/.env.example +8 -0
  9. package/templates/product-system/CLAUDE.md +45 -0
  10. package/templates/product-system/package.json +32 -0
  11. package/templates/product-system/packages/backend/package.json +37 -0
  12. package/templates/product-system/packages/backend/src/middleware/auth_middleware.test.ts +86 -0
  13. package/templates/product-system/packages/backend/src/middleware/auth_middleware.ts +35 -0
  14. package/templates/product-system/packages/backend/src/routes/auth.ts +463 -0
  15. package/templates/product-system/packages/backend/src/routes/coherence.ts +187 -0
  16. package/templates/product-system/packages/backend/src/routes/graph.ts +67 -0
  17. package/templates/product-system/packages/backend/src/routes/kanban.ts +201 -0
  18. package/templates/product-system/packages/backend/src/routes/pipeline.ts +41 -0
  19. package/templates/product-system/packages/backend/src/routes/projects.ts +122 -0
  20. package/templates/product-system/packages/backend/src/routes/users.ts +97 -0
  21. package/templates/product-system/packages/backend/src/server.ts +159 -0
  22. package/templates/product-system/packages/backend/src/services/auth_service.test.ts +115 -0
  23. package/templates/product-system/packages/backend/src/services/auth_service.ts +82 -0
  24. package/templates/product-system/packages/backend/src/services/coherence-review.ts +339 -0
  25. package/templates/product-system/packages/backend/src/services/email_service.ts +75 -0
  26. package/templates/product-system/packages/backend/src/services/init.ts +80 -0
  27. package/templates/product-system/packages/backend/src/services/invitation_service.test.ts +235 -0
  28. package/templates/product-system/packages/backend/src/services/invitation_service.ts +193 -0
  29. package/templates/product-system/packages/backend/src/services/password_reset_service.test.ts +151 -0
  30. package/templates/product-system/packages/backend/src/services/password_reset_service.ts +135 -0
  31. package/templates/product-system/packages/backend/src/services/project_service.test.ts +215 -0
  32. package/templates/product-system/packages/backend/src/services/project_service.ts +171 -0
  33. package/templates/product-system/packages/backend/src/services/pty_session_manager.test.ts +88 -0
  34. package/templates/product-system/packages/backend/src/services/pty_session_manager.ts +279 -0
  35. package/templates/product-system/packages/backend/src/services/terminal_ws_handler.ts +133 -0
  36. package/templates/product-system/packages/backend/src/services/user_management_service.test.ts +158 -0
  37. package/templates/product-system/packages/backend/src/services/user_management_service.ts +128 -0
  38. package/templates/product-system/packages/backend/tsconfig.json +22 -0
  39. package/templates/product-system/packages/frontend/index.html +13 -0
  40. package/templates/product-system/packages/frontend/package-lock.json +2666 -0
  41. package/templates/product-system/packages/frontend/package.json +30 -0
  42. package/templates/product-system/packages/frontend/public/favicon.svg +16 -0
  43. package/templates/product-system/packages/frontend/src/App.tsx +29 -0
  44. package/templates/product-system/packages/frontend/src/api/client.ts +386 -0
  45. package/templates/product-system/packages/frontend/src/api/client_projects.test.ts +104 -0
  46. package/templates/product-system/packages/frontend/src/api/client_refresh.test.ts +145 -0
  47. package/templates/product-system/packages/frontend/src/components/CoherenceView.tsx +414 -0
  48. package/templates/product-system/packages/frontend/src/components/GraphLegend.tsx +124 -0
  49. package/templates/product-system/packages/frontend/src/components/GraphSettings.tsx +112 -0
  50. package/templates/product-system/packages/frontend/src/components/GraphView.tsx +370 -0
  51. package/templates/product-system/packages/frontend/src/components/InviteUserDialog.tsx +85 -0
  52. package/templates/product-system/packages/frontend/src/components/KanbanView.tsx +470 -0
  53. package/templates/product-system/packages/frontend/src/components/LoginPage.tsx +116 -0
  54. package/templates/product-system/packages/frontend/src/components/ProjectSelector.tsx +187 -0
  55. package/templates/product-system/packages/frontend/src/components/QaIssueSheet.tsx +192 -0
  56. package/templates/product-system/packages/frontend/src/components/SidePanel.tsx +231 -0
  57. package/templates/product-system/packages/frontend/src/components/TerminalView.tsx +200 -0
  58. package/templates/product-system/packages/frontend/src/components/Toolbar.tsx +84 -0
  59. package/templates/product-system/packages/frontend/src/components/UsersView.tsx +249 -0
  60. package/templates/product-system/packages/frontend/src/constants/graph.ts +191 -0
  61. package/templates/product-system/packages/frontend/src/hooks/useAuth.tsx +54 -0
  62. package/templates/product-system/packages/frontend/src/hooks/useGraph.ts +27 -0
  63. package/templates/product-system/packages/frontend/src/hooks/useKanban.ts +21 -0
  64. package/templates/product-system/packages/frontend/src/hooks/useProjects.ts +86 -0
  65. package/templates/product-system/packages/frontend/src/hooks/useTheme.ts +26 -0
  66. package/templates/product-system/packages/frontend/src/hooks/useToast.tsx +62 -0
  67. package/templates/product-system/packages/frontend/src/hooks/use_projects_logic.test.ts +61 -0
  68. package/templates/product-system/packages/frontend/src/main.tsx +12 -0
  69. package/templates/product-system/packages/frontend/src/pages/accept_invitation_page.tsx +167 -0
  70. package/templates/product-system/packages/frontend/src/pages/forgot_password_page.tsx +100 -0
  71. package/templates/product-system/packages/frontend/src/pages/register_page.tsx +137 -0
  72. package/templates/product-system/packages/frontend/src/pages/reset_password_page.tsx +146 -0
  73. package/templates/product-system/packages/frontend/src/routes/ProtectedRoute.tsx +12 -0
  74. package/templates/product-system/packages/frontend/src/routes/accept_invitation.tsx +14 -0
  75. package/templates/product-system/packages/frontend/src/routes/dashboard.tsx +221 -0
  76. package/templates/product-system/packages/frontend/src/routes/forgot_password.tsx +13 -0
  77. package/templates/product-system/packages/frontend/src/routes/login.tsx +14 -0
  78. package/templates/product-system/packages/frontend/src/routes/register.tsx +14 -0
  79. package/templates/product-system/packages/frontend/src/routes/reset_password.tsx +13 -0
  80. package/templates/product-system/packages/frontend/src/styles/index.css +3358 -0
  81. package/templates/product-system/packages/frontend/src/utils/auth_validation.test.ts +51 -0
  82. package/templates/product-system/packages/frontend/src/utils/auth_validation.ts +19 -0
  83. package/templates/product-system/packages/frontend/src/utils/login_validation.test.ts +61 -0
  84. package/templates/product-system/packages/frontend/src/utils/login_validation.ts +24 -0
  85. package/templates/product-system/packages/frontend/src/utils/logout.test.ts +63 -0
  86. package/templates/product-system/packages/frontend/src/utils/node_sizing.test.ts +62 -0
  87. package/templates/product-system/packages/frontend/src/utils/node_sizing.ts +24 -0
  88. package/templates/product-system/packages/frontend/src/utils/task_status.test.ts +53 -0
  89. package/templates/product-system/packages/frontend/src/utils/task_status.ts +14 -0
  90. package/templates/product-system/packages/frontend/tsconfig.json +21 -0
  91. package/templates/product-system/packages/frontend/vite.config.ts +20 -0
  92. package/templates/product-system/packages/shared/.env.example +3 -0
  93. package/templates/product-system/packages/shared/README.md +1 -0
  94. package/templates/product-system/packages/shared/db/migrate.ts +32 -0
  95. package/templates/product-system/packages/shared/db/migrations/0000_dashing_gorgon.sql +128 -0
  96. package/templates/product-system/packages/shared/db/migrations/meta/0000_snapshot.json +819 -0
  97. package/templates/product-system/packages/shared/db/migrations/meta/_journal.json +13 -0
  98. package/templates/product-system/packages/shared/db/schema.ts +137 -0
  99. package/templates/product-system/packages/shared/drizzle.config.js +14 -0
  100. package/templates/product-system/packages/shared/lib/claude-service.ts +215 -0
  101. package/templates/product-system/packages/shared/lib/coherence.ts +278 -0
  102. package/templates/product-system/packages/shared/lib/completeness.ts +30 -0
  103. package/templates/product-system/packages/shared/lib/constants.ts +327 -0
  104. package/templates/product-system/packages/shared/lib/db.ts +81 -0
  105. package/templates/product-system/packages/shared/lib/git_workflow.ts +110 -0
  106. package/templates/product-system/packages/shared/lib/graph.ts +186 -0
  107. package/templates/product-system/packages/shared/lib/kanban.ts +161 -0
  108. package/templates/product-system/packages/shared/lib/markdown.ts +205 -0
  109. package/templates/product-system/packages/shared/lib/pipeline-state-store.ts +124 -0
  110. package/templates/product-system/packages/shared/lib/pipeline.ts +489 -0
  111. package/templates/product-system/packages/shared/lib/prompt_builder.ts +170 -0
  112. package/templates/product-system/packages/shared/lib/relevance_search.ts +159 -0
  113. package/templates/product-system/packages/shared/lib/session.ts +152 -0
  114. package/templates/product-system/packages/shared/lib/validator.ts +117 -0
  115. package/templates/product-system/packages/shared/lib/work_summary_parser.ts +130 -0
  116. package/templates/product-system/packages/shared/package.json +30 -0
  117. package/templates/product-system/packages/shared/scripts/assign-project.ts +52 -0
  118. package/templates/product-system/packages/shared/tools/add_edge.ts +61 -0
  119. package/templates/product-system/packages/shared/tools/add_node.ts +101 -0
  120. package/templates/product-system/packages/shared/tools/end_session.ts +87 -0
  121. package/templates/product-system/packages/shared/tools/get_gaps.ts +87 -0
  122. package/templates/product-system/packages/shared/tools/get_kanban.ts +125 -0
  123. package/templates/product-system/packages/shared/tools/get_node.ts +78 -0
  124. package/templates/product-system/packages/shared/tools/get_status.ts +98 -0
  125. package/templates/product-system/packages/shared/tools/migrate_to_turso.ts +385 -0
  126. package/templates/product-system/packages/shared/tools/move_card.ts +143 -0
  127. package/templates/product-system/packages/shared/tools/rebuild_index.ts +77 -0
  128. package/templates/product-system/packages/shared/tools/remove_edge.ts +59 -0
  129. package/templates/product-system/packages/shared/tools/remove_node.ts +96 -0
  130. package/templates/product-system/packages/shared/tools/resolve_question.ts +75 -0
  131. package/templates/product-system/packages/shared/tools/search_nodes.ts +106 -0
  132. package/templates/product-system/packages/shared/tools/start_session.ts +144 -0
  133. package/templates/product-system/packages/shared/tools/update_node.ts +133 -0
  134. package/templates/product-system/packages/shared/tsconfig.json +24 -0
  135. package/templates/product-system/pnpm-workspace.yaml +2 -0
  136. package/templates/product-system/smoke_test.ts +219 -0
  137. package/templates/product-system/tests/coherence_review.test.ts +562 -0
  138. package/templates/product-system/tests/db_sqlite_fallback.test.ts +75 -0
  139. package/templates/product-system/tests/edge_type_color_coding.test.ts +147 -0
  140. package/templates/product-system/tests/emit-tool-use-events.test.ts +85 -0
  141. package/templates/product-system/tests/feature_kind.test.ts +139 -0
  142. package/templates/product-system/tests/gap_indicators.test.ts +199 -0
  143. package/templates/product-system/tests/graceful_init.test.ts +142 -0
  144. package/templates/product-system/tests/graph_legend.test.ts +314 -0
  145. package/templates/product-system/tests/graph_settings_sheet.test.ts +804 -0
  146. package/templates/product-system/tests/hide_defined_filter.test.ts +205 -0
  147. package/templates/product-system/tests/kanban.test.ts +529 -0
  148. package/templates/product-system/tests/neighborhood_focus.test.ts +132 -0
  149. package/templates/product-system/tests/node_search.test.ts +340 -0
  150. package/templates/product-system/tests/node_sizing.test.ts +170 -0
  151. package/templates/product-system/tests/node_type_toggle_filters.test.ts +285 -0
  152. package/templates/product-system/tests/node_type_visual_encoding.test.ts +103 -0
  153. package/templates/product-system/tests/pipeline-state-store.test.ts +268 -0
  154. package/templates/product-system/tests/pipeline-unit.test.ts +593 -0
  155. package/templates/product-system/tests/pipeline.test.ts +195 -0
  156. package/templates/product-system/tests/pipeline_stats_all_cards.test.ts +193 -0
  157. package/templates/product-system/tests/play_all.test.ts +296 -0
  158. package/templates/product-system/tests/qa_issue_sheet.test.ts +464 -0
  159. package/templates/product-system/tests/relevance_search.test.ts +186 -0
  160. package/templates/product-system/tests/search_reorder.test.ts +88 -0
  161. package/templates/product-system/tests/serve_ui.test.ts +281 -0
  162. package/templates/product-system/tests/serve_ui_drizzle.test.ts +114 -0
  163. package/templates/product-system/tests/session_context_recall.test.ts +135 -0
  164. package/templates/product-system/tests/side_panel.test.ts +345 -0
  165. package/templates/product-system/tests/spec_completeness_label.test.ts +69 -0
  166. package/templates/product-system/tests/url_routing_test.ts +122 -0
  167. package/templates/product-system/tests/user_login.test.ts +150 -0
  168. package/templates/product-system/tests/user_registration.test.ts +205 -0
  169. package/templates/product-system/tests/web_terminal.test.ts +572 -0
  170. package/templates/product-system/tests/work_summary.test.ts +211 -0
  171. package/templates/product-system/tests/zoom_pan.test.ts +43 -0
  172. package/templates/product-system/tsconfig.json +24 -0
  173. package/templates/skills/product-bootstrap/SKILL.md +312 -0
  174. package/templates/skills/product-code-reviewer/SKILL.md +147 -0
  175. package/templates/skills/product-debugger/SKILL.md +206 -0
  176. package/templates/skills/product-debugger/references/agent-browser.md +1156 -0
  177. package/templates/skills/product-developer/SKILL.md +182 -0
  178. 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}--&gt; </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}--&gt; </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}--&gt; {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}--&gt; {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 ? '&#9660;' : '&#9654;' }} />
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 ? '&#9654;' : '&#9660;' }} />
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
+ }