@cryptiklemur/lattice 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bun.lock +705 -2
- package/client/index.html +1 -13
- package/client/package.json +6 -1
- package/client/src/App.tsx +2 -0
- package/client/src/commands.ts +36 -0
- package/client/src/components/chat/AttachmentChips.tsx +116 -0
- package/client/src/components/chat/ChatInput.tsx +250 -73
- package/client/src/components/chat/ChatView.tsx +242 -10
- package/client/src/components/chat/CommandPalette.tsx +162 -0
- package/client/src/components/chat/Message.tsx +23 -2
- package/client/src/components/chat/PromptQuestion.tsx +271 -0
- package/client/src/components/chat/TodoCard.tsx +57 -0
- package/client/src/components/chat/ToolResultRenderer.tsx +2 -1
- package/client/src/components/chat/VoiceRecorder.tsx +85 -0
- package/client/src/components/project-settings/ProjectMemory.tsx +12 -2
- package/client/src/components/project-settings/ProjectNotifications.tsx +48 -0
- package/client/src/components/project-settings/ProjectRules.tsx +10 -1
- package/client/src/components/project-settings/ProjectSettingsView.tsx +6 -0
- package/client/src/components/settings/Appearance.tsx +1 -0
- package/client/src/components/settings/ClaudeSettings.tsx +10 -0
- package/client/src/components/settings/Editor.tsx +123 -0
- package/client/src/components/settings/GlobalMcp.tsx +10 -1
- package/client/src/components/settings/GlobalMemory.tsx +19 -0
- package/client/src/components/settings/GlobalRules.tsx +149 -0
- package/client/src/components/settings/GlobalSkills.tsx +10 -0
- package/client/src/components/settings/Notifications.tsx +88 -0
- package/client/src/components/settings/SettingsView.tsx +12 -0
- package/client/src/components/settings/skill-shared.tsx +2 -1
- package/client/src/components/setup/SetupWizard.tsx +1 -1
- package/client/src/components/sidebar/NodeSettingsModal.tsx +23 -1
- package/client/src/components/sidebar/ProjectDropdown.tsx +176 -27
- package/client/src/components/sidebar/SettingsSidebar.tsx +11 -1
- package/client/src/components/sidebar/Sidebar.tsx +35 -2
- package/client/src/components/sidebar/UserIsland.tsx +18 -7
- package/client/src/components/ui/UpdatePrompt.tsx +47 -0
- package/client/src/components/workspace/FileBrowser.tsx +174 -0
- package/client/src/components/workspace/FileTree.tsx +129 -0
- package/client/src/components/workspace/FileViewer.tsx +211 -0
- package/client/src/components/workspace/NoteCard.tsx +119 -0
- package/client/src/components/workspace/NotesView.tsx +102 -0
- package/client/src/components/workspace/ScheduledTasksView.tsx +117 -0
- package/client/src/components/workspace/SplitPane.tsx +81 -0
- package/client/src/components/workspace/TabBar.tsx +185 -0
- package/client/src/components/workspace/TaskCard.tsx +158 -0
- package/client/src/components/workspace/TaskEditModal.tsx +114 -0
- package/client/src/components/{panels/Terminal.tsx → workspace/TerminalInstance.tsx} +50 -7
- package/client/src/components/workspace/TerminalView.tsx +110 -0
- package/client/src/components/workspace/WorkspaceView.tsx +116 -0
- package/client/src/hooks/useAttachments.ts +280 -0
- package/client/src/hooks/useEditorConfig.ts +28 -0
- package/client/src/hooks/useIdleDetection.ts +44 -0
- package/client/src/hooks/useInstallPrompt.ts +53 -0
- package/client/src/hooks/useNotifications.ts +54 -0
- package/client/src/hooks/useOnline.ts +6 -0
- package/client/src/hooks/useSession.ts +110 -4
- package/client/src/hooks/useSpinnerVerb.ts +36 -0
- package/client/src/hooks/useSwipeDrawer.ts +275 -0
- package/client/src/hooks/useVoiceRecorder.ts +123 -0
- package/client/src/hooks/useWorkspace.ts +48 -0
- package/client/src/providers/WebSocketProvider.tsx +18 -0
- package/client/src/router.tsx +48 -20
- package/client/src/stores/session.ts +136 -0
- package/client/src/stores/sidebar.ts +3 -2
- package/client/src/stores/workspace.ts +254 -0
- package/client/src/styles/global.css +123 -0
- package/client/src/utils/editorUrl.ts +62 -0
- package/client/vite.config.ts +53 -1
- package/package.json +1 -1
- package/server/src/daemon.ts +11 -1
- package/server/src/features/scheduler.ts +23 -0
- package/server/src/features/sticky-notes.ts +5 -3
- package/server/src/handlers/attachment.ts +172 -0
- package/server/src/handlers/chat.ts +43 -2
- package/server/src/handlers/editor.ts +40 -0
- package/server/src/handlers/fs.ts +10 -2
- package/server/src/handlers/memory.ts +3 -0
- package/server/src/handlers/notes.ts +4 -2
- package/server/src/handlers/scheduler.ts +18 -1
- package/server/src/handlers/session.ts +14 -8
- package/server/src/handlers/settings.ts +37 -2
- package/server/src/handlers/terminal.ts +13 -6
- package/server/src/project/pty-worker.cjs +83 -0
- package/server/src/project/sdk-bridge.ts +266 -11
- package/server/src/project/terminal.ts +78 -34
- package/shared/src/messages.ts +145 -4
- package/shared/src/models.ts +27 -1
- package/shared/src/project-settings.ts +1 -1
- package/tp.js +19 -0
- package/client/public/manifest.json +0 -24
- package/client/public/sw.js +0 -61
- package/client/src/components/panels/FileBrowser.tsx +0 -241
- package/client/src/components/panels/StickyNotes.tsx +0 -187
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { ChevronRight, FileIcon } from "lucide-react";
|
|
2
|
+
import type { FileEntry } from "@lattice/shared";
|
|
3
|
+
|
|
4
|
+
export interface TreeNode {
|
|
5
|
+
entry: FileEntry;
|
|
6
|
+
children: TreeNode[] | null;
|
|
7
|
+
expanded: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function buildNodes(entries: FileEntry[]): TreeNode[] {
|
|
11
|
+
return entries.map(function (entry) {
|
|
12
|
+
return { entry, children: null, expanded: false };
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface FileTreeItemProps {
|
|
17
|
+
node: TreeNode;
|
|
18
|
+
depth: number;
|
|
19
|
+
selectedPath: string | null;
|
|
20
|
+
onToggle: (path: string) => void;
|
|
21
|
+
onSelect: (path: string) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function FileTreeItem(props: FileTreeItemProps) {
|
|
25
|
+
var { node, depth, selectedPath, onToggle, onSelect } = props;
|
|
26
|
+
var isSelected = selectedPath === node.entry.path;
|
|
27
|
+
var isDir = node.entry.isDirectory;
|
|
28
|
+
|
|
29
|
+
function handleActivate() {
|
|
30
|
+
if (isDir) {
|
|
31
|
+
onToggle(node.entry.path);
|
|
32
|
+
} else {
|
|
33
|
+
onSelect(node.entry.path);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function handleKeyDown(e: React.KeyboardEvent) {
|
|
38
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
39
|
+
e.preventDefault();
|
|
40
|
+
handleActivate();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div role="treeitem" aria-expanded={isDir ? node.expanded : undefined} aria-selected={isSelected}>
|
|
46
|
+
<div
|
|
47
|
+
tabIndex={0}
|
|
48
|
+
onClick={handleActivate}
|
|
49
|
+
onKeyDown={handleKeyDown}
|
|
50
|
+
className={
|
|
51
|
+
"flex items-center gap-1.5 py-[3px] pr-2 cursor-pointer text-[13px] rounded select-none outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-1 focus-visible:ring-offset-base-200 " +
|
|
52
|
+
(isSelected ? "bg-base-300 text-primary" : "text-base-content hover:bg-base-200")
|
|
53
|
+
}
|
|
54
|
+
style={{ paddingLeft: (8 + depth * 16) + "px" }}
|
|
55
|
+
>
|
|
56
|
+
{isDir ? (
|
|
57
|
+
<ChevronRight
|
|
58
|
+
size={12}
|
|
59
|
+
className="flex-shrink-0 text-base-content/40 transition-transform duration-[120ms]"
|
|
60
|
+
style={{ transform: node.expanded ? "rotate(90deg)" : "none" }}
|
|
61
|
+
/>
|
|
62
|
+
) : (
|
|
63
|
+
<FileIcon
|
|
64
|
+
size={12}
|
|
65
|
+
className="flex-shrink-0 text-base-content/40"
|
|
66
|
+
/>
|
|
67
|
+
)}
|
|
68
|
+
<span
|
|
69
|
+
className={
|
|
70
|
+
"truncate " +
|
|
71
|
+
(isDir ? "text-info" : "text-base-content")
|
|
72
|
+
}
|
|
73
|
+
>
|
|
74
|
+
{node.entry.name}
|
|
75
|
+
</span>
|
|
76
|
+
</div>
|
|
77
|
+
{isDir && node.expanded && node.children && (
|
|
78
|
+
<div role="group">
|
|
79
|
+
{node.children.map(function (child) {
|
|
80
|
+
return (
|
|
81
|
+
<FileTreeItem
|
|
82
|
+
key={child.entry.path}
|
|
83
|
+
node={child}
|
|
84
|
+
depth={depth + 1}
|
|
85
|
+
selectedPath={selectedPath}
|
|
86
|
+
onToggle={onToggle}
|
|
87
|
+
onSelect={onSelect}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
})}
|
|
91
|
+
</div>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface FileTreeProps {
|
|
98
|
+
nodes: TreeNode[];
|
|
99
|
+
selectedPath: string | null;
|
|
100
|
+
onToggle: (path: string) => void;
|
|
101
|
+
onSelect: (path: string) => void;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function FileTree(props: FileTreeProps) {
|
|
105
|
+
var { nodes, selectedPath, onToggle, onSelect } = props;
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div role="tree" aria-label="File tree">
|
|
109
|
+
{nodes.length === 0 ? (
|
|
110
|
+
<div className="px-2 py-3 text-[12px] text-base-content/40">
|
|
111
|
+
Loading...
|
|
112
|
+
</div>
|
|
113
|
+
) : (
|
|
114
|
+
nodes.map(function (node) {
|
|
115
|
+
return (
|
|
116
|
+
<FileTreeItem
|
|
117
|
+
key={node.entry.path}
|
|
118
|
+
node={node}
|
|
119
|
+
depth={0}
|
|
120
|
+
selectedPath={selectedPath}
|
|
121
|
+
onToggle={onToggle}
|
|
122
|
+
onSelect={onSelect}
|
|
123
|
+
/>
|
|
124
|
+
);
|
|
125
|
+
})
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Copy, Check, ExternalLink, FileCode, Eye } from "lucide-react";
|
|
3
|
+
import ReactMarkdown from "react-markdown";
|
|
4
|
+
import type { HighlighterCore } from "shiki";
|
|
5
|
+
|
|
6
|
+
var highlighterPromise: Promise<HighlighterCore> | null = null;
|
|
7
|
+
var loadedLanguages = new Set<string>();
|
|
8
|
+
|
|
9
|
+
function getHighlighter(): Promise<HighlighterCore> {
|
|
10
|
+
if (!highlighterPromise) {
|
|
11
|
+
highlighterPromise = import("shiki").then(function (shiki) {
|
|
12
|
+
return shiki.createHighlighter({
|
|
13
|
+
themes: ["css-variables"],
|
|
14
|
+
langs: [],
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return highlighterPromise;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function highlightCode(code: string, lang: string): Promise<string> {
|
|
22
|
+
var highlighter = await getHighlighter();
|
|
23
|
+
var resolvedLang = lang === "text" ? "text" : lang;
|
|
24
|
+
|
|
25
|
+
if (resolvedLang !== "text" && !loadedLanguages.has(resolvedLang)) {
|
|
26
|
+
try {
|
|
27
|
+
await highlighter.loadLanguage(resolvedLang as any);
|
|
28
|
+
loadedLanguages.add(resolvedLang);
|
|
29
|
+
} catch {
|
|
30
|
+
resolvedLang = "text";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return highlighter.codeToHtml(code, {
|
|
35
|
+
lang: resolvedLang,
|
|
36
|
+
theme: "css-variables",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
var EXT_TO_LANG: Record<string, string> = {
|
|
41
|
+
ts: "typescript",
|
|
42
|
+
tsx: "tsx",
|
|
43
|
+
js: "javascript",
|
|
44
|
+
jsx: "jsx",
|
|
45
|
+
json: "json",
|
|
46
|
+
md: "markdown",
|
|
47
|
+
css: "css",
|
|
48
|
+
scss: "scss",
|
|
49
|
+
html: "html",
|
|
50
|
+
xml: "xml",
|
|
51
|
+
yaml: "yaml",
|
|
52
|
+
yml: "yaml",
|
|
53
|
+
toml: "toml",
|
|
54
|
+
py: "python",
|
|
55
|
+
rs: "rust",
|
|
56
|
+
go: "go",
|
|
57
|
+
sh: "bash",
|
|
58
|
+
bash: "bash",
|
|
59
|
+
zsh: "bash",
|
|
60
|
+
sql: "sql",
|
|
61
|
+
graphql: "graphql",
|
|
62
|
+
dockerfile: "dockerfile",
|
|
63
|
+
makefile: "makefile",
|
|
64
|
+
vue: "vue",
|
|
65
|
+
svelte: "svelte",
|
|
66
|
+
lua: "lua",
|
|
67
|
+
rb: "ruby",
|
|
68
|
+
java: "java",
|
|
69
|
+
kt: "kotlin",
|
|
70
|
+
swift: "swift",
|
|
71
|
+
c: "c",
|
|
72
|
+
cpp: "cpp",
|
|
73
|
+
h: "c",
|
|
74
|
+
hpp: "cpp",
|
|
75
|
+
cs: "csharp",
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function getLanguage(path: string): string {
|
|
79
|
+
var filename = path.split("/").pop() || "";
|
|
80
|
+
var lower = filename.toLowerCase();
|
|
81
|
+
if (lower === "dockerfile") return "dockerfile";
|
|
82
|
+
if (lower === "makefile") return "makefile";
|
|
83
|
+
var ext = filename.split(".").pop()?.toLowerCase() || "";
|
|
84
|
+
return EXT_TO_LANG[ext] || "text";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isMarkdown(path: string): boolean {
|
|
88
|
+
return path.toLowerCase().endsWith(".md");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface FileViewerProps {
|
|
92
|
+
path: string;
|
|
93
|
+
content: string;
|
|
94
|
+
editorUrl: string | null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function FileViewer(props: FileViewerProps) {
|
|
98
|
+
var { path, content, editorUrl } = props;
|
|
99
|
+
var [highlightedHtml, setHighlightedHtml] = useState<string | null>(null);
|
|
100
|
+
var [copied, setCopied] = useState(false);
|
|
101
|
+
var [showRendered, setShowRendered] = useState(true);
|
|
102
|
+
var copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
103
|
+
|
|
104
|
+
var language = getLanguage(path);
|
|
105
|
+
var lineCount = content.split("\n").length;
|
|
106
|
+
var isMd = isMarkdown(path);
|
|
107
|
+
var filename = path.split("/").pop() || path;
|
|
108
|
+
|
|
109
|
+
useEffect(function () {
|
|
110
|
+
setHighlightedHtml(null);
|
|
111
|
+
if (isMd && showRendered) return;
|
|
112
|
+
|
|
113
|
+
var cancelled = false;
|
|
114
|
+
highlightCode(content, language).then(function (html) {
|
|
115
|
+
if (cancelled) return;
|
|
116
|
+
setHighlightedHtml(html);
|
|
117
|
+
}).catch(function () {
|
|
118
|
+
// fallback to plain text
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return function () {
|
|
122
|
+
cancelled = true;
|
|
123
|
+
};
|
|
124
|
+
}, [content, language, isMd, showRendered]);
|
|
125
|
+
|
|
126
|
+
function handleCopy() {
|
|
127
|
+
navigator.clipboard.writeText(content);
|
|
128
|
+
setCopied(true);
|
|
129
|
+
if (copyTimeoutRef.current) {
|
|
130
|
+
clearTimeout(copyTimeoutRef.current);
|
|
131
|
+
}
|
|
132
|
+
copyTimeoutRef.current = setTimeout(function () {
|
|
133
|
+
setCopied(false);
|
|
134
|
+
}, 2000);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div className="flex flex-col h-full overflow-hidden">
|
|
139
|
+
<div className="h-9 flex-shrink-0 flex items-center gap-2 px-4 border-b border-base-content/15 bg-base-200">
|
|
140
|
+
<FileCode size={13} className="text-base-content/40 flex-shrink-0" />
|
|
141
|
+
<span className="text-[12px] font-mono text-base-content/60 truncate">{filename}</span>
|
|
142
|
+
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-base-300 text-base-content/40 uppercase flex-shrink-0">
|
|
143
|
+
{language}
|
|
144
|
+
</span>
|
|
145
|
+
<span className="text-[10px] font-mono text-base-content/30 flex-shrink-0">
|
|
146
|
+
{lineCount} lines
|
|
147
|
+
</span>
|
|
148
|
+
<div className="flex-1" />
|
|
149
|
+
{isMd && (
|
|
150
|
+
<button
|
|
151
|
+
onClick={function () { setShowRendered(!showRendered); }}
|
|
152
|
+
className={
|
|
153
|
+
"btn btn-ghost btn-xs gap-1 text-[11px] " +
|
|
154
|
+
(showRendered ? "text-primary" : "text-base-content/50")
|
|
155
|
+
}
|
|
156
|
+
title={showRendered ? "Show source" : "Show rendered"}
|
|
157
|
+
>
|
|
158
|
+
<Eye size={13} />
|
|
159
|
+
{showRendered ? "Source" : "Rendered"}
|
|
160
|
+
</button>
|
|
161
|
+
)}
|
|
162
|
+
<button
|
|
163
|
+
onClick={handleCopy}
|
|
164
|
+
className="btn btn-ghost btn-xs text-base-content/50 hover:text-base-content"
|
|
165
|
+
title="Copy to clipboard"
|
|
166
|
+
>
|
|
167
|
+
{copied ? <Check size={13} className="text-success" /> : <Copy size={13} />}
|
|
168
|
+
</button>
|
|
169
|
+
{editorUrl && (
|
|
170
|
+
<a
|
|
171
|
+
href={editorUrl}
|
|
172
|
+
className="btn btn-ghost btn-xs text-base-content/50 hover:text-base-content"
|
|
173
|
+
title="Open in editor"
|
|
174
|
+
>
|
|
175
|
+
<ExternalLink size={13} />
|
|
176
|
+
</a>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<div className="flex-1 overflow-auto">
|
|
181
|
+
{isMd && showRendered ? (
|
|
182
|
+
<div className="prose prose-sm max-w-none p-4">
|
|
183
|
+
<ReactMarkdown>{content}</ReactMarkdown>
|
|
184
|
+
</div>
|
|
185
|
+
) : highlightedHtml ? (
|
|
186
|
+
<div
|
|
187
|
+
className="p-4 text-[13px] leading-relaxed [&_pre]:!bg-transparent [&_pre]:!m-0 [&_pre]:!p-0 [&_code]:!bg-transparent"
|
|
188
|
+
style={{
|
|
189
|
+
"--shiki-foreground": "var(--base05)",
|
|
190
|
+
"--shiki-background": "transparent",
|
|
191
|
+
"--shiki-token-constant": "var(--base09)",
|
|
192
|
+
"--shiki-token-string": "var(--base0B)",
|
|
193
|
+
"--shiki-token-comment": "var(--base03)",
|
|
194
|
+
"--shiki-token-keyword": "var(--base0E)",
|
|
195
|
+
"--shiki-token-parameter": "var(--base08)",
|
|
196
|
+
"--shiki-token-function": "var(--base0D)",
|
|
197
|
+
"--shiki-token-string-expression": "var(--base0B)",
|
|
198
|
+
"--shiki-token-punctuation": "var(--base05)",
|
|
199
|
+
"--shiki-token-link": "var(--base0D)",
|
|
200
|
+
} as React.CSSProperties}
|
|
201
|
+
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
|
202
|
+
/>
|
|
203
|
+
) : (
|
|
204
|
+
<pre className="m-0 p-4 text-[13px] font-mono text-base-content leading-relaxed whitespace-pre-wrap break-words">
|
|
205
|
+
{content}
|
|
206
|
+
</pre>
|
|
207
|
+
)}
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { useState, useRef } from "react";
|
|
2
|
+
import { Trash2 } from "lucide-react";
|
|
3
|
+
import type { StickyNote } from "@lattice/shared";
|
|
4
|
+
|
|
5
|
+
interface NoteCardProps {
|
|
6
|
+
note: StickyNote;
|
|
7
|
+
onUpdate: (id: string, content: string) => void;
|
|
8
|
+
onDelete: (id: string) => void;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function NoteCard(props: NoteCardProps) {
|
|
13
|
+
var { note, onUpdate, onDelete, disabled } = props;
|
|
14
|
+
var [editing, setEditing] = useState(!note.content);
|
|
15
|
+
var [draft, setDraft] = useState(note.content);
|
|
16
|
+
var [confirming, setConfirming] = useState(false);
|
|
17
|
+
var originalRef = useRef(note.content);
|
|
18
|
+
|
|
19
|
+
function handleClick() {
|
|
20
|
+
if (disabled) return;
|
|
21
|
+
if (!editing) {
|
|
22
|
+
originalRef.current = note.content;
|
|
23
|
+
setDraft(note.content);
|
|
24
|
+
setEditing(true);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function handleBlur() {
|
|
29
|
+
if (editing) {
|
|
30
|
+
if (draft.trim() && draft.trim() !== originalRef.current) {
|
|
31
|
+
onUpdate(note.id, draft.trim());
|
|
32
|
+
}
|
|
33
|
+
setEditing(false);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
|
38
|
+
if (e.key === "Escape") {
|
|
39
|
+
setDraft(originalRef.current);
|
|
40
|
+
setEditing(false);
|
|
41
|
+
}
|
|
42
|
+
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
|
43
|
+
e.preventDefault();
|
|
44
|
+
handleBlur();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
var updatedDate = new Date(note.updatedAt).toLocaleDateString(undefined, {
|
|
49
|
+
month: "short",
|
|
50
|
+
day: "numeric",
|
|
51
|
+
year: "numeric",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
className="card bg-base-200 border border-base-content/15 outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-1 focus-visible:ring-offset-base-200"
|
|
57
|
+
tabIndex={editing ? undefined : 0}
|
|
58
|
+
role={editing ? undefined : "button"}
|
|
59
|
+
onClick={editing ? undefined : handleClick}
|
|
60
|
+
onKeyDown={editing ? undefined : function (e) {
|
|
61
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
handleClick();
|
|
64
|
+
}
|
|
65
|
+
}}
|
|
66
|
+
style={{ cursor: editing ? "default" : "pointer" }}
|
|
67
|
+
>
|
|
68
|
+
<div className="card-body p-3">
|
|
69
|
+
{editing ? (
|
|
70
|
+
<textarea
|
|
71
|
+
autoFocus
|
|
72
|
+
value={draft}
|
|
73
|
+
onChange={function (e) { setDraft(e.target.value); }}
|
|
74
|
+
onBlur={handleBlur}
|
|
75
|
+
onKeyDown={handleKeyDown}
|
|
76
|
+
placeholder="Type your note..."
|
|
77
|
+
className="textarea textarea-bordered w-full min-h-[80px] bg-base-300 text-base-content text-[13px] resize-y leading-relaxed"
|
|
78
|
+
/>
|
|
79
|
+
) : (
|
|
80
|
+
<div className="text-[13px] whitespace-pre-wrap break-words leading-relaxed min-h-12">
|
|
81
|
+
{note.content ? (
|
|
82
|
+
<span className="text-base-content">{note.content}</span>
|
|
83
|
+
) : (
|
|
84
|
+
<span className="text-base-content/30 italic">Click to add a note...</span>
|
|
85
|
+
)}
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
<div className="flex items-center justify-between mt-2">
|
|
89
|
+
<span className="text-[11px] text-base-content/40">{updatedDate}</span>
|
|
90
|
+
{confirming ? (
|
|
91
|
+
<div className="flex gap-1.5">
|
|
92
|
+
<button
|
|
93
|
+
onClick={function (e) { e.stopPropagation(); onDelete(note.id); }}
|
|
94
|
+
className="btn btn-error btn-xs outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-1 focus-visible:ring-offset-base-200"
|
|
95
|
+
>
|
|
96
|
+
Delete
|
|
97
|
+
</button>
|
|
98
|
+
<button
|
|
99
|
+
onClick={function (e) { e.stopPropagation(); setConfirming(false); }}
|
|
100
|
+
className="btn btn-ghost btn-xs border border-base-content/15 outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-1 focus-visible:ring-offset-base-200"
|
|
101
|
+
>
|
|
102
|
+
Cancel
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
) : (
|
|
106
|
+
<button
|
|
107
|
+
onClick={function (e) { e.stopPropagation(); setConfirming(true); }}
|
|
108
|
+
disabled={disabled}
|
|
109
|
+
className="btn btn-ghost btn-xs border border-base-content/15 text-base-content/50 outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-1 focus-visible:ring-offset-base-200"
|
|
110
|
+
aria-label="Delete note"
|
|
111
|
+
>
|
|
112
|
+
<Trash2 className="!size-3" />
|
|
113
|
+
</button>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import { StickyNote as StickyNoteIcon } from "lucide-react";
|
|
3
|
+
import type { StickyNote, ServerMessage } from "@lattice/shared";
|
|
4
|
+
import { useWebSocket } from "../../hooks/useWebSocket";
|
|
5
|
+
import { useSession } from "../../hooks/useSession";
|
|
6
|
+
import { useOnline } from "../../hooks/useOnline";
|
|
7
|
+
import { NoteCard } from "./NoteCard";
|
|
8
|
+
|
|
9
|
+
export function NotesView() {
|
|
10
|
+
var { send, subscribe, unsubscribe } = useWebSocket();
|
|
11
|
+
var { activeProjectSlug } = useSession();
|
|
12
|
+
var online = useOnline();
|
|
13
|
+
var [notes, setNotes] = useState<StickyNote[]>([]);
|
|
14
|
+
|
|
15
|
+
var handleMessage = useCallback(function (msg: ServerMessage) {
|
|
16
|
+
if (msg.type === "notes:list_result") {
|
|
17
|
+
setNotes(msg.notes);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (msg.type === "notes:created") {
|
|
21
|
+
setNotes(function (prev) { return [...prev, msg.note]; });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (msg.type === "notes:updated") {
|
|
25
|
+
setNotes(function (prev) {
|
|
26
|
+
return prev.map(function (n) { return n.id === msg.note.id ? msg.note : n; });
|
|
27
|
+
});
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (msg.type === "notes:deleted") {
|
|
31
|
+
setNotes(function (prev) { return prev.filter(function (n) { return n.id !== msg.id; }); });
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
useEffect(function () {
|
|
37
|
+
subscribe("notes:list_result", handleMessage);
|
|
38
|
+
subscribe("notes:created", handleMessage);
|
|
39
|
+
subscribe("notes:updated", handleMessage);
|
|
40
|
+
subscribe("notes:deleted", handleMessage);
|
|
41
|
+
send({ type: "notes:list", projectSlug: activeProjectSlug ?? undefined });
|
|
42
|
+
return function () {
|
|
43
|
+
unsubscribe("notes:list_result", handleMessage);
|
|
44
|
+
unsubscribe("notes:created", handleMessage);
|
|
45
|
+
unsubscribe("notes:updated", handleMessage);
|
|
46
|
+
unsubscribe("notes:deleted", handleMessage);
|
|
47
|
+
};
|
|
48
|
+
}, [send, subscribe, unsubscribe, handleMessage, activeProjectSlug]);
|
|
49
|
+
|
|
50
|
+
function handleCreate() {
|
|
51
|
+
send({ type: "notes:create", content: "", projectSlug: activeProjectSlug ?? undefined });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function handleUpdate(id: string, content: string) {
|
|
55
|
+
send({ type: "notes:update", id, content });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function handleDelete(id: string) {
|
|
59
|
+
send({ type: "notes:delete", id });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className="flex flex-col h-full bg-base-100">
|
|
64
|
+
<div className="flex items-center justify-between px-4 py-3 border-b border-base-content/15">
|
|
65
|
+
<span className="text-[13px] font-semibold text-base-content">Notes</span>
|
|
66
|
+
<button
|
|
67
|
+
onClick={handleCreate}
|
|
68
|
+
disabled={!online}
|
|
69
|
+
className="btn btn-primary btn-xs"
|
|
70
|
+
>
|
|
71
|
+
New Note
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div className="flex-1 overflow-auto p-3">
|
|
76
|
+
{notes.length === 0 ? (
|
|
77
|
+
<div className="flex flex-col items-center justify-center text-center mt-16 gap-3">
|
|
78
|
+
<StickyNoteIcon size={28} className="text-base-content/15" />
|
|
79
|
+
<div>
|
|
80
|
+
<div className="text-[13px] text-base-content/40">No notes for this project</div>
|
|
81
|
+
<div className="text-[11px] text-base-content/30 mt-1">Quick-capture ideas, reminders, or context. Click "New Note" to start.</div>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
) : (
|
|
85
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2.5">
|
|
86
|
+
{notes.map(function (note) {
|
|
87
|
+
return (
|
|
88
|
+
<NoteCard
|
|
89
|
+
key={note.id}
|
|
90
|
+
note={note}
|
|
91
|
+
onUpdate={handleUpdate}
|
|
92
|
+
onDelete={handleDelete}
|
|
93
|
+
disabled={!online}
|
|
94
|
+
/>
|
|
95
|
+
);
|
|
96
|
+
})}
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import { Calendar } from "lucide-react";
|
|
3
|
+
import type { ScheduledTask, ServerMessage } from "@lattice/shared";
|
|
4
|
+
import { useWebSocket } from "../../hooks/useWebSocket";
|
|
5
|
+
import { useSession } from "../../hooks/useSession";
|
|
6
|
+
import { useOnline } from "../../hooks/useOnline";
|
|
7
|
+
import { TaskCard } from "./TaskCard";
|
|
8
|
+
import { TaskEditModal } from "./TaskEditModal";
|
|
9
|
+
|
|
10
|
+
export function ScheduledTasksView() {
|
|
11
|
+
var { send, subscribe, unsubscribe } = useWebSocket();
|
|
12
|
+
var { activeProjectSlug } = useSession();
|
|
13
|
+
var online = useOnline();
|
|
14
|
+
var [tasks, setTasks] = useState<ScheduledTask[]>([]);
|
|
15
|
+
var [editingTask, setEditingTask] = useState<ScheduledTask | null | undefined>(undefined);
|
|
16
|
+
|
|
17
|
+
var handleMessage = useCallback(function (msg: ServerMessage) {
|
|
18
|
+
if (msg.type === "scheduler:tasks") {
|
|
19
|
+
setTasks(msg.tasks);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (msg.type === "scheduler:task_created") {
|
|
23
|
+
setTasks(function (prev) { return [...prev, msg.task]; });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (msg.type === "scheduler:task_updated") {
|
|
27
|
+
setTasks(function (prev) {
|
|
28
|
+
return prev.map(function (t) { return t.id === msg.task.id ? msg.task : t; });
|
|
29
|
+
});
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
useEffect(function () {
|
|
35
|
+
subscribe("scheduler:tasks", handleMessage);
|
|
36
|
+
subscribe("scheduler:task_created", handleMessage);
|
|
37
|
+
subscribe("scheduler:task_updated", handleMessage);
|
|
38
|
+
send({ type: "scheduler:list" });
|
|
39
|
+
return function () {
|
|
40
|
+
unsubscribe("scheduler:tasks", handleMessage);
|
|
41
|
+
unsubscribe("scheduler:task_created", handleMessage);
|
|
42
|
+
unsubscribe("scheduler:task_updated", handleMessage);
|
|
43
|
+
};
|
|
44
|
+
}, [send, subscribe, unsubscribe, handleMessage]);
|
|
45
|
+
|
|
46
|
+
var filtered = tasks.filter(function (t) { return t.projectSlug === activeProjectSlug; });
|
|
47
|
+
|
|
48
|
+
function handleToggle(taskId: string) {
|
|
49
|
+
send({ type: "scheduler:toggle", taskId });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function handleDelete(taskId: string) {
|
|
53
|
+
send({ type: "scheduler:delete", taskId });
|
|
54
|
+
setTasks(function (prev) { return prev.filter(function (t) { return t.id !== taskId; }); });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function handleSave(data: { name: string; prompt: string; cron: string }) {
|
|
58
|
+
if (editingTask === null) {
|
|
59
|
+
if (!activeProjectSlug) return;
|
|
60
|
+
send({ type: "scheduler:create", name: data.name, prompt: data.prompt, cron: data.cron, projectSlug: activeProjectSlug });
|
|
61
|
+
} else if (editingTask) {
|
|
62
|
+
send({ type: "scheduler:update", taskId: editingTask.id, name: data.name, prompt: data.prompt, cron: data.cron });
|
|
63
|
+
}
|
|
64
|
+
setEditingTask(undefined);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="flex flex-col h-full bg-base-100">
|
|
69
|
+
<div className="flex items-center justify-between px-4 py-3 border-b border-base-content/15">
|
|
70
|
+
<span className="text-[13px] font-semibold text-base-content">Scheduled Tasks</span>
|
|
71
|
+
<button
|
|
72
|
+
onClick={function () { setEditingTask(null); }}
|
|
73
|
+
disabled={!online}
|
|
74
|
+
className="btn btn-primary btn-xs"
|
|
75
|
+
>
|
|
76
|
+
New Task
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div className="flex-1 overflow-auto p-3">
|
|
81
|
+
{filtered.length === 0 ? (
|
|
82
|
+
<div className="flex flex-col items-center justify-center text-center mt-16 gap-3">
|
|
83
|
+
<Calendar size={28} className="text-base-content/15" />
|
|
84
|
+
<div>
|
|
85
|
+
<div className="text-[13px] text-base-content/40">No scheduled tasks for this project</div>
|
|
86
|
+
<div className="text-[11px] text-base-content/30 mt-1">Automate Claude prompts on a cron schedule. Click "New Task" to create one.</div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
) : (
|
|
90
|
+
<div className="space-y-2">
|
|
91
|
+
{filtered.map(function (task) {
|
|
92
|
+
return (
|
|
93
|
+
<TaskCard
|
|
94
|
+
key={task.id}
|
|
95
|
+
task={task}
|
|
96
|
+
onToggle={handleToggle}
|
|
97
|
+
onEdit={function (t) { setEditingTask(t); }}
|
|
98
|
+
onDelete={handleDelete}
|
|
99
|
+
disabled={!online}
|
|
100
|
+
/>
|
|
101
|
+
);
|
|
102
|
+
})}
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
{editingTask !== undefined && (
|
|
108
|
+
<TaskEditModal
|
|
109
|
+
task={editingTask}
|
|
110
|
+
projectSlug={activeProjectSlug ?? ""}
|
|
111
|
+
onSave={handleSave}
|
|
112
|
+
onClose={function () { setEditingTask(undefined); }}
|
|
113
|
+
/>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|