create-claude-code-visualizer 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.
Files changed (220) hide show
  1. package/index.js +393 -0
  2. package/package.json +31 -0
  3. package/templates/CLAUDE.md +108 -0
  4. package/templates/app/.env.local.example +14 -0
  5. package/templates/app/ecosystem.config.js +29 -0
  6. package/templates/app/next-env.d.ts +6 -0
  7. package/templates/app/next.config.ts +16 -0
  8. package/templates/app/package-lock.json +4581 -0
  9. package/templates/app/package.json +38 -0
  10. package/templates/app/postcss.config.js +5 -0
  11. package/templates/app/src/app/agents/[slug]/chat/loading.tsx +26 -0
  12. package/templates/app/src/app/agents/[slug]/chat/page.tsx +579 -0
  13. package/templates/app/src/app/agents/[slug]/loading.tsx +19 -0
  14. package/templates/app/src/app/agents/page.tsx +8 -0
  15. package/templates/app/src/app/api/agents/[slug]/capabilities/route.ts +11 -0
  16. package/templates/app/src/app/api/agents/[slug]/route.ts +57 -0
  17. package/templates/app/src/app/api/agents/route.ts +28 -0
  18. package/templates/app/src/app/api/ai/generate-agent/route.ts +87 -0
  19. package/templates/app/src/app/api/ai/improve-claude-md/route.ts +78 -0
  20. package/templates/app/src/app/api/ai/suggestions/route.ts +64 -0
  21. package/templates/app/src/app/api/ai/title/route.ts +88 -0
  22. package/templates/app/src/app/api/auth/role/route.ts +17 -0
  23. package/templates/app/src/app/api/commands/[slug]/route.ts +61 -0
  24. package/templates/app/src/app/api/commands/route.ts +6 -0
  25. package/templates/app/src/app/api/governance/costs/route.ts +117 -0
  26. package/templates/app/src/app/api/governance/sessions/route.ts +335 -0
  27. package/templates/app/src/app/api/notifications/route.ts +62 -0
  28. package/templates/app/src/app/api/preferences/route.ts +44 -0
  29. package/templates/app/src/app/api/runs/[id]/approve/route.ts +38 -0
  30. package/templates/app/src/app/api/runs/[id]/events/route.ts +28 -0
  31. package/templates/app/src/app/api/runs/[id]/metadata/route.ts +30 -0
  32. package/templates/app/src/app/api/runs/[id]/route.ts +21 -0
  33. package/templates/app/src/app/api/runs/[id]/start/route.ts +61 -0
  34. package/templates/app/src/app/api/runs/[id]/stop/route.ts +16 -0
  35. package/templates/app/src/app/api/runs/[id]/stream/route.ts +201 -0
  36. package/templates/app/src/app/api/runs/route.ts +95 -0
  37. package/templates/app/src/app/api/schedules/[id]/route.ts +81 -0
  38. package/templates/app/src/app/api/schedules/route.ts +75 -0
  39. package/templates/app/src/app/api/settings/access-logs/route.ts +33 -0
  40. package/templates/app/src/app/api/settings/claude-md/route.ts +44 -0
  41. package/templates/app/src/app/api/settings/env-keys/route.ts +271 -0
  42. package/templates/app/src/app/api/settings/users/route.ts +108 -0
  43. package/templates/app/src/app/api/skills/[slug]/route.ts +43 -0
  44. package/templates/app/src/app/api/skills/route.ts +6 -0
  45. package/templates/app/src/app/api/tools/route.ts +65 -0
  46. package/templates/app/src/app/api/uploads/cleanup/route.ts +29 -0
  47. package/templates/app/src/app/api/uploads/route.ts +77 -0
  48. package/templates/app/src/app/auth/callback/route.ts +19 -0
  49. package/templates/app/src/app/globals.css +115 -0
  50. package/templates/app/src/app/layout.tsx +24 -0
  51. package/templates/app/src/app/loading.tsx +16 -0
  52. package/templates/app/src/app/login/page.tsx +64 -0
  53. package/templates/app/src/app/not-authorized/page.tsx +33 -0
  54. package/templates/app/src/app/runs/page.tsx +55 -0
  55. package/templates/app/src/app/schedules/page.tsx +110 -0
  56. package/templates/app/src/app/settings/page.tsx +1294 -0
  57. package/templates/app/src/app/skills/page.tsx +7 -0
  58. package/templates/app/src/components/agent-card.tsx +58 -0
  59. package/templates/app/src/components/agent-grid.tsx +90 -0
  60. package/templates/app/src/components/auth/auth-context.tsx +79 -0
  61. package/templates/app/src/components/chat-thread.tsx +50 -0
  62. package/templates/app/src/components/chat-view.tsx +670 -0
  63. package/templates/app/src/components/commands-browser.tsx +349 -0
  64. package/templates/app/src/components/create-agent-modal.tsx +388 -0
  65. package/templates/app/src/components/governance-dashboard.tsx +397 -0
  66. package/templates/app/src/components/icons.tsx +401 -0
  67. package/templates/app/src/components/layout/agent-sidebar.tsx +504 -0
  68. package/templates/app/src/components/layout/app-shell.tsx +29 -0
  69. package/templates/app/src/components/layout/nav.tsx +87 -0
  70. package/templates/app/src/components/layout/overview-inner.tsx +14 -0
  71. package/templates/app/src/components/layout/profile-menu.tsx +95 -0
  72. package/templates/app/src/components/layout/sidebar.tsx +30 -0
  73. package/templates/app/src/components/markdown.tsx +57 -0
  74. package/templates/app/src/components/message-bar.tsx +161 -0
  75. package/templates/app/src/components/notifications/notification-bell.tsx +104 -0
  76. package/templates/app/src/components/notifications/notification-panel.tsx +116 -0
  77. package/templates/app/src/components/overview/overview-content.tsx +287 -0
  78. package/templates/app/src/components/overview/overview-context.tsx +88 -0
  79. package/templates/app/src/components/preferences-modal.tsx +112 -0
  80. package/templates/app/src/components/run-form.tsx +73 -0
  81. package/templates/app/src/components/run-history-table.tsx +226 -0
  82. package/templates/app/src/components/run-output.tsx +187 -0
  83. package/templates/app/src/components/schedule-form.tsx +148 -0
  84. package/templates/app/src/components/skills-browser.tsx +338 -0
  85. package/templates/app/src/components/tool-tooltip.tsx +82 -0
  86. package/templates/app/src/hooks/use-sse.ts +115 -0
  87. package/templates/app/src/instrumentation.ts +9 -0
  88. package/templates/app/src/lib/agent-cache.ts +19 -0
  89. package/templates/app/src/lib/agent-runner.ts +411 -0
  90. package/templates/app/src/lib/agents.ts +168 -0
  91. package/templates/app/src/lib/ai.ts +40 -0
  92. package/templates/app/src/lib/approval-store.ts +70 -0
  93. package/templates/app/src/lib/auth-guard.ts +116 -0
  94. package/templates/app/src/lib/capabilities.ts +191 -0
  95. package/templates/app/src/lib/line-diff.ts +96 -0
  96. package/templates/app/src/lib/queue.ts +22 -0
  97. package/templates/app/src/lib/redis.ts +12 -0
  98. package/templates/app/src/lib/role-permissions.ts +166 -0
  99. package/templates/app/src/lib/run-agent.ts +442 -0
  100. package/templates/app/src/lib/supabase-browser.ts +8 -0
  101. package/templates/app/src/lib/supabase-middleware.ts +63 -0
  102. package/templates/app/src/lib/supabase-server.ts +28 -0
  103. package/templates/app/src/lib/supabase.ts +6 -0
  104. package/templates/app/src/lib/tool-descriptions.ts +29 -0
  105. package/templates/app/src/lib/types.ts +73 -0
  106. package/templates/app/src/lib/typewriter-animation.ts +159 -0
  107. package/templates/app/src/middleware.ts +13 -0
  108. package/templates/app/tsconfig.json +21 -0
  109. package/templates/app/uploads/.gitkeep +0 -0
  110. package/templates/app/worker/index.ts +342 -0
  111. package/templates/claude/agents/ai-trends-scout.md +66 -0
  112. package/templates/claude/commands/add-to-todos.md +56 -0
  113. package/templates/claude/commands/check-todos.md +56 -0
  114. package/templates/claude/hooks/auto-approve-safe.sh +34 -0
  115. package/templates/claude/hooks/auto-format.sh +25 -0
  116. package/templates/claude/hooks/block-destructive.sh +32 -0
  117. package/templates/claude/hooks/compaction-preserver.sh +16 -0
  118. package/templates/claude/hooks/notify.sh +26 -0
  119. package/templates/claude/settings.local.json +66 -0
  120. package/templates/claude/skills/frontend-design/SKILL.md +127 -0
  121. package/templates/claude/skills/frontend-design/reference/color-and-contrast.md +132 -0
  122. package/templates/claude/skills/frontend-design/reference/interaction-design.md +123 -0
  123. package/templates/claude/skills/frontend-design/reference/motion-design.md +99 -0
  124. package/templates/claude/skills/frontend-design/reference/responsive-design.md +114 -0
  125. package/templates/claude/skills/frontend-design/reference/spatial-design.md +100 -0
  126. package/templates/claude/skills/frontend-design/reference/typography.md +131 -0
  127. package/templates/claude/skills/frontend-design/reference/ux-writing.md +107 -0
  128. package/templates/claude/skills/gws-admin-reports/SKILL.md +57 -0
  129. package/templates/claude/skills/gws-calendar/SKILL.md +108 -0
  130. package/templates/claude/skills/gws-calendar-agenda/SKILL.md +52 -0
  131. package/templates/claude/skills/gws-calendar-insert/SKILL.md +55 -0
  132. package/templates/claude/skills/gws-chat/SKILL.md +73 -0
  133. package/templates/claude/skills/gws-chat-send/SKILL.md +49 -0
  134. package/templates/claude/skills/gws-classroom/SKILL.md +75 -0
  135. package/templates/claude/skills/gws-docs/SKILL.md +48 -0
  136. package/templates/claude/skills/gws-docs-write/SKILL.md +49 -0
  137. package/templates/claude/skills/gws-drive/SKILL.md +137 -0
  138. package/templates/claude/skills/gws-drive-upload/SKILL.md +52 -0
  139. package/templates/claude/skills/gws-events/SKILL.md +67 -0
  140. package/templates/claude/skills/gws-events-renew/SKILL.md +48 -0
  141. package/templates/claude/skills/gws-events-subscribe/SKILL.md +59 -0
  142. package/templates/claude/skills/gws-forms/SKILL.md +45 -0
  143. package/templates/claude/skills/gws-gmail/SKILL.md +59 -0
  144. package/templates/claude/skills/gws-gmail-forward/SKILL.md +53 -0
  145. package/templates/claude/skills/gws-gmail-reply/SKILL.md +56 -0
  146. package/templates/claude/skills/gws-gmail-reply-all/SKILL.md +60 -0
  147. package/templates/claude/skills/gws-gmail-send/SKILL.md +55 -0
  148. package/templates/claude/skills/gws-gmail-triage/SKILL.md +50 -0
  149. package/templates/claude/skills/gws-gmail-watch/SKILL.md +58 -0
  150. package/templates/claude/skills/gws-keep/SKILL.md +48 -0
  151. package/templates/claude/skills/gws-meet/SKILL.md +51 -0
  152. package/templates/claude/skills/gws-modelarmor/SKILL.md +42 -0
  153. package/templates/claude/skills/gws-modelarmor-create-template/SKILL.md +53 -0
  154. package/templates/claude/skills/gws-modelarmor-sanitize-prompt/SKILL.md +48 -0
  155. package/templates/claude/skills/gws-modelarmor-sanitize-response/SKILL.md +48 -0
  156. package/templates/claude/skills/gws-people/SKILL.md +67 -0
  157. package/templates/claude/skills/gws-shared/SKILL.md +66 -0
  158. package/templates/claude/skills/gws-sheets/SKILL.md +53 -0
  159. package/templates/claude/skills/gws-sheets-append/SKILL.md +51 -0
  160. package/templates/claude/skills/gws-sheets-read/SKILL.md +47 -0
  161. package/templates/claude/skills/gws-slides/SKILL.md +43 -0
  162. package/templates/claude/skills/gws-tasks/SKILL.md +56 -0
  163. package/templates/claude/skills/gws-workflow/SKILL.md +44 -0
  164. package/templates/claude/skills/gws-workflow-email-to-task/SKILL.md +47 -0
  165. package/templates/claude/skills/gws-workflow-file-announce/SKILL.md +50 -0
  166. package/templates/claude/skills/gws-workflow-meeting-prep/SKILL.md +47 -0
  167. package/templates/claude/skills/gws-workflow-standup-report/SKILL.md +46 -0
  168. package/templates/claude/skills/gws-workflow-weekly-digest/SKILL.md +46 -0
  169. package/templates/claude/skills/persona-content-creator/SKILL.md +33 -0
  170. package/templates/claude/skills/persona-customer-support/SKILL.md +34 -0
  171. package/templates/claude/skills/persona-event-coordinator/SKILL.md +35 -0
  172. package/templates/claude/skills/persona-exec-assistant/SKILL.md +35 -0
  173. package/templates/claude/skills/persona-hr-coordinator/SKILL.md +33 -0
  174. package/templates/claude/skills/persona-it-admin/SKILL.md +30 -0
  175. package/templates/claude/skills/persona-project-manager/SKILL.md +35 -0
  176. package/templates/claude/skills/persona-researcher/SKILL.md +33 -0
  177. package/templates/claude/skills/persona-sales-ops/SKILL.md +35 -0
  178. package/templates/claude/skills/persona-team-lead/SKILL.md +36 -0
  179. package/templates/claude/skills/recipe-backup-sheet-as-csv/SKILL.md +25 -0
  180. package/templates/claude/skills/recipe-batch-invite-to-event/SKILL.md +25 -0
  181. package/templates/claude/skills/recipe-block-focus-time/SKILL.md +24 -0
  182. package/templates/claude/skills/recipe-bulk-download-folder/SKILL.md +25 -0
  183. package/templates/claude/skills/recipe-collect-form-responses/SKILL.md +25 -0
  184. package/templates/claude/skills/recipe-compare-sheet-tabs/SKILL.md +25 -0
  185. package/templates/claude/skills/recipe-copy-sheet-for-new-month/SKILL.md +25 -0
  186. package/templates/claude/skills/recipe-create-classroom-course/SKILL.md +25 -0
  187. package/templates/claude/skills/recipe-create-doc-from-template/SKILL.md +29 -0
  188. package/templates/claude/skills/recipe-create-events-from-sheet/SKILL.md +24 -0
  189. package/templates/claude/skills/recipe-create-expense-tracker/SKILL.md +26 -0
  190. package/templates/claude/skills/recipe-create-feedback-form/SKILL.md +25 -0
  191. package/templates/claude/skills/recipe-create-gmail-filter/SKILL.md +26 -0
  192. package/templates/claude/skills/recipe-create-meet-space/SKILL.md +25 -0
  193. package/templates/claude/skills/recipe-create-presentation/SKILL.md +25 -0
  194. package/templates/claude/skills/recipe-create-shared-drive/SKILL.md +25 -0
  195. package/templates/claude/skills/recipe-create-task-list/SKILL.md +26 -0
  196. package/templates/claude/skills/recipe-create-vacation-responder/SKILL.md +25 -0
  197. package/templates/claude/skills/recipe-draft-email-from-doc/SKILL.md +25 -0
  198. package/templates/claude/skills/recipe-email-drive-link/SKILL.md +25 -0
  199. package/templates/claude/skills/recipe-find-free-time/SKILL.md +25 -0
  200. package/templates/claude/skills/recipe-find-large-files/SKILL.md +24 -0
  201. package/templates/claude/skills/recipe-forward-labeled-emails/SKILL.md +27 -0
  202. package/templates/claude/skills/recipe-generate-report-from-sheet/SKILL.md +34 -0
  203. package/templates/claude/skills/recipe-label-and-archive-emails/SKILL.md +25 -0
  204. package/templates/claude/skills/recipe-log-deal-update/SKILL.md +25 -0
  205. package/templates/claude/skills/recipe-organize-drive-folder/SKILL.md +26 -0
  206. package/templates/claude/skills/recipe-plan-weekly-schedule/SKILL.md +26 -0
  207. package/templates/claude/skills/recipe-post-mortem-setup/SKILL.md +25 -0
  208. package/templates/claude/skills/recipe-reschedule-meeting/SKILL.md +25 -0
  209. package/templates/claude/skills/recipe-review-meet-participants/SKILL.md +25 -0
  210. package/templates/claude/skills/recipe-review-overdue-tasks/SKILL.md +25 -0
  211. package/templates/claude/skills/recipe-save-email-attachments/SKILL.md +26 -0
  212. package/templates/claude/skills/recipe-save-email-to-doc/SKILL.md +29 -0
  213. package/templates/claude/skills/recipe-schedule-recurring-event/SKILL.md +24 -0
  214. package/templates/claude/skills/recipe-send-team-announcement/SKILL.md +24 -0
  215. package/templates/claude/skills/recipe-share-doc-and-notify/SKILL.md +25 -0
  216. package/templates/claude/skills/recipe-share-event-materials/SKILL.md +25 -0
  217. package/templates/claude/skills/recipe-share-folder-with-team/SKILL.md +26 -0
  218. package/templates/claude/skills/recipe-sync-contacts-to-sheet/SKILL.md +25 -0
  219. package/templates/claude/skills/recipe-watch-drive-changes/SKILL.md +25 -0
  220. package/templates/mcp.json +12 -0
@@ -0,0 +1,1294 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { useAuth } from "@/components/auth/auth-context";
5
+ import { animateDiff } from "@/lib/typewriter-animation";
6
+
7
+ interface UserRole {
8
+ id: string;
9
+ email: string;
10
+ role: "admin" | "operator" | "viewer";
11
+ added_by: string | null;
12
+ created_at: string;
13
+ }
14
+
15
+
16
+
17
+ type Tab = "users" | "api-keys" | "claude-md";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Role permission matrix & explainer
21
+ // ---------------------------------------------------------------------------
22
+
23
+ interface Capability {
24
+ label: string;
25
+ admin: boolean;
26
+ operator: boolean;
27
+ viewer: boolean;
28
+ note?: string;
29
+ }
30
+
31
+ const CAPABILITIES: Capability[] = [
32
+ { label: "View chat & run history", admin: true, operator: true, viewer: true },
33
+ { label: "Run agents & send messages", admin: true, operator: true, viewer: false },
34
+ { label: "Upload files to runs", admin: true, operator: true, viewer: false },
35
+ { label: "Delete sessions", admin: true, operator: true, viewer: false },
36
+ { label: "Use all agent tools (Bash, Write, etc.)", admin: true, operator: true, viewer: false, note: "operator" },
37
+ { label: "Modify source code & config files", admin: true, operator: false, viewer: false },
38
+ { label: "Install packages (npm/pip)", admin: true, operator: false, viewer: false },
39
+ { label: "Manage users & settings", admin: true, operator: false, viewer: false },
40
+ { label: "Create scheduled runs", admin: true, operator: false, viewer: false },
41
+ ];
42
+
43
+ const ROLE_META: Record<string, { label: string; color: string; tagBg: string; tagText: string; desc: string }> = {
44
+ admin: {
45
+ label: "Admin",
46
+ color: "text-amber-400",
47
+ tagBg: "bg-amber-400/10 border-amber-400/20",
48
+ tagText: "text-amber-400",
49
+ desc: "Unrestricted access to everything.",
50
+ },
51
+ operator: {
52
+ label: "Operator",
53
+ color: "text-[var(--accent-text)]",
54
+ tagBg: "bg-[var(--accent-muted)] border-[var(--accent-muted)]",
55
+ tagText: "text-[var(--accent-text)]",
56
+ desc: "Can run agents, but cannot touch the codebase or settings.",
57
+ },
58
+ viewer: {
59
+ label: "Viewer",
60
+ color: "text-[var(--text-tertiary)]",
61
+ tagBg: "bg-[var(--bg-elevated)] border-[var(--border-subtle)]",
62
+ tagText: "text-[var(--text-tertiary)]",
63
+ desc: "Read-only. Can see history but can't change anything.",
64
+ },
65
+ };
66
+
67
+ const ROLES_ORDER = ["admin", "operator", "viewer"] as const;
68
+
69
+ function RoleExplainer() {
70
+ const [open, setOpen] = useState(false);
71
+
72
+ return (
73
+ <div className="rounded-md border border-[var(--border-subtle)]/80 bg-[var(--bg-base)]/40 overflow-hidden">
74
+ <button
75
+ onClick={() => setOpen(!open)}
76
+ className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-[var(--bg-elevated)]/20 transition-colors"
77
+ >
78
+ <div className="flex items-center gap-2.5">
79
+ <span className="text-[13px] text-[var(--text-tertiary)]">What can each role do?</span>
80
+ <div className="flex gap-1.5">
81
+ {ROLES_ORDER.map((r) => (
82
+ <span
83
+ key={r}
84
+ className={`text-[10px] font-medium px-1.5 py-0.5 rounded border ${ROLE_META[r].tagBg} ${ROLE_META[r].tagText}`}
85
+ >
86
+ {ROLE_META[r].label}
87
+ </span>
88
+ ))}
89
+ </div>
90
+ </div>
91
+ <svg
92
+ className={`h-4 w-4 text-[var(--text-muted)] transition-transform duration-200 ${open ? "rotate-180" : ""}`}
93
+ fill="none"
94
+ viewBox="0 0 24 24"
95
+ stroke="currentColor"
96
+ strokeWidth={2}
97
+ >
98
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
99
+ </svg>
100
+ </button>
101
+
102
+ {open && (
103
+ <div className="border-t border-[var(--border-subtle)]/60">
104
+ {/* Role summary cards */}
105
+ <div className="grid grid-cols-3 gap-px bg-[var(--bg-elevated)]/30">
106
+ {ROLES_ORDER.map((r) => (
107
+ <div key={r} className="bg-[var(--bg-base)]/50 px-4 py-3">
108
+ <div className={`text-[11px] font-semibold mb-1 ${ROLE_META[r].color}`}>
109
+ {ROLE_META[r].label}
110
+ </div>
111
+ <div className="text-[11px] text-[var(--text-tertiary)] leading-snug">
112
+ {ROLE_META[r].desc}
113
+ </div>
114
+ </div>
115
+ ))}
116
+ </div>
117
+
118
+ {/* Permission matrix */}
119
+ <div className="px-1 py-2">
120
+ <table className="w-full">
121
+ <thead>
122
+ <tr>
123
+ <th className="text-left px-3 py-1.5 text-[10px] font-medium text-[var(--text-muted)] uppercase tracking-wider">
124
+ Capability
125
+ </th>
126
+ {ROLES_ORDER.map((r) => (
127
+ <th
128
+ key={r}
129
+ className={`text-center px-3 py-1.5 text-[10px] font-medium uppercase tracking-wider ${ROLE_META[r].color}`}
130
+ style={{ width: "80px" }}
131
+ >
132
+ {ROLE_META[r].label}
133
+ </th>
134
+ ))}
135
+ </tr>
136
+ </thead>
137
+ <tbody>
138
+ {CAPABILITIES.map((cap, i) => (
139
+ <tr
140
+ key={i}
141
+ className="border-t border-[var(--border-subtle)]/30 hover:bg-[var(--bg-elevated)]/10 transition-colors"
142
+ >
143
+ <td className="px-3 py-2">
144
+ <span className="text-[12px] text-[var(--text-secondary)]">{cap.label}</span>
145
+ {cap.note === "operator" && (
146
+ <span className="ml-1.5 text-[10px] text-[var(--text-muted)]">
147
+ (protected files blocked)
148
+ </span>
149
+ )}
150
+ </td>
151
+ {ROLES_ORDER.map((r) => (
152
+ <td key={r} className="text-center px-3 py-2">
153
+ {cap[r] ? (
154
+ <span className="inline-flex items-center justify-center h-5 w-5 rounded-full bg-emerald-500/10">
155
+ <svg className="h-3 w-3 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
156
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
157
+ </svg>
158
+ </span>
159
+ ) : (
160
+ <span className="inline-flex items-center justify-center h-5 w-5 rounded-full bg-[var(--bg-elevated)]/50">
161
+ <svg className="h-3 w-3 text-[var(--text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
162
+ <path strokeLinecap="round" strokeLinejoin="round" d="M18 12H6" />
163
+ </svg>
164
+ </span>
165
+ )}
166
+ </td>
167
+ ))}
168
+ </tr>
169
+ ))}
170
+ </tbody>
171
+ </table>
172
+ </div>
173
+
174
+ <div className="px-4 py-2.5 border-t border-[var(--border-subtle)]/30">
175
+ <p className="text-[11px] text-[var(--text-muted)] leading-relaxed">
176
+ Only users listed above can sign in. Operators can run agents and use all tools, but writes to source code,
177
+ config files, and package managers are automatically blocked. Viewers have read-only access.
178
+ </p>
179
+ </div>
180
+ </div>
181
+ )}
182
+ </div>
183
+ );
184
+ }
185
+
186
+ const tabs: { key: Tab; label: string; icon: string }[] = [
187
+ { key: "users", label: "User Management", icon: "👥" },
188
+ { key: "api-keys", label: "API Keys", icon: "🔑" },
189
+ { key: "claude-md", label: "CLAUDE.md", icon: "📄" },
190
+ ];
191
+
192
+ function UserManagementTab() {
193
+ const { user } = useAuth();
194
+ const [users, setUsers] = useState<UserRole[]>([]);
195
+ const [loading, setLoading] = useState(true);
196
+ const [error, setError] = useState<string | null>(null);
197
+ const [newEmail, setNewEmail] = useState("");
198
+ const [newRole, setNewRole] = useState<"viewer" | "operator" | "admin">("viewer");
199
+ const [adding, setAdding] = useState(false);
200
+
201
+ const fetchUsers = useCallback(async () => {
202
+ try {
203
+ const res = await fetch("/api/settings/users");
204
+ if (!res.ok) throw new Error("Failed to load users");
205
+ setUsers(await res.json());
206
+ } catch (e) {
207
+ setError((e as Error).message);
208
+ } finally {
209
+ setLoading(false);
210
+ }
211
+ }, []);
212
+
213
+ useEffect(() => {
214
+ fetchUsers();
215
+ }, [fetchUsers]);
216
+
217
+ const handleAdd = async (e: React.FormEvent) => {
218
+ e.preventDefault();
219
+ if (!newEmail.trim()) return;
220
+ setAdding(true);
221
+ setError(null);
222
+
223
+ try {
224
+ const res = await fetch("/api/settings/users", {
225
+ method: "POST",
226
+ headers: { "Content-Type": "application/json" },
227
+ body: JSON.stringify({ email: newEmail.trim(), role: newRole }),
228
+ });
229
+
230
+ if (!res.ok) {
231
+ const data = await res.json();
232
+ throw new Error(data.error || "Failed to add user");
233
+ }
234
+
235
+ setNewEmail("");
236
+ setNewRole("viewer");
237
+ await fetchUsers();
238
+ } catch (e) {
239
+ setError((e as Error).message);
240
+ } finally {
241
+ setAdding(false);
242
+ }
243
+ };
244
+
245
+ const handleRoleChange = async (id: string, role: string) => {
246
+ try {
247
+ const res = await fetch("/api/settings/users", {
248
+ method: "PATCH",
249
+ headers: { "Content-Type": "application/json" },
250
+ body: JSON.stringify({ id, role }),
251
+ });
252
+ if (!res.ok) throw new Error("Failed to update role");
253
+ await fetchUsers();
254
+ } catch (e) {
255
+ setError((e as Error).message);
256
+ }
257
+ };
258
+
259
+ const handleRemove = async (id: string, email: string) => {
260
+ try {
261
+ const res = await fetch("/api/settings/users", {
262
+ method: "DELETE",
263
+ headers: { "Content-Type": "application/json" },
264
+ body: JSON.stringify({ id, email }),
265
+ });
266
+ if (!res.ok) {
267
+ const data = await res.json();
268
+ throw new Error(data.error || "Failed to remove user");
269
+ }
270
+ await fetchUsers();
271
+ } catch (e) {
272
+ setError((e as Error).message);
273
+ }
274
+ };
275
+
276
+ if (loading) {
277
+ return (
278
+ <div className="space-y-3">
279
+ {[...Array(3)].map((_, i) => (
280
+ <div key={i} className="h-14 rounded-md bg-[var(--bg-elevated)]/30 animate-pulse" />
281
+ ))}
282
+ </div>
283
+ );
284
+ }
285
+
286
+ return (
287
+ <div className="space-y-6">
288
+ {/* Add user form */}
289
+ <form onSubmit={handleAdd} className="flex gap-3">
290
+ <input
291
+ type="email"
292
+ value={newEmail}
293
+ onChange={(e) => setNewEmail(e.target.value)}
294
+ placeholder="email@example.com"
295
+ className="flex-1 rounded-md border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[13px] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none focus:border-[var(--border-focus)] transition-colors"
296
+ />
297
+ <select
298
+ value={newRole}
299
+ onChange={(e) => setNewRole(e.target.value as "viewer" | "operator" | "admin")}
300
+ className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[13px] text-[var(--text-secondary)] outline-none focus:border-[var(--border-focus)] transition-colors"
301
+ >
302
+ <option value="viewer">Viewer</option>
303
+ <option value="operator">Operator</option>
304
+ <option value="admin">Admin</option>
305
+ </select>
306
+ <button
307
+ type="submit"
308
+ disabled={adding || !newEmail.trim()}
309
+ className="rounded-md bg-[var(--accent)] px-4 py-2 text-[13px] font-medium text-[var(--text-primary)] transition-colors hover:bg-[var(--accent-hover)] disabled:opacity-50 disabled:cursor-not-allowed"
310
+ >
311
+ {adding ? "Adding..." : "Add User"}
312
+ </button>
313
+ </form>
314
+
315
+ {error && (
316
+ <div className="rounded-md bg-red-500/10 border border-red-500/20 px-4 py-2.5 text-[13px] text-red-400">
317
+ {error}
318
+ </div>
319
+ )}
320
+
321
+ {/* User list */}
322
+ <div className="rounded-md border border-[var(--border-subtle)] overflow-hidden">
323
+ <table className="w-full">
324
+ <thead>
325
+ <tr className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50">
326
+ <th className="text-left px-4 py-3 text-[11px] font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
327
+ Email
328
+ </th>
329
+ <th className="text-left px-4 py-3 text-[11px] font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
330
+ Role
331
+ </th>
332
+ <th className="text-left px-4 py-3 text-[11px] font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
333
+ Added By
334
+ </th>
335
+ <th className="text-right px-4 py-3 text-[11px] font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
336
+ Actions
337
+ </th>
338
+ </tr>
339
+ </thead>
340
+ <tbody>
341
+ {users.map((u) => {
342
+ const isSelf = u.email === user?.email;
343
+ return (
344
+ <tr
345
+ key={u.id}
346
+ className="border-b border-[var(--border-subtle)]/50 last:border-0 hover:bg-[var(--bg-base)]/30 transition-colors"
347
+ >
348
+ <td className="px-4 py-3">
349
+ <span className="text-[13px] text-[var(--text-primary)]">{u.email}</span>
350
+ {isSelf && (
351
+ <span className="ml-2 text-[10px] text-[var(--text-muted)] bg-[var(--bg-elevated)] rounded px-1.5 py-0.5">
352
+ you
353
+ </span>
354
+ )}
355
+ </td>
356
+ <td className="px-4 py-3">
357
+ <select
358
+ value={u.role}
359
+ onChange={(e) => handleRoleChange(u.id, e.target.value)}
360
+ disabled={isSelf}
361
+ className={`rounded-md border border-[var(--border-subtle)] bg-[var(--bg-base)] px-2 py-1 text-[11px] outline-none transition-colors ${
362
+ isSelf
363
+ ? "text-[var(--text-tertiary)] cursor-not-allowed"
364
+ : "text-[var(--text-secondary)] hover:border-[var(--border-default)] focus:border-[var(--border-focus)]"
365
+ }`}
366
+ >
367
+ <option value="admin">Admin</option>
368
+ <option value="operator">Operator</option>
369
+ <option value="viewer">Viewer</option>
370
+ </select>
371
+ </td>
372
+ <td className="px-4 py-3">
373
+ <span className="text-[11px] text-[var(--text-tertiary)]">
374
+ {u.added_by || "system"}
375
+ </span>
376
+ </td>
377
+ <td className="px-4 py-3 text-right">
378
+ {!isSelf && (
379
+ <button
380
+ onClick={() => handleRemove(u.id, u.email)}
381
+ className="text-[11px] text-[var(--text-muted)] hover:text-red-400 transition-colors"
382
+ >
383
+ Remove
384
+ </button>
385
+ )}
386
+ </td>
387
+ </tr>
388
+ );
389
+ })}
390
+ </tbody>
391
+ </table>
392
+ </div>
393
+
394
+ {/* Role explainer */}
395
+ <RoleExplainer />
396
+ </div>
397
+ );
398
+ }
399
+
400
+ // ---------------------------------------------------------------------------
401
+ // API Keys Tab
402
+ // ---------------------------------------------------------------------------
403
+
404
+ interface EnvKeyInfo {
405
+ name: string;
406
+ category: "core" | "auth";
407
+ optional: boolean;
408
+ masked: string | null;
409
+ source: "env-file" | "process" | "missing";
410
+ health: "valid" | "invalid" | "unchecked" | "missing";
411
+ healthError?: string;
412
+ validationHint: string;
413
+ }
414
+
415
+ function ApiKeysTab() {
416
+ const [keys, setKeys] = useState<EnvKeyInfo[] | null>(null);
417
+ const [loading, setLoading] = useState(true);
418
+ const [error, setError] = useState<string | null>(null);
419
+ const [editingKey, setEditingKey] = useState<string | null>(null);
420
+ const [editValue, setEditValue] = useState("");
421
+ const [confirmName, setConfirmName] = useState("");
422
+ const [saving, setSaving] = useState(false);
423
+ const [saveError, setSaveError] = useState<string | null>(null);
424
+
425
+ const fetchKeys = useCallback(async () => {
426
+ setLoading(true);
427
+ setError(null);
428
+ try {
429
+ const res = await fetch("/api/settings/env-keys");
430
+ if (!res.ok) throw new Error("Failed to load keys");
431
+ setKeys(await res.json());
432
+ } catch (e) {
433
+ setError((e as Error).message);
434
+ } finally {
435
+ setLoading(false);
436
+ }
437
+ }, []);
438
+
439
+ useEffect(() => {
440
+ fetchKeys();
441
+ }, [fetchKeys]);
442
+
443
+ const startEdit = (name: string) => {
444
+ setEditingKey(name);
445
+ setEditValue("");
446
+ setConfirmName("");
447
+ setSaveError(null);
448
+ };
449
+
450
+ const cancelEdit = () => {
451
+ setEditingKey(null);
452
+ setEditValue("");
453
+ setConfirmName("");
454
+ setSaveError(null);
455
+ };
456
+
457
+ const handleSave = async () => {
458
+ if (!editingKey || confirmName !== editingKey) return;
459
+ setSaving(true);
460
+ setSaveError(null);
461
+ try {
462
+ const res = await fetch("/api/settings/env-keys", {
463
+ method: "PATCH",
464
+ headers: { "Content-Type": "application/json" },
465
+ body: JSON.stringify({ name: editingKey, value: editValue }),
466
+ });
467
+ const data = await res.json();
468
+ if (!res.ok) {
469
+ setSaveError(data.error || "Failed to save");
470
+ setSaving(false);
471
+ return;
472
+ }
473
+ cancelEdit();
474
+ fetchKeys(); // Refresh all keys to show updated status
475
+ } catch (e) {
476
+ setSaveError((e as Error).message);
477
+ } finally {
478
+ setSaving(false);
479
+ }
480
+ };
481
+
482
+ const healthBadge = (health: EnvKeyInfo["health"], error?: string) => {
483
+ switch (health) {
484
+ case "valid":
485
+ return <span className="inline-flex items-center gap-1 text-[11px] text-green-400" title="Key is valid"><span className="h-1.5 w-1.5 rounded-full bg-green-400" />Valid</span>;
486
+ case "invalid":
487
+ return <span className="inline-flex items-center gap-1 text-[11px] text-red-400" title={error}><span className="h-1.5 w-1.5 rounded-full bg-red-400" />Invalid{error ? `: ${error}` : ""}</span>;
488
+ case "missing":
489
+ return <span className="inline-flex items-center gap-1 text-[11px] text-amber-400"><span className="h-1.5 w-1.5 rounded-full bg-amber-400" />Missing</span>;
490
+ case "unchecked":
491
+ return <span className="inline-flex items-center gap-1 text-[11px] text-[var(--text-muted)]"><span className="h-1.5 w-1.5 rounded-full bg-[var(--text-muted)]" />Not set</span>;
492
+ }
493
+ };
494
+
495
+ const sourceBadge = (source: EnvKeyInfo["source"]) => {
496
+ switch (source) {
497
+ case "env-file":
498
+ return <span className="rounded px-1.5 py-0.5 text-[10px] bg-blue-500/10 text-blue-400 border border-blue-500/20">.env</span>;
499
+ case "process":
500
+ return <span className="rounded px-1.5 py-0.5 text-[10px] bg-purple-500/10 text-purple-400 border border-purple-500/20">shell</span>;
501
+ case "missing":
502
+ return null;
503
+ }
504
+ };
505
+
506
+ const categoryBadge = (cat: EnvKeyInfo["category"], optional: boolean) => {
507
+ if (cat === "core") {
508
+ return <span className="rounded px-1.5 py-0.5 text-[10px] bg-[var(--accent-muted)] text-[var(--accent-text)]">Core</span>;
509
+ }
510
+ return <span className="rounded px-1.5 py-0.5 text-[10px] bg-[var(--bg-elevated)] text-[var(--text-muted)] border border-[var(--border-subtle)]">Auth{optional ? " (optional)" : ""}</span>;
511
+ };
512
+
513
+ if (loading) {
514
+ return (
515
+ <div className="space-y-3 animate-pulse">
516
+ {[...Array(6)].map((_, i) => (
517
+ <div key={i} className="h-16 rounded-md bg-[var(--bg-elevated)]/30" />
518
+ ))}
519
+ </div>
520
+ );
521
+ }
522
+
523
+ if (error) {
524
+ return (
525
+ <div className="rounded-md border border-red-500/20 bg-red-500/5 px-4 py-3">
526
+ <p className="text-[13px] text-red-400">{error}</p>
527
+ <button onClick={fetchKeys} className="mt-2 text-[12px] text-[var(--accent-text)] hover:underline">Retry</button>
528
+ </div>
529
+ );
530
+ }
531
+
532
+ return (
533
+ <div className="space-y-4">
534
+ <div className="flex items-center justify-between">
535
+ <p className="text-[12px] text-[var(--text-muted)]">
536
+ API keys and secrets loaded by the application. Values are never shown in full.
537
+ </p>
538
+ <button
539
+ onClick={fetchKeys}
540
+ className="text-[11px] text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] transition-colors"
541
+ >
542
+ Refresh
543
+ </button>
544
+ </div>
545
+
546
+ <div className="rounded-md border border-[var(--border-subtle)] divide-y divide-[var(--border-subtle)]">
547
+ {keys?.map((key) => (
548
+ <div key={key.name} className="px-4 py-3">
549
+ {/* Key info row */}
550
+ <div className="flex items-center justify-between gap-4">
551
+ <div className="flex items-center gap-3 min-w-0">
552
+ <code className="text-[13px] font-mono text-[var(--text-primary)] shrink-0">{key.name}</code>
553
+ {categoryBadge(key.category, key.optional)}
554
+ {sourceBadge(key.source)}
555
+ </div>
556
+ <div className="flex items-center gap-3 shrink-0">
557
+ {healthBadge(key.health, key.healthError)}
558
+ {editingKey !== key.name && (
559
+ <button
560
+ onClick={() => startEdit(key.name)}
561
+ className="rounded-md px-2.5 py-1 text-[11px] text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] transition-colors"
562
+ >
563
+ {key.source === "missing" ? "Set" : "Change"}
564
+ </button>
565
+ )}
566
+ </div>
567
+ </div>
568
+
569
+ {/* Masked value */}
570
+ {key.masked && editingKey !== key.name && (
571
+ <div className="mt-1">
572
+ <span className="text-[12px] font-mono text-[var(--text-muted)] tracking-wider">{key.masked}</span>
573
+ </div>
574
+ )}
575
+
576
+ {/* Edit form */}
577
+ {editingKey === key.name && (
578
+ <div className="mt-3 space-y-3">
579
+ {/* Warning */}
580
+ <div className="rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2.5">
581
+ <p className="text-[12px] text-amber-300/90 font-medium mb-1">
582
+ {key.source === "missing" ? "Setting a new key" : "Changing this key takes effect immediately"}
583
+ </p>
584
+ <p className="text-[11px] text-amber-300/60">
585
+ {key.category === "core"
586
+ ? "An invalid key will break core application functionality."
587
+ : "This key is optional. Leaving it empty will disable related features."}
588
+ {" "}The new value will be validated before saving.
589
+ </p>
590
+ </div>
591
+
592
+ {/* Value input */}
593
+ <div>
594
+ <label className="block text-[11px] text-[var(--text-muted)] mb-1">New value</label>
595
+ <input
596
+ type="password"
597
+ value={editValue}
598
+ onChange={(e) => setEditValue(e.target.value)}
599
+ placeholder={key.validationHint}
600
+ className="w-full rounded-md border border-[var(--border-default)] bg-[var(--bg-base)] px-3 py-2 text-[13px] font-mono text-[var(--text-secondary)] placeholder:text-[var(--text-muted)] focus:border-[var(--border-focus)] focus:outline-none"
601
+ autoFocus
602
+ />
603
+ </div>
604
+
605
+ {/* Confirmation input */}
606
+ <div>
607
+ <label className="block text-[11px] text-[var(--text-muted)] mb-1">
608
+ Type <code className="text-[var(--text-tertiary)]">{key.name}</code> to confirm
609
+ </label>
610
+ <input
611
+ type="text"
612
+ value={confirmName}
613
+ onChange={(e) => setConfirmName(e.target.value)}
614
+ placeholder={key.name}
615
+ className="w-full rounded-md border border-[var(--border-default)] bg-[var(--bg-base)] px-3 py-2 text-[13px] font-mono text-[var(--text-secondary)] placeholder:text-[var(--text-muted)] focus:border-[var(--border-focus)] focus:outline-none"
616
+ />
617
+ </div>
618
+
619
+ {/* Save error */}
620
+ {saveError && (
621
+ <p className="text-[12px] text-red-400">{saveError}</p>
622
+ )}
623
+
624
+ {/* Actions */}
625
+ <div className="flex items-center gap-2">
626
+ <button
627
+ onClick={handleSave}
628
+ disabled={saving || !editValue || confirmName !== key.name}
629
+ className="rounded-md bg-[var(--accent)] px-3.5 py-1.5 text-[12px] font-medium text-white transition-colors hover:bg-[var(--accent-hover)] disabled:opacity-40 disabled:cursor-not-allowed"
630
+ >
631
+ {saving ? "Validating & saving..." : "Save"}
632
+ </button>
633
+ <button
634
+ onClick={cancelEdit}
635
+ className="rounded-md px-3.5 py-1.5 text-[12px] text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] transition-colors"
636
+ >
637
+ Cancel
638
+ </button>
639
+ </div>
640
+ </div>
641
+ )}
642
+ </div>
643
+ ))}
644
+ </div>
645
+ </div>
646
+ );
647
+ }
648
+
649
+ function ClaudeMdTab() {
650
+ const [content, setContent] = useState("");
651
+ const [savedContent, setSavedContent] = useState("");
652
+ const [loading, setLoading] = useState(true);
653
+ const [saving, setSaving] = useState(false);
654
+ const [error, setError] = useState<string | null>(null);
655
+ const [success, setSuccess] = useState(false);
656
+ const [mode, setMode] = useState<"edit" | "preview">("preview");
657
+
658
+ // AI Improve state
659
+ const [aiOpen, setAiOpen] = useState(false);
660
+ const [aiPrompt, setAiPrompt] = useState("");
661
+ const [aiLoading, setAiLoading] = useState(false);
662
+ const [isAnimating, setIsAnimating] = useState(false);
663
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
664
+ const abortRef = useRef<AbortController | null>(null);
665
+ const aiInputRef = useRef<HTMLTextAreaElement>(null);
666
+
667
+ const isDirty = content !== savedContent;
668
+
669
+ const fetchContent = useCallback(async () => {
670
+ try {
671
+ const res = await fetch("/api/settings/claude-md");
672
+ if (!res.ok) throw new Error("Failed to load CLAUDE.md");
673
+ const data = await res.json();
674
+ setContent(data.content);
675
+ setSavedContent(data.content);
676
+ } catch (e) {
677
+ setError((e as Error).message);
678
+ } finally {
679
+ setLoading(false);
680
+ }
681
+ }, []);
682
+
683
+ useEffect(() => {
684
+ fetchContent();
685
+ }, [fetchContent]);
686
+
687
+ // Focus AI input when popout opens
688
+ useEffect(() => {
689
+ if (aiOpen) {
690
+ setTimeout(() => aiInputRef.current?.focus(), 50);
691
+ }
692
+ }, [aiOpen]);
693
+
694
+ // Cleanup animation on unmount
695
+ useEffect(() => {
696
+ return () => abortRef.current?.abort();
697
+ }, []);
698
+
699
+ // Keyboard shortcut: Ctrl/Cmd+S to save
700
+ useEffect(() => {
701
+ const handler = (e: KeyboardEvent) => {
702
+ if ((e.metaKey || e.ctrlKey) && e.key === "s" && mode === "edit" && isDirty && !isAnimating) {
703
+ e.preventDefault();
704
+ handleSave();
705
+ }
706
+ };
707
+ window.addEventListener("keydown", handler);
708
+ return () => window.removeEventListener("keydown", handler);
709
+ // eslint-disable-next-line react-hooks/exhaustive-deps
710
+ }, [mode, isDirty, content, isAnimating]);
711
+
712
+ const handleSave = async () => {
713
+ setSaving(true);
714
+ setError(null);
715
+ setSuccess(false);
716
+ try {
717
+ const res = await fetch("/api/settings/claude-md", {
718
+ method: "PUT",
719
+ headers: { "Content-Type": "application/json" },
720
+ body: JSON.stringify({ content }),
721
+ });
722
+ if (!res.ok) {
723
+ const data = await res.json();
724
+ throw new Error(data.error || "Failed to save");
725
+ }
726
+ setSavedContent(content);
727
+ setSuccess(true);
728
+ setTimeout(() => setSuccess(false), 2000);
729
+ } catch (e) {
730
+ setError((e as Error).message);
731
+ } finally {
732
+ setSaving(false);
733
+ }
734
+ };
735
+
736
+ const handleAiImprove = async () => {
737
+ if (!aiPrompt.trim() || aiLoading || isAnimating) return;
738
+
739
+ setAiLoading(true);
740
+ setError(null);
741
+ setAiOpen(false);
742
+
743
+ // Switch to edit mode so user sees the animation
744
+ if (mode !== "edit") setMode("edit");
745
+
746
+ try {
747
+ const res = await fetch("/api/ai/improve-claude-md", {
748
+ method: "POST",
749
+ headers: { "Content-Type": "application/json" },
750
+ body: JSON.stringify({ content, instruction: aiPrompt }),
751
+ });
752
+
753
+ if (!res.ok) {
754
+ const data = await res.json();
755
+ throw new Error(data.error || "AI request failed");
756
+ }
757
+
758
+ const { improved } = await res.json();
759
+ setAiLoading(false);
760
+
761
+ if (improved === content) {
762
+ setError("No changes needed — the AI found nothing to update for that instruction.");
763
+ return;
764
+ }
765
+
766
+ // Start typewriter animation
767
+ setIsAnimating(true);
768
+ const controller = new AbortController();
769
+ abortRef.current = controller;
770
+
771
+ // Small delay so edit mode renders the textarea
772
+ await new Promise((r) => setTimeout(r, 100));
773
+
774
+ await animateDiff(
775
+ textareaRef.current,
776
+ content,
777
+ improved,
778
+ setContent,
779
+ { deleteSpeed: 1, typeSpeed: 2, batchSize: 5, signal: controller.signal }
780
+ );
781
+
782
+ setContent(improved);
783
+ setIsAnimating(false);
784
+ setAiPrompt("");
785
+ abortRef.current = null;
786
+ } catch (e) {
787
+ setAiLoading(false);
788
+ setIsAnimating(false);
789
+ setError((e as Error).message);
790
+ }
791
+ };
792
+
793
+ const handleSkipAnimation = () => {
794
+ abortRef.current?.abort();
795
+ };
796
+
797
+ if (loading) {
798
+ return (
799
+ <div className="space-y-3 animate-pulse">
800
+ <div className="h-8 w-48 rounded-md bg-[var(--bg-elevated)]/40" />
801
+ <div className="h-[500px] rounded-md bg-[var(--bg-elevated)]/30" />
802
+ </div>
803
+ );
804
+ }
805
+
806
+ return (
807
+ <div className="space-y-4">
808
+ {/* Toolbar */}
809
+ <div className="flex items-center justify-between">
810
+ <div className="flex items-center gap-3">
811
+ <div className="flex items-center gap-1 rounded-md bg-[var(--bg-base)] border border-[var(--border-subtle)] p-0.5">
812
+ <button
813
+ onClick={() => setMode("preview")}
814
+ disabled={isAnimating}
815
+ className={`rounded-md px-3 py-1.5 text-[11px] font-medium transition-colors ${
816
+ mode === "preview"
817
+ ? "bg-[var(--bg-elevated)] text-[var(--text-primary)] shadow-sm"
818
+ : "text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]"
819
+ } disabled:opacity-40`}
820
+ >
821
+ Preview
822
+ </button>
823
+ <button
824
+ onClick={() => setMode("edit")}
825
+ disabled={isAnimating}
826
+ className={`rounded-md px-3 py-1.5 text-[11px] font-medium transition-colors ${
827
+ mode === "edit"
828
+ ? "bg-[var(--bg-elevated)] text-[var(--text-primary)] shadow-sm"
829
+ : "text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]"
830
+ } disabled:opacity-40`}
831
+ >
832
+ Edit
833
+ </button>
834
+ </div>
835
+
836
+ {/* Improve with AI button */}
837
+ <div className="relative">
838
+ <button
839
+ onClick={() => setAiOpen(!aiOpen)}
840
+ disabled={aiLoading || isAnimating}
841
+ className="flex items-center gap-1.5 rounded-md bg-[var(--accent-muted)] border border-[var(--accent-muted)] px-3 py-1.5 text-[11px] font-medium text-[var(--accent-text)] transition-all hover:bg-[var(--accent)]/25 hover:border-[var(--accent)] hover:text-[var(--accent-text)] disabled:opacity-40 disabled:cursor-not-allowed"
842
+ >
843
+ <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
844
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z" />
845
+ </svg>
846
+ Improve with AI
847
+ </button>
848
+
849
+ {/* AI Instruction Popout */}
850
+ {aiOpen && (
851
+ <>
852
+ {/* Backdrop */}
853
+ <div
854
+ className="fixed inset-0 z-40"
855
+ onClick={() => setAiOpen(false)}
856
+ />
857
+ {/* Popout card */}
858
+ <div className="absolute top-full left-0 mt-2 z-50 w-96 rounded-md border border-[var(--accent-muted)] bg-[var(--bg-base)] shadow-2xl shadow-black/40 overflow-hidden">
859
+ <div className="px-4 py-3 border-b border-[var(--border-subtle)]/60">
860
+ <div className="flex items-center gap-2 mb-1">
861
+ <div className="h-1.5 w-1.5 rounded-full bg-[var(--accent)] animate-pulse" />
862
+ <span className="text-[11px] font-medium text-[var(--accent-text)]">AI Editor</span>
863
+ </div>
864
+ <p className="text-[11px] text-[var(--text-tertiary)] leading-relaxed">
865
+ Describe what you want changed. Only the relevant sections will be updated.
866
+ </p>
867
+ </div>
868
+ <div className="p-3">
869
+ <textarea
870
+ ref={aiInputRef}
871
+ value={aiPrompt}
872
+ onChange={(e) => setAiPrompt(e.target.value)}
873
+ onKeyDown={(e) => {
874
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
875
+ e.preventDefault();
876
+ handleAiImprove();
877
+ }
878
+ if (e.key === "Escape") {
879
+ setAiOpen(false);
880
+ }
881
+ }}
882
+ placeholder="e.g. Add a section about deployment to AWS..."
883
+ className="w-full h-20 rounded-md border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2.5 text-[13px] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] resize-none outline-none focus:border-[var(--border-focus)] transition-colors"
884
+ />
885
+ <div className="flex items-center justify-between mt-2.5">
886
+ <span className="text-[10px] text-[var(--text-muted)]">
887
+ {navigator.platform.includes("Mac") ? "⌘" : "Ctrl"}+Enter to send
888
+ </span>
889
+ <div className="flex gap-2">
890
+ <button
891
+ onClick={() => setAiOpen(false)}
892
+ className="rounded-md px-3 py-1.5 text-[11px] text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] transition-colors"
893
+ >
894
+ Cancel
895
+ </button>
896
+ <button
897
+ onClick={handleAiImprove}
898
+ disabled={!aiPrompt.trim()}
899
+ className="rounded-md bg-[var(--accent)] px-4 py-1.5 text-[11px] font-medium text-[var(--text-primary)] transition-colors hover:bg-[var(--accent-hover)] disabled:opacity-40 disabled:cursor-not-allowed"
900
+ >
901
+ Apply
902
+ </button>
903
+ </div>
904
+ </div>
905
+ </div>
906
+ </div>
907
+ </>
908
+ )}
909
+ </div>
910
+ </div>
911
+
912
+ <div className="flex items-center gap-3">
913
+ {isDirty && !isAnimating && (
914
+ <span className="text-[11px] text-amber-400/80">Unsaved changes</span>
915
+ )}
916
+ {success && (
917
+ <span className="text-[11px] text-emerald-400">Saved</span>
918
+ )}
919
+ {mode === "edit" && !isAnimating && (
920
+ <button
921
+ onClick={handleSave}
922
+ disabled={saving || !isDirty}
923
+ className="rounded-md bg-[var(--accent)] px-4 py-1.5 text-[11px] font-medium text-[var(--text-primary)] transition-colors hover:bg-[var(--accent-hover)] disabled:opacity-40 disabled:cursor-not-allowed"
924
+ >
925
+ {saving ? "Saving..." : "Save"}
926
+ </button>
927
+ )}
928
+ </div>
929
+ </div>
930
+
931
+ {/* AI Loading / Animating Status Bar */}
932
+ {(aiLoading || isAnimating) && (
933
+ <div className="flex items-center justify-between rounded-md border border-[var(--accent-muted)] bg-[var(--accent-muted)] px-4 py-2.5">
934
+ <div className="flex items-center gap-2.5">
935
+ {aiLoading ? (
936
+ <>
937
+ <div className="flex gap-1">
938
+ <div className="h-1.5 w-1.5 rounded-full bg-[var(--accent)] animate-bounce [animation-delay:-0.3s]" />
939
+ <div className="h-1.5 w-1.5 rounded-full bg-[var(--accent)] animate-bounce [animation-delay:-0.15s]" />
940
+ <div className="h-1.5 w-1.5 rounded-full bg-[var(--accent)] animate-bounce" />
941
+ </div>
942
+ <span className="text-[11px] text-[var(--accent-text)]">Thinking...</span>
943
+ </>
944
+ ) : (
945
+ <>
946
+ <div className="h-1.5 w-1.5 rounded-full bg-[var(--accent)] animate-pulse" />
947
+ <span className="text-[11px] text-[var(--accent-text)]">Applying changes...</span>
948
+ </>
949
+ )}
950
+ </div>
951
+ {isAnimating && (
952
+ <button
953
+ onClick={handleSkipAnimation}
954
+ className="rounded-md px-2.5 py-1 text-[11px] text-[var(--accent-text)]/70 hover:text-[var(--accent-text)] hover:bg-[var(--accent-hover)]/10 transition-colors"
955
+ >
956
+ Skip
957
+ </button>
958
+ )}
959
+ </div>
960
+ )}
961
+
962
+ {error && (
963
+ <div className="rounded-md bg-red-500/10 border border-red-500/20 px-4 py-2.5 text-[13px] text-red-400">
964
+ {error}
965
+ </div>
966
+ )}
967
+
968
+ {/* Editor / Preview */}
969
+ {mode === "edit" ? (
970
+ <div className="relative">
971
+ <textarea
972
+ ref={textareaRef}
973
+ value={content}
974
+ onChange={(e) => !isAnimating && setContent(e.target.value)}
975
+ readOnly={isAnimating}
976
+ spellCheck={false}
977
+ className={`w-full h-[calc(100vh-380px)] min-h-[400px] rounded-md border bg-[var(--bg-base)] px-5 py-4 text-[13px] text-[var(--text-primary)] font-mono leading-relaxed resize-none outline-none transition-colors placeholder:text-[var(--text-muted)] ${
978
+ isAnimating
979
+ ? "border-[var(--accent)] cursor-default"
980
+ : "border-[var(--border-subtle)] focus:border-[var(--border-focus)]"
981
+ }`}
982
+ placeholder="# Project Instructions&#10;&#10;Write your CLAUDE.md content here..."
983
+ />
984
+ {!isAnimating && (
985
+ <div className="absolute bottom-3 right-3 text-[10px] text-[var(--text-muted)]">
986
+ {navigator.platform.includes("Mac") ? "⌘" : "Ctrl"}+S to save
987
+ </div>
988
+ )}
989
+ </div>
990
+ ) : (
991
+ <div className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-base)] px-6 py-5 min-h-[400px] max-h-[calc(100vh-380px)] overflow-y-auto">
992
+ {content ? (
993
+ <MarkdownPreview content={content} />
994
+ ) : (
995
+ <p className="text-[13px] text-[var(--text-muted)] italic">CLAUDE.md is empty. Switch to Edit mode to add content.</p>
996
+ )}
997
+ </div>
998
+ )}
999
+
1000
+ <p className="text-[11px] text-[var(--text-muted)] leading-relaxed">
1001
+ This file provides project-level instructions to Claude Code. Changes take effect immediately for all new conversations.
1002
+ </p>
1003
+ </div>
1004
+ );
1005
+ }
1006
+
1007
+ function MarkdownPreview({ content }: { content: string }) {
1008
+ // Simple markdown renderer — handles headers, code blocks, lists, bold, links, tables
1009
+ const lines = content.split("\n");
1010
+ const elements: React.ReactNode[] = [];
1011
+ let i = 0;
1012
+
1013
+ while (i < lines.length) {
1014
+ const line = lines[i];
1015
+
1016
+ // Fenced code block
1017
+ if (line.startsWith("```")) {
1018
+ const lang = line.slice(3).trim();
1019
+ const codeLines: string[] = [];
1020
+ i++;
1021
+ while (i < lines.length && !lines[i].startsWith("```")) {
1022
+ codeLines.push(lines[i]);
1023
+ i++;
1024
+ }
1025
+ i++; // skip closing ```
1026
+ elements.push(
1027
+ <div key={elements.length} className="my-3 rounded-md bg-[var(--bg-base)] border border-[var(--border-subtle)] overflow-hidden">
1028
+ {lang && (
1029
+ <div className="px-3 py-1.5 text-[10px] text-[var(--text-tertiary)] border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/80">
1030
+ {lang}
1031
+ </div>
1032
+ )}
1033
+ <pre className="px-4 py-3 overflow-x-auto">
1034
+ <code className="text-[13px] text-[var(--text-secondary)] leading-relaxed">{codeLines.join("\n")}</code>
1035
+ </pre>
1036
+ </div>
1037
+ );
1038
+ continue;
1039
+ }
1040
+
1041
+ // Empty line
1042
+ if (line.trim() === "") {
1043
+ i++;
1044
+ continue;
1045
+ }
1046
+
1047
+ // Headers
1048
+ const headerMatch = line.match(/^(#{1,6})\s+(.*)/);
1049
+ if (headerMatch) {
1050
+ const level = headerMatch[1].length;
1051
+ const text = headerMatch[2];
1052
+ const sizes: Record<number, string> = {
1053
+ 1: "text-xl font-bold text-[var(--text-primary)] mt-6 mb-3",
1054
+ 2: "text-lg font-semibold text-[var(--text-primary)] mt-5 mb-2.5",
1055
+ 3: "text-base font-semibold text-[var(--text-primary)] mt-4 mb-2",
1056
+ 4: "text-[13px] font-semibold text-[var(--text-secondary)] mt-3 mb-1.5",
1057
+ 5: "text-[13px] font-medium text-[var(--text-tertiary)] mt-2 mb-1",
1058
+ 6: "text-[11px] font-medium text-[var(--text-tertiary)] mt-2 mb-1",
1059
+ };
1060
+ elements.push(
1061
+ <div key={elements.length} className={sizes[level]}>
1062
+ {formatInline(text)}
1063
+ </div>
1064
+ );
1065
+ i++;
1066
+ continue;
1067
+ }
1068
+
1069
+ // Table
1070
+ if (line.includes("|") && i + 1 < lines.length && lines[i + 1].match(/^\|[\s\-:|]+\|/)) {
1071
+ const tableLines: string[] = [line];
1072
+ i++;
1073
+ while (i < lines.length && lines[i].includes("|")) {
1074
+ tableLines.push(lines[i]);
1075
+ i++;
1076
+ }
1077
+ const parseRow = (row: string) =>
1078
+ row.split("|").slice(1, -1).map((c) => c.trim());
1079
+
1080
+ const headers = parseRow(tableLines[0]);
1081
+ const rows = tableLines.slice(2).map(parseRow);
1082
+
1083
+ elements.push(
1084
+ <div key={elements.length} className="my-3 rounded-md border border-[var(--border-subtle)] overflow-hidden overflow-x-auto">
1085
+ <table className="w-full">
1086
+ <thead>
1087
+ <tr className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/60">
1088
+ {headers.map((h, j) => (
1089
+ <th key={j} className="px-3 py-2 text-left text-[11px] font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
1090
+ {formatInline(h)}
1091
+ </th>
1092
+ ))}
1093
+ </tr>
1094
+ </thead>
1095
+ <tbody>
1096
+ {rows.map((row, j) => (
1097
+ <tr key={j} className="border-b border-[var(--border-subtle)]/30 last:border-0">
1098
+ {row.map((cell, k) => (
1099
+ <td key={k} className="px-3 py-2 text-[13px] text-[var(--text-secondary)]">
1100
+ {formatInline(cell)}
1101
+ </td>
1102
+ ))}
1103
+ </tr>
1104
+ ))}
1105
+ </tbody>
1106
+ </table>
1107
+ </div>
1108
+ );
1109
+ continue;
1110
+ }
1111
+
1112
+ // Unordered list
1113
+ if (line.match(/^(\s*)[-*]\s/)) {
1114
+ const listItems: { indent: number; text: string }[] = [];
1115
+ while (i < lines.length && lines[i].match(/^(\s*)[-*]\s/)) {
1116
+ const m = lines[i].match(/^(\s*)[-*]\s+(.*)/);
1117
+ if (m) listItems.push({ indent: m[1].length, text: m[2] });
1118
+ i++;
1119
+ }
1120
+ elements.push(
1121
+ <ul key={elements.length} className="my-2 space-y-1">
1122
+ {listItems.map((item, j) => (
1123
+ <li
1124
+ key={j}
1125
+ className="flex items-start gap-2 text-[13px] text-[var(--text-secondary)] leading-relaxed"
1126
+ style={{ paddingLeft: `${item.indent * 8 + 4}px` }}
1127
+ >
1128
+ <span className="text-[var(--text-muted)] mt-1.5 shrink-0">&#x2022;</span>
1129
+ <span>{formatInline(item.text)}</span>
1130
+ </li>
1131
+ ))}
1132
+ </ul>
1133
+ );
1134
+ continue;
1135
+ }
1136
+
1137
+ // Ordered list
1138
+ if (line.match(/^\s*\d+\.\s/)) {
1139
+ const listItems: string[] = [];
1140
+ while (i < lines.length && lines[i].match(/^\s*\d+\.\s/)) {
1141
+ const m = lines[i].match(/^\s*\d+\.\s+(.*)/);
1142
+ if (m) listItems.push(m[1]);
1143
+ i++;
1144
+ }
1145
+ elements.push(
1146
+ <ol key={elements.length} className="my-2 space-y-1 list-decimal list-inside">
1147
+ {listItems.map((item, j) => (
1148
+ <li key={j} className="text-[13px] text-[var(--text-secondary)] leading-relaxed pl-1">
1149
+ {formatInline(item)}
1150
+ </li>
1151
+ ))}
1152
+ </ol>
1153
+ );
1154
+ continue;
1155
+ }
1156
+
1157
+ // Horizontal rule
1158
+ if (line.match(/^---+$/)) {
1159
+ elements.push(<hr key={elements.length} className="my-4 border-[var(--border-subtle)]" />);
1160
+ i++;
1161
+ continue;
1162
+ }
1163
+
1164
+ // Paragraph (collect consecutive non-special lines)
1165
+ const paraLines: string[] = [line];
1166
+ i++;
1167
+ while (
1168
+ i < lines.length &&
1169
+ lines[i].trim() !== "" &&
1170
+ !lines[i].startsWith("#") &&
1171
+ !lines[i].startsWith("```") &&
1172
+ !lines[i].match(/^[-*]\s/) &&
1173
+ !lines[i].match(/^\d+\.\s/) &&
1174
+ !lines[i].match(/^---+$/) &&
1175
+ !(lines[i].includes("|") && i + 1 < lines.length && lines[i + 1]?.match(/^\|[\s\-:|]+\|/))
1176
+ ) {
1177
+ paraLines.push(lines[i]);
1178
+ i++;
1179
+ }
1180
+ elements.push(
1181
+ <p key={elements.length} className="text-[13px] text-[var(--text-secondary)] leading-relaxed my-2">
1182
+ {formatInline(paraLines.join(" "))}
1183
+ </p>
1184
+ );
1185
+ }
1186
+
1187
+ return <div className="prose-custom">{elements}</div>;
1188
+ }
1189
+
1190
+ function formatInline(text: string): React.ReactNode {
1191
+ // Process inline markdown: bold, italic, code, links
1192
+ const parts: React.ReactNode[] = [];
1193
+ let remaining = text;
1194
+ let key = 0;
1195
+
1196
+ while (remaining.length > 0) {
1197
+ // Inline code
1198
+ const codeMatch = remaining.match(/^`([^`]+)`/);
1199
+ if (codeMatch) {
1200
+ parts.push(
1201
+ <code key={key++} className="rounded bg-[var(--bg-elevated)] px-1.5 py-0.5 text-[12px] text-amber-300/90 font-mono">
1202
+ {codeMatch[1]}
1203
+ </code>
1204
+ );
1205
+ remaining = remaining.slice(codeMatch[0].length);
1206
+ continue;
1207
+ }
1208
+
1209
+ // Bold + italic
1210
+ const boldItalicMatch = remaining.match(/^\*\*\*(.+?)\*\*\*/);
1211
+ if (boldItalicMatch) {
1212
+ parts.push(<strong key={key++} className="font-bold italic text-[var(--text-primary)]">{boldItalicMatch[1]}</strong>);
1213
+ remaining = remaining.slice(boldItalicMatch[0].length);
1214
+ continue;
1215
+ }
1216
+
1217
+ // Bold
1218
+ const boldMatch = remaining.match(/^\*\*(.+?)\*\*/);
1219
+ if (boldMatch) {
1220
+ parts.push(<strong key={key++} className="font-semibold text-[var(--text-primary)]">{boldMatch[1]}</strong>);
1221
+ remaining = remaining.slice(boldMatch[0].length);
1222
+ continue;
1223
+ }
1224
+
1225
+ // Italic
1226
+ const italicMatch = remaining.match(/^\*(.+?)\*/);
1227
+ if (italicMatch) {
1228
+ parts.push(<em key={key++} className="italic text-[var(--text-primary)]">{italicMatch[1]}</em>);
1229
+ remaining = remaining.slice(italicMatch[0].length);
1230
+ continue;
1231
+ }
1232
+
1233
+ // Link
1234
+ const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
1235
+ if (linkMatch) {
1236
+ parts.push(
1237
+ <span key={key++} className="text-[var(--accent-text)] underline underline-offset-2 decoration-[var(--accent-muted)]">
1238
+ {linkMatch[1]}
1239
+ </span>
1240
+ );
1241
+ remaining = remaining.slice(linkMatch[0].length);
1242
+ continue;
1243
+ }
1244
+
1245
+ // Plain text (advance to next special char or end)
1246
+ const nextSpecial = remaining.search(/[`*\[]/);
1247
+ if (nextSpecial === 0) {
1248
+ // Special char that didn't match any pattern — just emit it
1249
+ parts.push(remaining[0]);
1250
+ remaining = remaining.slice(1);
1251
+ } else if (nextSpecial > 0) {
1252
+ parts.push(remaining.slice(0, nextSpecial));
1253
+ remaining = remaining.slice(nextSpecial);
1254
+ } else {
1255
+ parts.push(remaining);
1256
+ remaining = "";
1257
+ }
1258
+ }
1259
+
1260
+ return parts.length === 1 ? parts[0] : <>{parts}</>;
1261
+ }
1262
+
1263
+ export default function SettingsPage() {
1264
+ const [activeTab, setActiveTab] = useState<Tab>("users");
1265
+
1266
+ return (
1267
+ <div className={activeTab === "claude-md" ? "max-w-5xl mx-auto" : "max-w-3xl mx-auto"}>
1268
+ <h1 className="text-xl font-bold text-[var(--text-primary)] mb-6">Settings</h1>
1269
+
1270
+ {/* Tabs */}
1271
+ <div className="flex gap-1 border-b border-[var(--border-subtle)] mb-6">
1272
+ {tabs.map((tab) => (
1273
+ <button
1274
+ key={tab.key}
1275
+ onClick={() => setActiveTab(tab.key)}
1276
+ className={`flex items-center gap-2 px-4 py-2.5 text-[13px] font-medium transition-colors border-b-2 -mb-[1px] ${
1277
+ activeTab === tab.key
1278
+ ? "border-[var(--accent)] text-[var(--text-primary)]"
1279
+ : "border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]"
1280
+ }`}
1281
+ >
1282
+ <span>{tab.icon}</span>
1283
+ {tab.label}
1284
+ </button>
1285
+ ))}
1286
+ </div>
1287
+
1288
+ {/* Tab content */}
1289
+ {activeTab === "users" && <UserManagementTab />}
1290
+ {activeTab === "api-keys" && <ApiKeysTab />}
1291
+ {activeTab === "claude-md" && <ClaudeMdTab />}
1292
+ </div>
1293
+ );
1294
+ }