@cryptiklemur/lattice 1.2.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.
Files changed (95) hide show
  1. package/.serena/project.yml +138 -0
  2. package/bun.lock +705 -2
  3. package/client/index.html +1 -13
  4. package/client/package.json +6 -1
  5. package/client/src/App.tsx +2 -0
  6. package/client/src/commands.ts +36 -0
  7. package/client/src/components/chat/AttachmentChips.tsx +116 -0
  8. package/client/src/components/chat/ChatInput.tsx +250 -73
  9. package/client/src/components/chat/ChatView.tsx +242 -10
  10. package/client/src/components/chat/CommandPalette.tsx +162 -0
  11. package/client/src/components/chat/Message.tsx +23 -2
  12. package/client/src/components/chat/PromptQuestion.tsx +271 -0
  13. package/client/src/components/chat/TodoCard.tsx +57 -0
  14. package/client/src/components/chat/ToolResultRenderer.tsx +2 -1
  15. package/client/src/components/chat/VoiceRecorder.tsx +85 -0
  16. package/client/src/components/project-settings/ProjectClaude.tsx +14 -0
  17. package/client/src/components/project-settings/ProjectMemory.tsx +12 -2
  18. package/client/src/components/project-settings/ProjectNotifications.tsx +48 -0
  19. package/client/src/components/project-settings/ProjectRules.tsx +10 -1
  20. package/client/src/components/project-settings/ProjectSettingsView.tsx +6 -0
  21. package/client/src/components/settings/Appearance.tsx +1 -0
  22. package/client/src/components/settings/ClaudeSettings.tsx +24 -0
  23. package/client/src/components/settings/Editor.tsx +123 -0
  24. package/client/src/components/settings/GlobalMcp.tsx +10 -1
  25. package/client/src/components/settings/GlobalMemory.tsx +19 -0
  26. package/client/src/components/settings/GlobalRules.tsx +149 -0
  27. package/client/src/components/settings/GlobalSkills.tsx +10 -0
  28. package/client/src/components/settings/Notifications.tsx +88 -0
  29. package/client/src/components/settings/SettingsView.tsx +12 -0
  30. package/client/src/components/settings/skill-shared.tsx +2 -1
  31. package/client/src/components/setup/SetupWizard.tsx +1 -1
  32. package/client/src/components/sidebar/AddProjectModal.tsx +3 -2
  33. package/client/src/components/sidebar/NodeSettingsModal.tsx +23 -1
  34. package/client/src/components/sidebar/ProjectDropdown.tsx +176 -27
  35. package/client/src/components/sidebar/SettingsSidebar.tsx +11 -1
  36. package/client/src/components/sidebar/Sidebar.tsx +35 -2
  37. package/client/src/components/sidebar/UserIsland.tsx +18 -7
  38. package/client/src/components/ui/UpdatePrompt.tsx +47 -0
  39. package/client/src/components/workspace/FileBrowser.tsx +174 -0
  40. package/client/src/components/workspace/FileTree.tsx +129 -0
  41. package/client/src/components/workspace/FileViewer.tsx +211 -0
  42. package/client/src/components/workspace/NoteCard.tsx +119 -0
  43. package/client/src/components/workspace/NotesView.tsx +102 -0
  44. package/client/src/components/workspace/ScheduledTasksView.tsx +117 -0
  45. package/client/src/components/workspace/SplitPane.tsx +81 -0
  46. package/client/src/components/workspace/TabBar.tsx +185 -0
  47. package/client/src/components/workspace/TaskCard.tsx +158 -0
  48. package/client/src/components/workspace/TaskEditModal.tsx +114 -0
  49. package/client/src/components/{panels/Terminal.tsx → workspace/TerminalInstance.tsx} +50 -7
  50. package/client/src/components/workspace/TerminalView.tsx +110 -0
  51. package/client/src/components/workspace/WorkspaceView.tsx +116 -0
  52. package/client/src/hooks/useAttachments.ts +280 -0
  53. package/client/src/hooks/useEditorConfig.ts +28 -0
  54. package/client/src/hooks/useIdleDetection.ts +44 -0
  55. package/client/src/hooks/useInstallPrompt.ts +53 -0
  56. package/client/src/hooks/useNotifications.ts +54 -0
  57. package/client/src/hooks/useOnline.ts +6 -0
  58. package/client/src/hooks/useSession.ts +110 -4
  59. package/client/src/hooks/useSpinnerVerb.ts +36 -0
  60. package/client/src/hooks/useSwipeDrawer.ts +275 -0
  61. package/client/src/hooks/useVoiceRecorder.ts +123 -0
  62. package/client/src/hooks/useWorkspace.ts +48 -0
  63. package/client/src/providers/WebSocketProvider.tsx +18 -0
  64. package/client/src/router.tsx +48 -20
  65. package/client/src/stores/session.ts +136 -0
  66. package/client/src/stores/sidebar.ts +3 -2
  67. package/client/src/stores/workspace.ts +254 -0
  68. package/client/src/styles/global.css +131 -0
  69. package/client/src/utils/editorUrl.ts +62 -0
  70. package/client/vite.config.ts +53 -1
  71. package/package.json +1 -1
  72. package/server/src/daemon.ts +11 -1
  73. package/server/src/features/scheduler.ts +23 -0
  74. package/server/src/features/sticky-notes.ts +5 -3
  75. package/server/src/handlers/attachment.ts +172 -0
  76. package/server/src/handlers/chat.ts +43 -2
  77. package/server/src/handlers/editor.ts +40 -0
  78. package/server/src/handlers/fs.ts +10 -2
  79. package/server/src/handlers/memory.ts +3 -0
  80. package/server/src/handlers/notes.ts +4 -2
  81. package/server/src/handlers/scheduler.ts +18 -1
  82. package/server/src/handlers/session.ts +14 -8
  83. package/server/src/handlers/settings.ts +37 -2
  84. package/server/src/handlers/terminal.ts +13 -6
  85. package/server/src/project/pty-worker.cjs +83 -0
  86. package/server/src/project/sdk-bridge.ts +266 -11
  87. package/server/src/project/terminal.ts +78 -34
  88. package/shared/src/messages.ts +145 -4
  89. package/shared/src/models.ts +27 -1
  90. package/shared/src/project-settings.ts +1 -1
  91. package/tp.js +19 -0
  92. package/client/public/manifest.json +0 -24
  93. package/client/public/sw.js +0 -61
  94. package/client/src/components/panels/FileBrowser.tsx +0 -241
  95. 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
+ }