@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,180 @@
1
+ import { useState, useEffect, useRef } from "react";
2
+ import { X, Copy, Check } from "lucide-react";
3
+ import { useWebSocket } from "../../hooks/useWebSocket";
4
+ import { useMesh } from "../../hooks/useMesh";
5
+ import { useSaveState } from "../../hooks/useSaveState";
6
+ import { SaveFooter } from "../ui/SaveFooter";
7
+ import type { ServerMessage, SettingsDataMessage, LatticeConfig } from "@lattice/shared";
8
+
9
+ interface NodeSettingsModalProps {
10
+ isOpen: boolean;
11
+ onClose: () => void;
12
+ }
13
+
14
+ export function NodeSettingsModal({ isOpen, onClose }: NodeSettingsModalProps) {
15
+ var { send, subscribe, unsubscribe } = useWebSocket();
16
+ var { nodes } = useMesh();
17
+ var save = useSaveState();
18
+
19
+ var localNode = nodes.find(function (n) { return n.isLocal; });
20
+ var nodeId = localNode ? localNode.id : "";
21
+
22
+ var [config, setConfig] = useState<LatticeConfig | null>(null);
23
+ var [name, setName] = useState("");
24
+ var [port, setPort] = useState(7654);
25
+ var [tls, setTls] = useState(false);
26
+ var [debug, setDebug] = useState(false);
27
+ var [copied, setCopied] = useState(false);
28
+ var copyTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
29
+
30
+ useEffect(function () {
31
+ if (!isOpen) return;
32
+
33
+ function handleData(msg: ServerMessage) {
34
+ if (msg.type !== "settings:data") return;
35
+ var data = msg as SettingsDataMessage;
36
+ var cfg = data.config;
37
+ setConfig(cfg);
38
+
39
+ if (save.saving) {
40
+ save.confirmSave();
41
+ } else {
42
+ setName(cfg.name);
43
+ setPort(cfg.port);
44
+ setTls(cfg.tls);
45
+ setDebug(cfg.debug);
46
+ save.resetFromServer();
47
+ }
48
+ }
49
+
50
+ subscribe("settings:data", handleData);
51
+ send({ type: "settings:get" });
52
+
53
+ return function () {
54
+ unsubscribe("settings:data", handleData);
55
+ };
56
+ }, [isOpen]);
57
+
58
+ useEffect(function () {
59
+ return function () {
60
+ if (copyTimeout.current) clearTimeout(copyTimeout.current);
61
+ };
62
+ }, []);
63
+
64
+ function handleSave() {
65
+ save.startSave();
66
+ send({
67
+ type: "settings:update",
68
+ settings: { name, port, tls, debug },
69
+ } as any);
70
+ }
71
+
72
+ function handleCopyId() {
73
+ if (!nodeId) return;
74
+ navigator.clipboard.writeText(nodeId);
75
+ setCopied(true);
76
+ if (copyTimeout.current) clearTimeout(copyTimeout.current);
77
+ copyTimeout.current = setTimeout(function () { setCopied(false); }, 2000);
78
+ }
79
+
80
+ if (!isOpen) return null;
81
+
82
+ var inputClass = "w-full h-9 px-3 bg-base-300 border border-base-content/15 rounded-xl text-base-content text-[13px] focus:border-primary focus-visible:outline-none transition-colors duration-[120ms]";
83
+
84
+ return (
85
+ <div className="fixed inset-0 z-[9999] flex items-center justify-center">
86
+ <div className="absolute inset-0 bg-black/50" onClick={onClose} />
87
+ <div className="relative bg-base-200 border border-base-content/15 rounded-2xl shadow-2xl w-full max-w-md mx-4 overflow-hidden">
88
+ <div className="flex items-center justify-between px-5 py-4 border-b border-base-content/15">
89
+ <h2 className="text-[15px] font-mono font-bold text-base-content">Node Settings</h2>
90
+ <button
91
+ onClick={onClose}
92
+ aria-label="Close"
93
+ className="btn btn-ghost btn-xs btn-square text-base-content/40 hover:text-base-content"
94
+ >
95
+ <X size={16} />
96
+ </button>
97
+ </div>
98
+
99
+ <div className="px-5 py-4 space-y-4 max-h-[70vh] overflow-y-auto">
100
+ <div>
101
+ <label htmlFor="node-id" className="block text-[12px] font-semibold text-base-content/40 mb-1.5">Node ID</label>
102
+ <div className="flex items-center gap-2">
103
+ <div className="flex-1 h-9 px-3 bg-base-300/50 border border-base-content/10 rounded-xl font-mono text-[11px] text-base-content/50 flex items-center truncate select-all">
104
+ {nodeId || "Loading..."}
105
+ </div>
106
+ <button
107
+ onClick={handleCopyId}
108
+ disabled={!nodeId}
109
+ aria-label="Copy node ID"
110
+ className="btn btn-ghost btn-sm btn-square text-base-content/30 hover:text-base-content flex-shrink-0"
111
+ >
112
+ {copied ? <Check size={14} className="text-success" /> : <Copy size={14} />}
113
+ </button>
114
+ </div>
115
+ </div>
116
+
117
+ <div>
118
+ <label htmlFor="node-name" className="block text-[12px] font-semibold text-base-content/40 mb-1.5">Node Name</label>
119
+ <input
120
+ id="node-name"
121
+ type="text"
122
+ value={name}
123
+ onChange={function (e) { setName(e.target.value); save.markDirty(); }}
124
+ placeholder="My Node"
125
+ className={inputClass}
126
+ />
127
+ </div>
128
+
129
+ <div>
130
+ <label htmlFor="node-port" className="block text-[12px] font-semibold text-base-content/40 mb-1.5">Port</label>
131
+ <input
132
+ id="node-port"
133
+ type="number"
134
+ value={port}
135
+ onChange={function (e) {
136
+ var val = parseInt(e.target.value, 10);
137
+ if (!isNaN(val)) { setPort(val); save.markDirty(); }
138
+ }}
139
+ min={1}
140
+ max={65535}
141
+ className={inputClass + " font-mono"}
142
+ />
143
+ </div>
144
+
145
+ <div className="flex items-center justify-between py-1">
146
+ <div>
147
+ <div className="text-[12px] font-semibold text-base-content/40">TLS</div>
148
+ <div className="text-[11px] text-base-content/30">Encrypt connections between nodes</div>
149
+ </div>
150
+ <input
151
+ type="checkbox"
152
+ checked={tls}
153
+ onChange={function (e) { setTls(e.target.checked); save.markDirty(); }}
154
+ className="toggle toggle-sm toggle-primary"
155
+ aria-label="Enable TLS"
156
+ />
157
+ </div>
158
+
159
+ <div className="flex items-center justify-between py-1">
160
+ <div>
161
+ <div className="text-[12px] font-semibold text-base-content/40">Debug Mode</div>
162
+ <div className="text-[11px] text-base-content/30">Enable verbose logging</div>
163
+ </div>
164
+ <input
165
+ type="checkbox"
166
+ checked={debug}
167
+ onChange={function (e) { setDebug(e.target.checked); save.markDirty(); }}
168
+ className="toggle toggle-sm toggle-primary"
169
+ aria-label="Enable debug mode"
170
+ />
171
+ </div>
172
+ </div>
173
+
174
+ <div className="px-5 py-3 border-t border-base-content/15">
175
+ <SaveFooter dirty={save.dirty} saving={save.saving} saveState={save.saveState} onSave={handleSave} />
176
+ </div>
177
+ </div>
178
+ </div>
179
+ );
180
+ }
@@ -0,0 +1,43 @@
1
+ import React from "react";
2
+ import { Settings, Plug, Sparkles, Terminal } from "lucide-react";
3
+ import { PopupMenu, PopupMenuItem } from "../ui/PopupMenu";
4
+ import { useSidebar } from "../../hooks/useSidebar";
5
+
6
+ interface ProjectDropdownProps {
7
+ anchorRef: React.RefObject<HTMLElement | null>;
8
+ onClose: () => void;
9
+ }
10
+
11
+ export function ProjectDropdown(props: ProjectDropdownProps) {
12
+ var sidebar = useSidebar();
13
+
14
+ var items: PopupMenuItem[] = [
15
+ { id: "project-settings", label: "Project Settings", icon: <Settings size={14} /> },
16
+ { id: "mcp", label: "MCP Servers", icon: <Plug size={14} /> },
17
+ { id: "skills", label: "Skills", icon: <Sparkles size={14} /> },
18
+ { id: "environment", label: "Environment", icon: <Terminal size={14} /> },
19
+ ];
20
+
21
+ function handleSelect(id: string) {
22
+ if (id === "project-settings") {
23
+ sidebar.openProjectSettings("general");
24
+ } else if (id === "mcp") {
25
+ sidebar.openProjectSettings("mcp");
26
+ } else if (id === "skills") {
27
+ sidebar.openProjectSettings("skills");
28
+ } else if (id === "environment") {
29
+ sidebar.openProjectSettings("environment");
30
+ }
31
+ props.onClose();
32
+ }
33
+
34
+ return (
35
+ <PopupMenu
36
+ items={items}
37
+ onSelect={handleSelect}
38
+ onClose={props.onClose}
39
+ anchorRef={props.anchorRef}
40
+ position="below"
41
+ />
42
+ );
43
+ }
@@ -0,0 +1,291 @@
1
+ import { useState, useEffect, useRef } from "react";
2
+ import { Plus } from "lucide-react";
3
+ import type { ProjectInfo, NodeInfo } from "@lattice/shared";
4
+ import { LatticeLogomark } from "../ui/LatticeLogomark";
5
+ import { useWebSocket } from "../../hooks/useWebSocket";
6
+ import { useSidebar } from "../../hooks/useSidebar";
7
+
8
+ function getProjectInitials(title: string): string {
9
+ var words = title.trim().split(/[\s\-_]+/);
10
+ if (words.length >= 3) {
11
+ return (words[0][0] + words[1][0] + words[2][0]).toUpperCase();
12
+ }
13
+ if (words.length === 2) {
14
+ return (words[0][0] + words[1][0]).toUpperCase();
15
+ }
16
+ return title.slice(0, 3).toUpperCase();
17
+ }
18
+
19
+ interface ProjectGroup {
20
+ slug: string;
21
+ title: string;
22
+ nodes: Array<{ nodeId: string; nodeName: string; online: boolean; path: string }>;
23
+ }
24
+
25
+ function groupProjectsBySlug(projects: ProjectInfo[], nodes: NodeInfo[]): ProjectGroup[] {
26
+ var groups = new Map<string, ProjectGroup>();
27
+ for (var i = 0; i < projects.length; i++) {
28
+ var p = projects[i];
29
+ var existing = groups.get(p.slug);
30
+ var node = nodes.find(function (n) { return n.id === p.nodeId; });
31
+ var nodeEntry = {
32
+ nodeId: p.nodeId,
33
+ nodeName: p.nodeName,
34
+ online: node ? node.online : false,
35
+ path: p.path,
36
+ };
37
+ if (existing) {
38
+ existing.nodes.push(nodeEntry);
39
+ } else {
40
+ groups.set(p.slug, { slug: p.slug, title: p.title, nodes: [nodeEntry] });
41
+ }
42
+ }
43
+ return Array.from(groups.values());
44
+ }
45
+
46
+ interface ContextMenuState {
47
+ visible: boolean;
48
+ x: number;
49
+ y: number;
50
+ slug: string | null;
51
+ }
52
+
53
+ interface ProjectButtonProps {
54
+ group: ProjectGroup;
55
+ isActive: boolean;
56
+ onClick: () => void;
57
+ onContextMenu: (e: React.MouseEvent, slug: string) => void;
58
+ }
59
+
60
+ function ProjectButton(props: ProjectButtonProps) {
61
+ var [hovered, setHovered] = useState(false);
62
+ var [tooltipTop, setTooltipTop] = useState(0);
63
+ var initials = getProjectInitials(props.group.title);
64
+
65
+ return (
66
+ <div className="relative flex items-center">
67
+ {props.isActive && (
68
+ <div
69
+ className="absolute -left-3 w-[3px] bg-base-content rounded-r-full pointer-events-none"
70
+ style={{ height: "32px", top: "50%", transform: "translateY(-50%)" }}
71
+ />
72
+ )}
73
+
74
+ <button
75
+ onClick={props.onClick}
76
+ onContextMenu={function (e) {
77
+ e.preventDefault();
78
+ props.onContextMenu(e, props.group.slug);
79
+ }}
80
+ onMouseEnter={function (e) {
81
+ var rect = e.currentTarget.getBoundingClientRect();
82
+ setTooltipTop(rect.top + rect.height / 2);
83
+ setHovered(true);
84
+ }}
85
+ onMouseLeave={function () { setHovered(false); }}
86
+ className={
87
+ "w-[42px] h-[42px] flex items-center justify-center text-[11px] font-bold tracking-[0.03em] cursor-pointer transition-all duration-[120ms] flex-shrink-0 " +
88
+ (props.isActive
89
+ ? "rounded-xl bg-primary text-primary-content"
90
+ : hovered
91
+ ? "rounded-xl bg-base-200 text-base-content/60"
92
+ : "rounded-full bg-base-200 text-base-content/60")
93
+ }
94
+ >
95
+ {initials}
96
+ </button>
97
+
98
+ <div className="absolute bottom-0 right-0 flex gap-[2px] pointer-events-none">
99
+ {props.group.nodes.map(function (n) {
100
+ return (
101
+ <div
102
+ key={n.nodeId}
103
+ className={
104
+ "w-[11px] h-[11px] rounded-full border-[1.5px] border-base-100 " +
105
+ (n.online ? "bg-success" : "bg-error")
106
+ }
107
+ />
108
+ );
109
+ })}
110
+ </div>
111
+
112
+ {hovered && (
113
+ <div
114
+ className="pointer-events-none z-[9000] bg-base-300 border border-base-content/20 rounded px-2 py-1 text-xs text-base-content whitespace-nowrap shadow-xl"
115
+ style={{
116
+ position: "fixed",
117
+ left: "calc(64px + 8px)",
118
+ top: tooltipTop + "px",
119
+ transform: "translateY(-50%)",
120
+ }}
121
+ >
122
+ {props.group.title}
123
+ </div>
124
+ )}
125
+ </div>
126
+ );
127
+ }
128
+
129
+ interface ProjectRailProps {
130
+ projects: ProjectInfo[];
131
+ nodes: NodeInfo[];
132
+ activeProjectSlug: string | null;
133
+ onSelectProject: (slug: string) => void;
134
+ onDashboardClick: () => void;
135
+ isDashboardActive: boolean;
136
+ dimmed?: boolean;
137
+ }
138
+
139
+ export function ProjectRail(props: ProjectRailProps) {
140
+ var ws = useWebSocket();
141
+ var sidebar = useSidebar();
142
+ var groups = groupProjectsBySlug(props.projects, props.nodes);
143
+ var [contextMenu, setContextMenu] = useState<ContextMenuState>({
144
+ visible: false,
145
+ x: 0,
146
+ y: 0,
147
+ slug: null,
148
+ });
149
+ var menuRef = useRef<HTMLDivElement>(null);
150
+
151
+ useEffect(
152
+ function () {
153
+ if (!contextMenu.visible) return;
154
+
155
+ function handleClick() {
156
+ setContextMenu(function (prev) { return { ...prev, visible: false }; });
157
+ }
158
+
159
+ function handleKeyDown(e: KeyboardEvent) {
160
+ if (e.key === "Escape") {
161
+ setContextMenu(function (prev) { return { ...prev, visible: false }; });
162
+ }
163
+ }
164
+
165
+ function handleScroll() {
166
+ setContextMenu(function (prev) { return { ...prev, visible: false }; });
167
+ }
168
+
169
+ window.addEventListener("click", handleClick);
170
+ window.addEventListener("keydown", handleKeyDown);
171
+ window.addEventListener("scroll", handleScroll, true);
172
+
173
+ return function () {
174
+ window.removeEventListener("click", handleClick);
175
+ window.removeEventListener("keydown", handleKeyDown);
176
+ window.removeEventListener("scroll", handleScroll, true);
177
+ };
178
+ },
179
+ [contextMenu.visible]
180
+ );
181
+
182
+ function handleContextMenu(e: React.MouseEvent, slug: string) {
183
+ setContextMenu({ visible: true, x: e.clientX, y: e.clientY, slug: slug });
184
+ }
185
+
186
+ return (
187
+ <div
188
+ className={
189
+ "w-16 flex-shrink-0 flex flex-col items-center pt-3 pb-16 gap-2 bg-base-100 border-r border-base-300 overflow-y-auto overflow-x-hidden scrollbar-hidden " +
190
+ (props.dimmed ? "opacity-60" : "")
191
+ }
192
+ >
193
+ <div className="relative flex items-center">
194
+ {props.isDashboardActive && (
195
+ <div
196
+ className="absolute -left-3 w-[3px] bg-base-content rounded-r-full pointer-events-none"
197
+ style={{ height: "32px", top: "50%", transform: "translateY(-50%)" }}
198
+ />
199
+ )}
200
+ <button
201
+ onClick={props.onDashboardClick}
202
+ className={
203
+ "w-[42px] h-[42px] flex items-center justify-center cursor-pointer transition-all duration-[120ms] flex-shrink-0 " +
204
+ (props.isDashboardActive
205
+ ? "rounded-xl bg-primary text-primary-content"
206
+ : "rounded-full bg-base-200 text-base-content/60 hover:rounded-xl hover:bg-primary/20 hover:text-primary")
207
+ }
208
+ title="Lattice Dashboard"
209
+ >
210
+ <LatticeLogomark size={22} />
211
+ </button>
212
+ </div>
213
+
214
+ <div className="w-6 h-px bg-base-300 my-0.5 flex-shrink-0" />
215
+
216
+ {groups.map(function (group) {
217
+ return (
218
+ <ProjectButton
219
+ key={group.slug}
220
+ group={group}
221
+ isActive={props.activeProjectSlug === group.slug}
222
+ onClick={function () { props.onSelectProject(group.slug); }}
223
+ onContextMenu={handleContextMenu}
224
+ />
225
+ );
226
+ })}
227
+
228
+ {groups.length > 0 && (
229
+ <div className="w-6 h-px bg-base-300 my-0.5 flex-shrink-0" />
230
+ )}
231
+
232
+ <button
233
+ onClick={function () {}}
234
+ className="w-[42px] h-[42px] flex items-center justify-center rounded-full border-2 border-dashed border-base-content/25 text-base-content/20 transition-colors duration-[120ms] flex-shrink-0 cursor-not-allowed opacity-60"
235
+ title="Add project (coming soon)"
236
+ disabled
237
+ >
238
+ <Plus size={18} />
239
+ </button>
240
+
241
+ <div className="flex-1" />
242
+
243
+ {contextMenu.visible && (
244
+ <div
245
+ ref={menuRef}
246
+ role="menu"
247
+ aria-label="Project actions"
248
+ onClick={function (e) { e.stopPropagation(); }}
249
+ className="fixed z-[9999] bg-base-300 border border-base-content/20 rounded-lg shadow-2xl py-1 min-w-[160px]"
250
+ style={{ left: contextMenu.x + "px", top: contextMenu.y + "px" }}
251
+ >
252
+ <button
253
+ role="menuitem"
254
+ className="w-full text-left px-3 py-1.5 text-sm text-base-content hover:bg-base-content/10 transition-colors"
255
+ onClick={function () {
256
+ if (contextMenu.slug) {
257
+ sidebar.setActiveProjectSlug(contextMenu.slug);
258
+ sidebar.openProjectSettings("general");
259
+ }
260
+ setContextMenu(function (prev) { return { ...prev, visible: false }; });
261
+ }}
262
+ >
263
+ Project Settings
264
+ </button>
265
+ <button
266
+ role="menuitem"
267
+ className="w-full text-left px-3 py-1.5 text-sm text-base-content hover:bg-base-content/10 transition-colors"
268
+ onClick={function () {
269
+ if (contextMenu.slug) {
270
+ ws.send({ type: "session:create", projectSlug: contextMenu.slug });
271
+ }
272
+ setContextMenu(function (prev) { return { ...prev, visible: false }; });
273
+ }}
274
+ >
275
+ New Session
276
+ </button>
277
+ <div className="my-1 h-px bg-base-content/10" />
278
+ <button
279
+ role="menuitem"
280
+ className="w-full text-left px-3 py-1.5 text-sm text-error hover:bg-error/10 transition-colors"
281
+ onClick={function () {
282
+ setContextMenu(function (prev) { return { ...prev, visible: false }; });
283
+ }}
284
+ >
285
+ Remove Project
286
+ </button>
287
+ </div>
288
+ )}
289
+ </div>
290
+ );
291
+ }
@@ -0,0 +1,52 @@
1
+ import { useRef, useEffect } from "react";
2
+ import { Search, X } from "lucide-react";
3
+
4
+ interface SearchFilterProps {
5
+ value: string;
6
+ onChange: (value: string) => void;
7
+ onClose: () => void;
8
+ placeholder?: string;
9
+ }
10
+
11
+ export function SearchFilter(props: SearchFilterProps) {
12
+ var inputRef = useRef<HTMLInputElement>(null);
13
+
14
+ useEffect(function () {
15
+ if (inputRef.current) {
16
+ inputRef.current.focus();
17
+ }
18
+ }, []);
19
+
20
+ function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
21
+ if (e.key === "Escape") {
22
+ props.onClose();
23
+ }
24
+ }
25
+
26
+ return (
27
+ <div className="px-2 pb-1.5 flex-shrink-0">
28
+ <div className="flex items-center gap-1.5 bg-base-300 border border-base-content/15 rounded-md px-2 h-7 focus-within:border-primary transition-colors duration-[120ms]">
29
+ <Search size={12} className="text-base-content/30 flex-shrink-0" />
30
+ <input
31
+ ref={inputRef}
32
+ type="text"
33
+ value={props.value}
34
+ onChange={function (e) { props.onChange(e.target.value); }}
35
+ onKeyDown={handleKeyDown}
36
+ placeholder={props.placeholder || "Search..."}
37
+ className="flex-1 bg-transparent text-base-content text-[13px] outline-none min-w-0"
38
+ spellCheck={false}
39
+ />
40
+ {props.value.length > 0 && (
41
+ <button
42
+ onClick={function () { props.onChange(""); }}
43
+ aria-label="Clear search"
44
+ className="text-base-content/30 hover:text-base-content flex-shrink-0"
45
+ >
46
+ <X size={11} />
47
+ </button>
48
+ )}
49
+ </div>
50
+ </div>
51
+ );
52
+ }