@cryptiklemur/lattice 0.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 (162) hide show
  1. package/.editorconfig +12 -0
  2. package/.github/workflows/release.yml +44 -0
  3. package/.impeccable.md +66 -0
  4. package/.releaserc.json +32 -0
  5. package/.serena/project.yml +138 -0
  6. package/CLAUDE.md +35 -0
  7. package/CONTRIBUTING.md +93 -0
  8. package/LICENSE +21 -0
  9. package/README.md +83 -0
  10. package/bun.lock +1459 -0
  11. package/bunfig.toml +2 -0
  12. package/client/index.html +32 -0
  13. package/client/package.json +37 -0
  14. package/client/public/icons/icon-192.svg +11 -0
  15. package/client/public/icons/icon-512.svg +11 -0
  16. package/client/public/manifest.json +24 -0
  17. package/client/public/sw.js +61 -0
  18. package/client/src/App.tsx +28 -0
  19. package/client/src/components/auth/PassphrasePrompt.tsx +70 -0
  20. package/client/src/components/chat/ChatInput.tsx +241 -0
  21. package/client/src/components/chat/ChatView.tsx +727 -0
  22. package/client/src/components/chat/Message.tsx +362 -0
  23. package/client/src/components/chat/ModelSelector.tsx +87 -0
  24. package/client/src/components/chat/PermissionModeSelector.tsx +41 -0
  25. package/client/src/components/chat/StatusBar.tsx +50 -0
  26. package/client/src/components/chat/ToolGroup.tsx +129 -0
  27. package/client/src/components/chat/ToolResultRenderer.tsx +343 -0
  28. package/client/src/components/chat/toolSummary.ts +41 -0
  29. package/client/src/components/dashboard/DashboardView.tsx +219 -0
  30. package/client/src/components/dashboard/ProjectDashboardView.tsx +168 -0
  31. package/client/src/components/mesh/NodeBadge.tsx +24 -0
  32. package/client/src/components/mesh/PairingDialog.tsx +281 -0
  33. package/client/src/components/panels/FileBrowser.tsx +241 -0
  34. package/client/src/components/panels/StickyNotes.tsx +187 -0
  35. package/client/src/components/panels/Terminal.tsx +128 -0
  36. package/client/src/components/project-settings/ProjectClaude.tsx +304 -0
  37. package/client/src/components/project-settings/ProjectEnvironment.tsx +235 -0
  38. package/client/src/components/project-settings/ProjectGeneral.tsx +76 -0
  39. package/client/src/components/project-settings/ProjectMcp.tsx +232 -0
  40. package/client/src/components/project-settings/ProjectPermissions.tsx +209 -0
  41. package/client/src/components/project-settings/ProjectRules.tsx +277 -0
  42. package/client/src/components/project-settings/ProjectSettingsView.tsx +99 -0
  43. package/client/src/components/project-settings/ProjectSkills.tsx +91 -0
  44. package/client/src/components/settings/Appearance.tsx +151 -0
  45. package/client/src/components/settings/ClaudeSettings.tsx +151 -0
  46. package/client/src/components/settings/Environment.tsx +185 -0
  47. package/client/src/components/settings/GlobalMcp.tsx +207 -0
  48. package/client/src/components/settings/GlobalSkills.tsx +125 -0
  49. package/client/src/components/settings/MeshStatus.tsx +145 -0
  50. package/client/src/components/settings/SettingsView.tsx +57 -0
  51. package/client/src/components/settings/SkillMarketplace.tsx +175 -0
  52. package/client/src/components/settings/mcp-shared.tsx +194 -0
  53. package/client/src/components/settings/skill-shared.tsx +177 -0
  54. package/client/src/components/setup/SetupWizard.tsx +750 -0
  55. package/client/src/components/sidebar/NodeSettingsModal.tsx +180 -0
  56. package/client/src/components/sidebar/ProjectDropdown.tsx +43 -0
  57. package/client/src/components/sidebar/ProjectRail.tsx +291 -0
  58. package/client/src/components/sidebar/SearchFilter.tsx +52 -0
  59. package/client/src/components/sidebar/SessionList.tsx +384 -0
  60. package/client/src/components/sidebar/SettingsSidebar.tsx +128 -0
  61. package/client/src/components/sidebar/Sidebar.tsx +209 -0
  62. package/client/src/components/sidebar/UserIsland.tsx +59 -0
  63. package/client/src/components/sidebar/UserMenu.tsx +101 -0
  64. package/client/src/components/ui/CommandPalette.tsx +321 -0
  65. package/client/src/components/ui/ErrorBoundary.tsx +56 -0
  66. package/client/src/components/ui/IconPicker.tsx +209 -0
  67. package/client/src/components/ui/LatticeLogomark.tsx +19 -0
  68. package/client/src/components/ui/PopupMenu.tsx +98 -0
  69. package/client/src/components/ui/SaveFooter.tsx +38 -0
  70. package/client/src/components/ui/Toast.tsx +112 -0
  71. package/client/src/hooks/useMesh.ts +89 -0
  72. package/client/src/hooks/useProjectSettings.ts +56 -0
  73. package/client/src/hooks/useProjects.ts +66 -0
  74. package/client/src/hooks/useSaveState.ts +59 -0
  75. package/client/src/hooks/useSession.ts +317 -0
  76. package/client/src/hooks/useSidebar.ts +74 -0
  77. package/client/src/hooks/useSkills.ts +30 -0
  78. package/client/src/hooks/useTheme.ts +114 -0
  79. package/client/src/hooks/useWebSocket.ts +26 -0
  80. package/client/src/main.tsx +10 -0
  81. package/client/src/providers/WebSocketProvider.tsx +146 -0
  82. package/client/src/router.tsx +391 -0
  83. package/client/src/stores/mesh.ts +78 -0
  84. package/client/src/stores/session.ts +322 -0
  85. package/client/src/stores/sidebar.ts +336 -0
  86. package/client/src/stores/theme.ts +44 -0
  87. package/client/src/styles/global.css +167 -0
  88. package/client/src/styles/theme-vars.css +18 -0
  89. package/client/src/themes/index.ts +79 -0
  90. package/client/src/utils/findDuplicateKeys.ts +12 -0
  91. package/client/tsconfig.json +14 -0
  92. package/client/vite.config.ts +20 -0
  93. package/package.json +46 -0
  94. package/server/package.json +22 -0
  95. package/server/src/auth/passphrase.ts +48 -0
  96. package/server/src/config.ts +55 -0
  97. package/server/src/daemon.ts +338 -0
  98. package/server/src/features/ralph-loop.ts +173 -0
  99. package/server/src/features/scheduler.ts +281 -0
  100. package/server/src/features/sticky-notes.ts +102 -0
  101. package/server/src/handlers/chat.ts +194 -0
  102. package/server/src/handlers/fs.ts +84 -0
  103. package/server/src/handlers/loop.ts +37 -0
  104. package/server/src/handlers/mesh.ts +125 -0
  105. package/server/src/handlers/notes.ts +45 -0
  106. package/server/src/handlers/project-settings.ts +174 -0
  107. package/server/src/handlers/scheduler.ts +47 -0
  108. package/server/src/handlers/session.ts +159 -0
  109. package/server/src/handlers/settings.ts +109 -0
  110. package/server/src/handlers/skills.ts +380 -0
  111. package/server/src/handlers/terminal.ts +70 -0
  112. package/server/src/identity.ts +26 -0
  113. package/server/src/index.ts +190 -0
  114. package/server/src/mesh/connector.ts +209 -0
  115. package/server/src/mesh/discovery.ts +123 -0
  116. package/server/src/mesh/pairing.ts +94 -0
  117. package/server/src/mesh/peers.ts +52 -0
  118. package/server/src/mesh/proxy.ts +103 -0
  119. package/server/src/mesh/session-sync.ts +107 -0
  120. package/server/src/project/context-breakdown.ts +289 -0
  121. package/server/src/project/file-browser.ts +106 -0
  122. package/server/src/project/project-files.ts +267 -0
  123. package/server/src/project/registry.ts +57 -0
  124. package/server/src/project/sdk-bridge.ts +566 -0
  125. package/server/src/project/session.ts +432 -0
  126. package/server/src/project/terminal.ts +69 -0
  127. package/server/src/tls.ts +51 -0
  128. package/server/src/ws/broadcast.ts +31 -0
  129. package/server/src/ws/router.ts +104 -0
  130. package/server/src/ws/server.ts +2 -0
  131. package/server/tsconfig.json +16 -0
  132. package/shared/package.json +11 -0
  133. package/shared/src/constants.ts +7 -0
  134. package/shared/src/index.ts +4 -0
  135. package/shared/src/messages.ts +638 -0
  136. package/shared/src/models.ts +136 -0
  137. package/shared/src/project-settings.ts +45 -0
  138. package/shared/tsconfig.json +11 -0
  139. package/themes/amoled.json +20 -0
  140. package/themes/ayu-light.json +9 -0
  141. package/themes/catppuccin-latte.json +9 -0
  142. package/themes/catppuccin-mocha.json +9 -0
  143. package/themes/clay-light.json +10 -0
  144. package/themes/clay.json +10 -0
  145. package/themes/dracula.json +9 -0
  146. package/themes/everforest-light.json +9 -0
  147. package/themes/everforest.json +9 -0
  148. package/themes/github-light.json +9 -0
  149. package/themes/gruvbox-dark.json +9 -0
  150. package/themes/gruvbox-light.json +9 -0
  151. package/themes/monokai.json +9 -0
  152. package/themes/nord-light.json +9 -0
  153. package/themes/nord.json +9 -0
  154. package/themes/one-dark.json +9 -0
  155. package/themes/one-light.json +9 -0
  156. package/themes/rose-pine-dawn.json +9 -0
  157. package/themes/rose-pine.json +9 -0
  158. package/themes/solarized-dark.json +9 -0
  159. package/themes/solarized-light.json +9 -0
  160. package/themes/tokyo-night-light.json +9 -0
  161. package/themes/tokyo-night.json +9 -0
  162. package/tsconfig.json +26 -0
@@ -0,0 +1,168 @@
1
+ import { useState, useEffect } from "react";
2
+ import { useSidebar } from "../../hooks/useSidebar";
3
+ import { useProjectSettings } from "../../hooks/useProjectSettings";
4
+ import { useProjects } from "../../hooks/useProjects";
5
+ import { useWebSocket } from "../../hooks/useWebSocket";
6
+ import {
7
+ Menu, Settings, FileText, Terminal, Plug, ScrollText, Shield,
8
+ MessageSquare, ChevronRight,
9
+ } from "lucide-react";
10
+ import type { ProjectSettingsSection } from "../../stores/sidebar";
11
+ import type { SessionSummary, ServerMessage } from "@lattice/shared";
12
+
13
+ function StatCard({ label, value, icon }: { label: string; value: string | number; icon: React.ReactNode }) {
14
+ return (
15
+ <div className="bg-base-300 rounded-xl border border-base-content/15 p-3 px-4">
16
+ <div className="flex items-center gap-2 mb-1.5">
17
+ {icon}
18
+ <span className="text-[11px] font-semibold tracking-wider uppercase text-base-content/40">{label}</span>
19
+ </div>
20
+ <div className="text-xl font-mono font-bold text-base-content">{value}</div>
21
+ </div>
22
+ );
23
+ }
24
+
25
+ function QuickLink({ label, icon, onClick }: { label: string; icon: React.ReactNode; onClick: () => void }) {
26
+ return (
27
+ <button
28
+ onClick={onClick}
29
+ className="flex items-center gap-2.5 px-3 py-2 rounded-xl border border-base-content/15 bg-base-300 text-[12px] text-base-content/60 hover:text-base-content hover:border-base-content/30 transition-colors duration-[120ms] cursor-pointer focus-visible:ring-2 focus-visible:ring-primary"
30
+ >
31
+ {icon}
32
+ <span className="flex-1 text-left">{label}</span>
33
+ <ChevronRight size={12} className="text-base-content/30" />
34
+ </button>
35
+ );
36
+ }
37
+
38
+ export function ProjectDashboardView() {
39
+ var sidebar = useSidebar();
40
+
41
+ if (sidebar.activeView.type !== "project-dashboard") {
42
+ return null;
43
+ }
44
+
45
+ var { settings, loading } = useProjectSettings(sidebar.activeProjectSlug);
46
+ var { projects } = useProjects();
47
+ var { send, subscribe, unsubscribe } = useWebSocket();
48
+ var [sessions, setSessions] = useState<SessionSummary[]>([]);
49
+
50
+ var activeProject = projects.find(function (p) { return p.slug === sidebar.activeProjectSlug; });
51
+ var projectTitle = activeProject?.title ?? sidebar.activeProjectSlug ?? "Project";
52
+
53
+ useEffect(function () {
54
+ if (!sidebar.activeProjectSlug) return;
55
+
56
+ function handleSessionList(msg: ServerMessage) {
57
+ if (msg.type !== "session:list") return;
58
+ var data = msg as { type: "session:list"; projectSlug: string; sessions: SessionSummary[] };
59
+ if (data.projectSlug === sidebar.activeProjectSlug) {
60
+ var sorted = data.sessions.slice().sort(function (a, b) { return b.updatedAt - a.updatedAt; });
61
+ setSessions(sorted);
62
+ }
63
+ }
64
+
65
+ subscribe("session:list", handleSessionList);
66
+ send({ type: "session:list_request", projectSlug: sidebar.activeProjectSlug });
67
+
68
+ return function () {
69
+ unsubscribe("session:list", handleSessionList);
70
+ };
71
+ }, [sidebar.activeProjectSlug]);
72
+
73
+ function goToSection(section: ProjectSettingsSection) {
74
+ sidebar.openProjectSettings(section);
75
+ }
76
+
77
+ function goToSession(sessionId: string) {
78
+ if (activeProject) {
79
+ sidebar.setActiveSessionId(sessionId);
80
+ }
81
+ }
82
+
83
+ var sessionCount = sessions.length;
84
+ var mcpCount = settings ? Object.keys(settings.mcpServers).length : 0;
85
+ var globalMcpCount = settings ? Object.keys(settings.global.mcpServers).length : 0;
86
+ var rulesCount = settings ? settings.rules.length : 0;
87
+ var globalRulesCount = settings ? settings.global.rules.length : 0;
88
+ var envCount = settings ? Object.keys(settings.env).length : 0;
89
+
90
+ return (
91
+ <div className="flex-1 overflow-auto px-4 sm:px-8 py-4 sm:py-6 max-w-3xl">
92
+ <div className="mb-6 flex items-center gap-3">
93
+ <button
94
+ className="btn btn-ghost btn-sm btn-square lg:hidden"
95
+ aria-label="Toggle sidebar"
96
+ onClick={sidebar.toggleDrawer}
97
+ >
98
+ <Menu size={18} />
99
+ </button>
100
+ <h1 className="text-lg font-mono font-bold text-base-content">{projectTitle}</h1>
101
+ </div>
102
+
103
+ {loading && (
104
+ <div className="text-[13px] text-base-content/40 py-4">Loading...</div>
105
+ )}
106
+
107
+ {!loading && settings && (
108
+ <div className="space-y-6">
109
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
110
+ <StatCard label="Sessions" value={sessionCount} icon={<MessageSquare size={14} className="text-primary" />} />
111
+ <StatCard label="MCP Servers" value={mcpCount + globalMcpCount} icon={<Plug size={14} className="text-accent" />} />
112
+ <StatCard label="Rules" value={rulesCount + globalRulesCount} icon={<ScrollText size={14} className="text-info" />} />
113
+ <StatCard label="Env Vars" value={envCount} icon={<Terminal size={14} className="text-success" />} />
114
+ </div>
115
+
116
+ {settings.claudeMd && (
117
+ <div>
118
+ <div className="text-[12px] font-semibold text-base-content/40 mb-2">CLAUDE.md</div>
119
+ <div className="px-3 py-2.5 bg-base-300 border border-base-content/15 rounded-xl font-mono text-[11px] text-base-content/60 leading-relaxed max-h-48 overflow-y-auto">
120
+ <pre className="whitespace-pre-wrap">{settings.claudeMd}</pre>
121
+ </div>
122
+ </div>
123
+ )}
124
+
125
+ {sessionCount > 0 && (
126
+ <div>
127
+ <div className="text-[12px] font-semibold text-base-content/40 mb-2">Recent Sessions</div>
128
+ <div className="flex flex-col gap-1.5">
129
+ {sessions.slice(0, 5).map(function (s) {
130
+ return (
131
+ <button
132
+ key={s.id}
133
+ onClick={function () { goToSession(s.id); }}
134
+ className="flex items-center gap-3 px-3 py-2 rounded-xl border border-base-content/15 bg-base-300 hover:border-base-content/30 transition-colors duration-[120ms] cursor-pointer text-left focus-visible:ring-2 focus-visible:ring-primary"
135
+ >
136
+ <MessageSquare size={12} className="text-base-content/30 flex-shrink-0" />
137
+ <span className="flex-1 text-[12px] text-base-content truncate">{s.title || "Untitled"}</span>
138
+ <span className="text-[10px] text-base-content/30 font-mono flex-shrink-0">
139
+ {new Date(s.updatedAt).toLocaleDateString()}
140
+ </span>
141
+ <ChevronRight size={12} className="text-base-content/20 flex-shrink-0" />
142
+ </button>
143
+ );
144
+ })}
145
+ </div>
146
+ </div>
147
+ )}
148
+
149
+ <div>
150
+ <div className="text-[12px] font-semibold text-base-content/40 mb-2">Settings</div>
151
+ <div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
152
+ <QuickLink label="General" icon={<Settings size={13} className="text-base-content/40" />} onClick={function () { goToSection("general"); }} />
153
+ <QuickLink label="Claude" icon={<FileText size={13} className="text-base-content/40" />} onClick={function () { goToSection("claude"); }} />
154
+ <QuickLink label="Environment" icon={<Terminal size={13} className="text-base-content/40" />} onClick={function () { goToSection("environment"); }} />
155
+ <QuickLink label="MCP Servers" icon={<Plug size={13} className="text-base-content/40" />} onClick={function () { goToSection("mcp"); }} />
156
+ <QuickLink label="Rules" icon={<ScrollText size={13} className="text-base-content/40" />} onClick={function () { goToSection("rules"); }} />
157
+ <QuickLink label="Permissions" icon={<Shield size={13} className="text-base-content/40" />} onClick={function () { goToSection("permissions"); }} />
158
+ </div>
159
+ </div>
160
+
161
+ <div className="text-[11px] text-base-content/30 font-mono truncate">
162
+ {settings.path}
163
+ </div>
164
+ </div>
165
+ )}
166
+ </div>
167
+ );
168
+ }
@@ -0,0 +1,24 @@
1
+ import type { NodeInfo } from "@lattice/shared";
2
+
3
+ interface NodeBadgeProps {
4
+ node: NodeInfo;
5
+ }
6
+
7
+ export function NodeBadge(props: NodeBadgeProps) {
8
+ var initials = props.node.name.slice(0, 2).toUpperCase();
9
+
10
+ return (
11
+ <span
12
+ title={props.node.name + (props.node.online ? " (online)" : " (offline)")}
13
+ className="inline-flex items-center gap-[3px] px-[5px] py-[1px] rounded-full bg-base-300 border border-base-content/15 text-[10px] font-semibold text-base-content/40 tracking-[0.03em] flex-shrink-0"
14
+ >
15
+ <span
16
+ className={
17
+ "w-[5px] h-[5px] rounded-full flex-shrink-0 inline-block " +
18
+ (props.node.online ? "bg-success" : "bg-base-content/30")
19
+ }
20
+ />
21
+ {initials}
22
+ </span>
23
+ );
24
+ }
@@ -0,0 +1,281 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { X, Copy, Check } from "lucide-react";
3
+ import { useWebSocket } from "../../hooks/useWebSocket";
4
+ import { useMesh } from "../../hooks/useMesh";
5
+ import { clearInvite } from "../../stores/mesh";
6
+ import type { ServerMessage } from "@lattice/shared";
7
+
8
+ type Tab = "generate" | "enter";
9
+ type PairStatus = "idle" | "connecting" | "paired" | "failed";
10
+
11
+ interface PairingDialogProps {
12
+ isOpen: boolean;
13
+ onClose: () => void;
14
+ }
15
+
16
+ export function PairingDialog(props: PairingDialogProps) {
17
+ var ws = useWebSocket();
18
+ var mesh = useMesh();
19
+ var [tab, setTab] = useState<Tab>("generate");
20
+ var [pairCode, setPairCode] = useState("");
21
+ var [pairStatus, setPairStatus] = useState<PairStatus>("idle");
22
+ var [pairError, setPairError] = useState<string | null>(null);
23
+ var [copied, setCopied] = useState(false);
24
+
25
+ var handleKeyDown = useCallback(function (e: KeyboardEvent) {
26
+ if (e.key === "Escape") {
27
+ props.onClose();
28
+ }
29
+ }, [props.onClose]);
30
+
31
+ useEffect(function () {
32
+ if (!props.isOpen) {
33
+ return;
34
+ }
35
+ document.addEventListener("keydown", handleKeyDown);
36
+ return function () {
37
+ document.removeEventListener("keydown", handleKeyDown);
38
+ };
39
+ }, [props.isOpen, handleKeyDown]);
40
+
41
+ useEffect(function () {
42
+ if (!props.isOpen) {
43
+ clearInvite();
44
+ setPairCode("");
45
+ setPairStatus("idle");
46
+ setPairError(null);
47
+ setCopied(false);
48
+ setTab("generate");
49
+ }
50
+ }, [props.isOpen]);
51
+
52
+ useEffect(function () {
53
+ if (pairStatus !== "connecting") {
54
+ return;
55
+ }
56
+
57
+ function handler(msg: ServerMessage) {
58
+ if (msg.type === "mesh:paired") {
59
+ setPairStatus("paired");
60
+ setPairError(null);
61
+ }
62
+ }
63
+
64
+ ws.subscribe("mesh:paired", handler);
65
+ return function () {
66
+ ws.unsubscribe("mesh:paired", handler);
67
+ };
68
+ }, [ws, pairStatus]);
69
+
70
+ function handleGenerateInvite() {
71
+ clearInvite();
72
+ mesh.generateInvite();
73
+ }
74
+
75
+ function handlePair() {
76
+ var trimmed = pairCode.trim();
77
+ if (!trimmed) {
78
+ return;
79
+ }
80
+ setPairStatus("connecting");
81
+ setPairError(null);
82
+
83
+ var timeout = setTimeout(function () {
84
+ setPairStatus(function (prev) {
85
+ if (prev === "connecting") {
86
+ setPairError("Pairing timed out. Check the code and try again.");
87
+ return "failed";
88
+ }
89
+ return prev;
90
+ });
91
+ }, 30000);
92
+
93
+ ws.send({ type: "mesh:pair", code: trimmed });
94
+
95
+ return function () {
96
+ clearTimeout(timeout);
97
+ };
98
+ }
99
+
100
+ function handleCopyCode() {
101
+ if (!mesh.inviteCode) {
102
+ return;
103
+ }
104
+ navigator.clipboard.writeText(mesh.inviteCode).then(function () {
105
+ setCopied(true);
106
+ setTimeout(function () { setCopied(false); }, 2000);
107
+ });
108
+ }
109
+
110
+ if (!props.isOpen) {
111
+ return null;
112
+ }
113
+
114
+ return (
115
+ <div
116
+ role="dialog"
117
+ aria-modal="true"
118
+ aria-label="Pair a node"
119
+ className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/65 backdrop-blur-sm"
120
+ onClick={props.onClose}
121
+ >
122
+ <div
123
+ className="w-[440px] max-w-[calc(100vw-24px)] rounded-xl border border-base-300 bg-base-200 overflow-hidden shadow-2xl"
124
+ onClick={function (e) { e.stopPropagation(); }}
125
+ >
126
+ <div className="flex items-center justify-between px-5 py-4 border-b border-base-300">
127
+ <div className="text-[14px] font-semibold text-base-content">Pair a Node</div>
128
+ <button
129
+ onClick={props.onClose}
130
+ aria-label="Close"
131
+ className="btn btn-ghost btn-xs btn-square text-base-content/40 hover:text-base-content"
132
+ >
133
+ <X size={14} />
134
+ </button>
135
+ </div>
136
+
137
+ <div className="flex border-b border-base-300">
138
+ {(["generate", "enter"] as Tab[]).map(function (t) {
139
+ var label = t === "generate" ? "Generate Invite" : "Enter Code";
140
+ var isActive = tab === t;
141
+ return (
142
+ <button
143
+ key={t}
144
+ onClick={function () { setTab(t); }}
145
+ className={
146
+ "flex-1 px-4 py-2.5 text-[13px] cursor-pointer transition-colors duration-[120ms] border-b-2 " +
147
+ (isActive
148
+ ? "font-semibold text-base-content border-primary"
149
+ : "font-normal text-base-content/40 border-transparent hover:text-base-content/70")
150
+ }
151
+ >
152
+ {label}
153
+ </button>
154
+ );
155
+ })}
156
+ </div>
157
+
158
+ <div className="p-5">
159
+ {tab === "generate" && (
160
+ <div>
161
+ <div className="text-[12px] text-base-content/40 mb-4 leading-relaxed">
162
+ Generate an invite code on this machine and share it with the other node.
163
+ The code encodes this node&apos;s address and a one-time auth token.
164
+ </div>
165
+
166
+ {!mesh.inviteCode && (
167
+ <button
168
+ onClick={handleGenerateInvite}
169
+ className="btn btn-primary btn-sm"
170
+ >
171
+ Generate Invite Code
172
+ </button>
173
+ )}
174
+
175
+ {mesh.inviteCode && (
176
+ <div>
177
+ <div className="flex items-center gap-2 px-3.5 py-2.5 rounded bg-base-100 border border-base-300 mb-4">
178
+ <code className="flex-1 font-mono text-[14px] font-semibold text-base-content tracking-[0.08em] break-all">
179
+ {mesh.inviteCode}
180
+ </code>
181
+ <button
182
+ onClick={handleCopyCode}
183
+ title="Copy code"
184
+ className={
185
+ "btn btn-xs gap-1 flex-shrink-0 " +
186
+ (copied ? "btn-success" : "btn-ghost border border-base-300")
187
+ }
188
+ >
189
+ {copied ? <Check size={12} /> : <Copy size={12} />}
190
+ {copied ? "Copied!" : "Copy"}
191
+ </button>
192
+ </div>
193
+
194
+ {mesh.inviteQr && (
195
+ <div className="flex justify-center mb-4">
196
+ <img
197
+ src={mesh.inviteQr}
198
+ alt="QR code for invite"
199
+ className="w-40 h-40 rounded border border-base-300"
200
+ style={{ imageRendering: "pixelated" }}
201
+ />
202
+ </div>
203
+ )}
204
+
205
+ <button
206
+ onClick={handleGenerateInvite}
207
+ className="text-[12px] text-base-content/40 underline cursor-pointer"
208
+ >
209
+ Generate new code
210
+ </button>
211
+ </div>
212
+ )}
213
+ </div>
214
+ )}
215
+
216
+ {tab === "enter" && (
217
+ <div>
218
+ <div className="text-[12px] text-base-content/40 mb-4 leading-relaxed">
219
+ Paste the invite code generated on the other node to pair with it.
220
+ </div>
221
+
222
+ <input
223
+ type="text"
224
+ value={pairCode}
225
+ onChange={function (e) {
226
+ setPairCode(e.target.value);
227
+ if (pairStatus !== "idle") {
228
+ setPairStatus("idle");
229
+ setPairError(null);
230
+ }
231
+ }}
232
+ onKeyDown={function (e) {
233
+ if (e.key === "Enter") {
234
+ handlePair();
235
+ }
236
+ }}
237
+ placeholder="LTCE-XXXX-XXXX"
238
+ disabled={pairStatus === "connecting" || pairStatus === "paired"}
239
+ className="input input-bordered w-full bg-base-100 text-base-content font-mono text-[14px] tracking-[0.06em] mb-3 focus:border-primary"
240
+ />
241
+
242
+ {pairStatus === "idle" && (
243
+ <button
244
+ onClick={handlePair}
245
+ disabled={!pairCode.trim()}
246
+ className={
247
+ "btn btn-sm " +
248
+ (pairCode.trim() ? "btn-primary" : "btn-ghost border border-base-300 cursor-not-allowed")
249
+ }
250
+ >
251
+ Pair
252
+ </button>
253
+ )}
254
+
255
+ {pairStatus === "connecting" && (
256
+ <div className="flex items-center gap-2 text-[13px] text-base-content/40">
257
+ <span
258
+ className="w-3 h-3 rounded-full border-2 border-primary border-t-transparent inline-block"
259
+ style={{ animation: "spin 0.6s linear infinite" }}
260
+ />
261
+ Connecting...
262
+ </div>
263
+ )}
264
+
265
+ {pairStatus === "paired" && (
266
+ <div className="flex items-center gap-1.5 text-[13px] font-semibold text-success">
267
+ <Check size={14} />
268
+ Paired successfully!
269
+ </div>
270
+ )}
271
+
272
+ {pairStatus === "failed" && pairError && (
273
+ <div className="text-[12px] text-error mt-2">{pairError}</div>
274
+ )}
275
+ </div>
276
+ )}
277
+ </div>
278
+ </div>
279
+ </div>
280
+ );
281
+ }