@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.
- package/.serena/project.yml +138 -0
- 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/ProjectClaude.tsx +14 -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 +24 -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/AddProjectModal.tsx +3 -2
- 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 +131 -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,81 @@
|
|
|
1
|
+
import React, { useRef, useCallback, useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
interface SplitPaneProps {
|
|
4
|
+
direction: "horizontal" | "vertical";
|
|
5
|
+
ratio: number;
|
|
6
|
+
onRatioChange: (ratio: number) => void;
|
|
7
|
+
children: [React.ReactNode, React.ReactNode];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function SplitPane({ direction, ratio, onRatioChange, children }: SplitPaneProps) {
|
|
11
|
+
var containerRef = useRef<HTMLDivElement>(null);
|
|
12
|
+
var [isDragging, setIsDragging] = useState(false);
|
|
13
|
+
|
|
14
|
+
var handleMouseDown = useCallback(function (e: React.MouseEvent) {
|
|
15
|
+
e.preventDefault();
|
|
16
|
+
setIsDragging(true);
|
|
17
|
+
}, []);
|
|
18
|
+
|
|
19
|
+
useEffect(function () {
|
|
20
|
+
if (!isDragging) return;
|
|
21
|
+
|
|
22
|
+
function handleMouseMove(e: MouseEvent) {
|
|
23
|
+
var container = containerRef.current;
|
|
24
|
+
if (!container) return;
|
|
25
|
+
var rect = container.getBoundingClientRect();
|
|
26
|
+
var newRatio: number;
|
|
27
|
+
if (direction === "horizontal") {
|
|
28
|
+
newRatio = (e.clientX - rect.left) / rect.width;
|
|
29
|
+
} else {
|
|
30
|
+
newRatio = (e.clientY - rect.top) / rect.height;
|
|
31
|
+
}
|
|
32
|
+
onRatioChange(newRatio);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function handleMouseUp() {
|
|
36
|
+
setIsDragging(false);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
document.addEventListener("mousemove", handleMouseMove);
|
|
40
|
+
document.addEventListener("mouseup", handleMouseUp);
|
|
41
|
+
return function () {
|
|
42
|
+
document.removeEventListener("mousemove", handleMouseMove);
|
|
43
|
+
document.removeEventListener("mouseup", handleMouseUp);
|
|
44
|
+
};
|
|
45
|
+
}, [isDragging, direction, onRatioChange]);
|
|
46
|
+
|
|
47
|
+
var isHorizontal = direction === "horizontal";
|
|
48
|
+
var firstSize = (ratio * 100) + "%";
|
|
49
|
+
var secondSize = ((1 - ratio) * 100) + "%";
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
ref={containerRef}
|
|
54
|
+
className={"flex h-full w-full overflow-hidden " + (isHorizontal ? "flex-row" : "flex-col")}
|
|
55
|
+
style={isDragging ? { userSelect: "none" } : undefined}
|
|
56
|
+
>
|
|
57
|
+
<div
|
|
58
|
+
className="overflow-hidden"
|
|
59
|
+
style={isHorizontal ? { width: firstSize, height: "100%" } : { height: firstSize, width: "100%" }}
|
|
60
|
+
>
|
|
61
|
+
{children[0]}
|
|
62
|
+
</div>
|
|
63
|
+
<div
|
|
64
|
+
onMouseDown={handleMouseDown}
|
|
65
|
+
className={
|
|
66
|
+
"flex-shrink-0 bg-base-300 transition-colors " +
|
|
67
|
+
(isHorizontal
|
|
68
|
+
? "w-1 cursor-col-resize hover:bg-primary/30"
|
|
69
|
+
: "h-1 cursor-row-resize hover:bg-primary/30") +
|
|
70
|
+
(isDragging ? " bg-primary/30" : "")
|
|
71
|
+
}
|
|
72
|
+
/>
|
|
73
|
+
<div
|
|
74
|
+
className="overflow-hidden"
|
|
75
|
+
style={isHorizontal ? { width: secondSize, height: "100%" } : { height: secondSize, width: "100%" }}
|
|
76
|
+
>
|
|
77
|
+
{children[1]}
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from "react";
|
|
2
|
+
import { X, Columns2, Rows2, MessageSquare, FolderOpen, TerminalSquare, StickyNote, Calendar } from "lucide-react";
|
|
3
|
+
import { useWorkspace } from "../../hooks/useWorkspace";
|
|
4
|
+
import type { Tab, TabType } from "../../stores/workspace";
|
|
5
|
+
|
|
6
|
+
interface TabBarProps {
|
|
7
|
+
paneId?: string;
|
|
8
|
+
isActivePane?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ContextMenuState {
|
|
12
|
+
tabId: string;
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
var TAB_ICONS: Record<TabType, typeof MessageSquare> = {
|
|
18
|
+
chat: MessageSquare,
|
|
19
|
+
files: FolderOpen,
|
|
20
|
+
terminal: TerminalSquare,
|
|
21
|
+
notes: StickyNote,
|
|
22
|
+
tasks: Calendar,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function TabBar({ paneId, isActivePane }: TabBarProps) {
|
|
26
|
+
var workspace = useWorkspace();
|
|
27
|
+
var [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
|
28
|
+
var menuRef = useRef<HTMLDivElement>(null);
|
|
29
|
+
|
|
30
|
+
useEffect(function () {
|
|
31
|
+
if (!contextMenu) return;
|
|
32
|
+
|
|
33
|
+
function handleClickOutside(e: MouseEvent) {
|
|
34
|
+
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
35
|
+
setContextMenu(null);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function handleEscape(e: KeyboardEvent) {
|
|
40
|
+
if (e.key === "Escape") {
|
|
41
|
+
setContextMenu(null);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
46
|
+
document.addEventListener("keydown", handleEscape);
|
|
47
|
+
return function () {
|
|
48
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
49
|
+
document.removeEventListener("keydown", handleEscape);
|
|
50
|
+
};
|
|
51
|
+
}, [contextMenu]);
|
|
52
|
+
|
|
53
|
+
var paneTabs: Tab[];
|
|
54
|
+
var activeTabId: string;
|
|
55
|
+
|
|
56
|
+
if (paneId) {
|
|
57
|
+
var pane = workspace.panes.find(function (p) { return p.id === paneId; });
|
|
58
|
+
if (!pane) return null;
|
|
59
|
+
paneTabs = pane.tabIds.map(function (id) {
|
|
60
|
+
return workspace.tabs.find(function (t) { return t.id === id; });
|
|
61
|
+
}).filter(function (t): t is Tab { return t != null; });
|
|
62
|
+
activeTabId = pane.activeTabId;
|
|
63
|
+
} else {
|
|
64
|
+
paneTabs = workspace.tabs;
|
|
65
|
+
activeTabId = workspace.activeTabId;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
var shouldShow = paneTabs.length > 1 || (paneTabs[0]?.closeable ?? false);
|
|
69
|
+
|
|
70
|
+
function handleTabClick(tabId: string) {
|
|
71
|
+
if (paneId) {
|
|
72
|
+
workspace.setPaneActiveTab(paneId, tabId);
|
|
73
|
+
} else {
|
|
74
|
+
workspace.setActiveTab(tabId);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function handleCloseTab(tabId: string) {
|
|
79
|
+
workspace.closeTab(tabId);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function handleContextMenu(e: React.MouseEvent, tabId: string) {
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
if (workspace.panes.length >= 2) return;
|
|
85
|
+
var contextPane = paneId
|
|
86
|
+
? workspace.panes.find(function (p) { return p.id === paneId; })
|
|
87
|
+
: workspace.panes[0];
|
|
88
|
+
if (!contextPane || contextPane.tabIds.length < 2) return;
|
|
89
|
+
var menuWidth = 160;
|
|
90
|
+
var menuHeight = 80;
|
|
91
|
+
var x = e.clientX;
|
|
92
|
+
var y = e.clientY;
|
|
93
|
+
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
|
|
94
|
+
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8;
|
|
95
|
+
setContextMenu({ tabId, x, y });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function handleSplit(direction: "horizontal" | "vertical") {
|
|
99
|
+
if (!contextMenu) return;
|
|
100
|
+
workspace.splitPane(contextMenu.tabId, direction);
|
|
101
|
+
setContextMenu(null);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<>
|
|
106
|
+
<div
|
|
107
|
+
className={
|
|
108
|
+
"flex items-stretch bg-base-200 overflow-x-auto flex-shrink-0 order-1 sm:order-none" +
|
|
109
|
+
(shouldShow ? " border-b border-t sm:border-t-0 border-base-content/15" : "") +
|
|
110
|
+
(isActivePane && shouldShow ? " sm:border-t-2 sm:border-t-primary/40" : "")
|
|
111
|
+
}
|
|
112
|
+
style={{
|
|
113
|
+
maxHeight: shouldShow ? "3rem" : "0",
|
|
114
|
+
opacity: shouldShow ? 1 : 0,
|
|
115
|
+
overflow: shouldShow ? undefined : "hidden",
|
|
116
|
+
transition: "max-height 0.2s ease, opacity 0.15s ease",
|
|
117
|
+
}}
|
|
118
|
+
>
|
|
119
|
+
{paneTabs.map(function (tab) {
|
|
120
|
+
var isActive = tab.id === activeTabId;
|
|
121
|
+
var Icon = TAB_ICONS[tab.type] || MessageSquare;
|
|
122
|
+
return (
|
|
123
|
+
<div
|
|
124
|
+
key={tab.id}
|
|
125
|
+
role="tab"
|
|
126
|
+
tabIndex={0}
|
|
127
|
+
aria-selected={isActive}
|
|
128
|
+
onClick={function () { handleTabClick(tab.id); }}
|
|
129
|
+
onKeyDown={function (e) {
|
|
130
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
131
|
+
e.preventDefault();
|
|
132
|
+
handleTabClick(tab.id);
|
|
133
|
+
}
|
|
134
|
+
}}
|
|
135
|
+
onContextMenu={function (e) { handleContextMenu(e, tab.id); }}
|
|
136
|
+
className={
|
|
137
|
+
"flex items-center gap-2 px-4 py-2.5 text-[13px] font-mono border-r border-base-content/10 transition-colors whitespace-nowrap flex-shrink-0 outline-none cursor-pointer select-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-inset " +
|
|
138
|
+
(isActive
|
|
139
|
+
? "bg-base-100 text-base-content border-b-2 border-b-primary"
|
|
140
|
+
: "text-base-content/40 hover:text-base-content/70 hover:bg-base-300/30")
|
|
141
|
+
}
|
|
142
|
+
>
|
|
143
|
+
<Icon size={14} className={isActive ? "text-primary" : ""} />
|
|
144
|
+
<span>{tab.label}</span>
|
|
145
|
+
{tab.closeable && (
|
|
146
|
+
<button
|
|
147
|
+
aria-label={"Close " + tab.label + " tab"}
|
|
148
|
+
onClick={function (e) {
|
|
149
|
+
e.stopPropagation();
|
|
150
|
+
handleCloseTab(tab.id);
|
|
151
|
+
}}
|
|
152
|
+
className="ml-0.5 p-1 rounded hover:bg-base-content/15 text-base-content/30 hover:text-base-content/60 outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
|
153
|
+
>
|
|
154
|
+
<X size={12} />
|
|
155
|
+
</button>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
})}
|
|
160
|
+
</div>
|
|
161
|
+
{contextMenu && (
|
|
162
|
+
<div
|
|
163
|
+
ref={menuRef}
|
|
164
|
+
className="fixed z-50 bg-base-300 border border-base-content/15 rounded-lg shadow-lg py-1 min-w-[160px]"
|
|
165
|
+
style={{ left: contextMenu.x, top: contextMenu.y }}
|
|
166
|
+
>
|
|
167
|
+
<button
|
|
168
|
+
onClick={function () { handleSplit("horizontal"); }}
|
|
169
|
+
className="flex items-center gap-2 w-full px-3 py-1.5 text-[12px] font-mono text-base-content/80 hover:bg-base-content/15 hover:text-base-content transition-colors outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
|
170
|
+
>
|
|
171
|
+
<Columns2 size={14} />
|
|
172
|
+
Split Right
|
|
173
|
+
</button>
|
|
174
|
+
<button
|
|
175
|
+
onClick={function () { handleSplit("vertical"); }}
|
|
176
|
+
className="flex items-center gap-2 w-full px-3 py-1.5 text-[12px] font-mono text-base-content/80 hover:bg-base-content/15 hover:text-base-content transition-colors outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
|
177
|
+
>
|
|
178
|
+
<Rows2 size={14} />
|
|
179
|
+
Split Down
|
|
180
|
+
</button>
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
183
|
+
</>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { ChevronDown, ChevronRight, Pencil, Trash2 } from "lucide-react";
|
|
3
|
+
import cronstrue from "cronstrue";
|
|
4
|
+
import type { ScheduledTask } from "@lattice/shared";
|
|
5
|
+
|
|
6
|
+
interface TaskCardProps {
|
|
7
|
+
task: ScheduledTask;
|
|
8
|
+
onToggle: (taskId: string) => void;
|
|
9
|
+
onEdit: (task: ScheduledTask) => void;
|
|
10
|
+
onDelete: (taskId: string) => void;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function formatTime(ms: number | null): string {
|
|
15
|
+
if (!ms) return "—";
|
|
16
|
+
return new Date(ms).toLocaleString(undefined, {
|
|
17
|
+
month: "short",
|
|
18
|
+
day: "numeric",
|
|
19
|
+
hour: "2-digit",
|
|
20
|
+
minute: "2-digit",
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function humanCron(expr: string): string {
|
|
25
|
+
try {
|
|
26
|
+
return cronstrue.toString(expr, { use24HourTimeFormat: true });
|
|
27
|
+
} catch {
|
|
28
|
+
return expr;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function TaskCard(props: TaskCardProps) {
|
|
33
|
+
var { task, onToggle, onEdit, onDelete, disabled } = props;
|
|
34
|
+
var [expanded, setExpanded] = useState(false);
|
|
35
|
+
var [confirming, setConfirming] = useState(false);
|
|
36
|
+
|
|
37
|
+
function handleToggleExpand(e: React.MouseEvent) {
|
|
38
|
+
e.stopPropagation();
|
|
39
|
+
setExpanded(function (prev) { return !prev; });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function handleDelete(e: React.MouseEvent) {
|
|
43
|
+
e.stopPropagation();
|
|
44
|
+
if (confirming) {
|
|
45
|
+
onDelete(task.id);
|
|
46
|
+
} else {
|
|
47
|
+
setConfirming(true);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function handleCancelDelete(e: React.MouseEvent) {
|
|
52
|
+
e.stopPropagation();
|
|
53
|
+
setConfirming(false);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function handleEdit(e: React.MouseEvent) {
|
|
57
|
+
e.stopPropagation();
|
|
58
|
+
onEdit(task);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function handleToggle(e: React.MouseEvent) {
|
|
62
|
+
e.stopPropagation();
|
|
63
|
+
onToggle(task.id);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className="bg-base-200 border border-base-content/15 rounded-lg overflow-hidden">
|
|
68
|
+
<div
|
|
69
|
+
tabIndex={0}
|
|
70
|
+
role="button"
|
|
71
|
+
className="flex items-center gap-2.5 px-3 py-2.5 cursor-pointer hover:bg-base-300/50 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-1 focus-visible:ring-offset-base-200"
|
|
72
|
+
onClick={handleToggleExpand}
|
|
73
|
+
onKeyDown={function (e) {
|
|
74
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
setExpanded(function (prev) { return !prev; });
|
|
77
|
+
}
|
|
78
|
+
}}
|
|
79
|
+
aria-expanded={expanded}
|
|
80
|
+
>
|
|
81
|
+
<span className="text-base-content/40 flex-shrink-0">
|
|
82
|
+
{expanded ? <ChevronDown className="!size-3.5" /> : <ChevronRight className="!size-3.5" />}
|
|
83
|
+
</span>
|
|
84
|
+
<div className="flex-1 min-w-0">
|
|
85
|
+
<div className="flex items-center gap-2">
|
|
86
|
+
<span className={`text-[13px] font-medium truncate ${task.enabled ? "text-base-content" : "text-base-content/40"}`}>
|
|
87
|
+
{task.name}
|
|
88
|
+
</span>
|
|
89
|
+
</div>
|
|
90
|
+
<div className="text-[11px] text-base-content/40 truncate mt-0.5">
|
|
91
|
+
{humanCron(task.cron)}
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
<label className="swap flex-shrink-0" onClick={disabled ? undefined : handleToggle}>
|
|
95
|
+
<input
|
|
96
|
+
type="checkbox"
|
|
97
|
+
className="toggle toggle-primary toggle-xs"
|
|
98
|
+
checked={task.enabled}
|
|
99
|
+
disabled={disabled}
|
|
100
|
+
onChange={function () {}}
|
|
101
|
+
/>
|
|
102
|
+
</label>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
{expanded && (
|
|
106
|
+
<div className="px-3 pb-3 border-t border-base-content/15 bg-base-100/50">
|
|
107
|
+
<div className="pt-2.5 space-y-2">
|
|
108
|
+
<div>
|
|
109
|
+
<span className="text-[11px] text-base-content/40 uppercase tracking-wider">Prompt</span>
|
|
110
|
+
<p className="text-[12px] text-base-content/80 mt-0.5 whitespace-pre-wrap break-words leading-relaxed">
|
|
111
|
+
{task.prompt}
|
|
112
|
+
</p>
|
|
113
|
+
</div>
|
|
114
|
+
<div className="grid grid-cols-2 gap-2">
|
|
115
|
+
<div>
|
|
116
|
+
<span className="text-[11px] text-base-content/40 uppercase tracking-wider">Last Run</span>
|
|
117
|
+
<p className="text-[12px] text-base-content/70 mt-0.5">{formatTime(task.lastRunAt)}</p>
|
|
118
|
+
</div>
|
|
119
|
+
<div>
|
|
120
|
+
<span className="text-[11px] text-base-content/40 uppercase tracking-wider">Next Run</span>
|
|
121
|
+
<p className="text-[12px] text-base-content/70 mt-0.5">{formatTime(task.nextRunAt)}</p>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
{!disabled && (
|
|
125
|
+
<div className="flex items-center gap-1.5 pt-1">
|
|
126
|
+
<button
|
|
127
|
+
onClick={handleEdit}
|
|
128
|
+
className="btn btn-ghost btn-xs border border-base-content/15 text-base-content/70 gap-1 outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-1 focus-visible:ring-offset-base-200"
|
|
129
|
+
>
|
|
130
|
+
<Pencil className="!size-3" />
|
|
131
|
+
Edit
|
|
132
|
+
</button>
|
|
133
|
+
{confirming ? (
|
|
134
|
+
<div className="flex gap-1.5">
|
|
135
|
+
<button onClick={handleDelete} 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">
|
|
136
|
+
Confirm Delete
|
|
137
|
+
</button>
|
|
138
|
+
<button onClick={handleCancelDelete} 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">
|
|
139
|
+
Cancel
|
|
140
|
+
</button>
|
|
141
|
+
</div>
|
|
142
|
+
) : (
|
|
143
|
+
<button
|
|
144
|
+
onClick={handleDelete}
|
|
145
|
+
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"
|
|
146
|
+
aria-label="Delete task"
|
|
147
|
+
>
|
|
148
|
+
<Trash2 className="!size-3" />
|
|
149
|
+
</button>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { X } from "lucide-react";
|
|
3
|
+
import cronstrue from "cronstrue";
|
|
4
|
+
import type { ScheduledTask } from "@lattice/shared";
|
|
5
|
+
|
|
6
|
+
interface TaskEditModalProps {
|
|
7
|
+
task: ScheduledTask | null;
|
|
8
|
+
projectSlug: string;
|
|
9
|
+
onSave: (data: { name: string; prompt: string; cron: string }) => void;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getCronPreview(expr: string): string {
|
|
14
|
+
if (!expr.trim()) return "";
|
|
15
|
+
try {
|
|
16
|
+
return cronstrue.toString(expr.trim(), { use24HourTimeFormat: true });
|
|
17
|
+
} catch {
|
|
18
|
+
return "Invalid cron expression";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function TaskEditModal(props: TaskEditModalProps) {
|
|
23
|
+
var { task, onSave, onClose } = props;
|
|
24
|
+
var [name, setName] = useState(task ? task.name : "");
|
|
25
|
+
var [prompt, setPrompt] = useState(task ? task.prompt : "");
|
|
26
|
+
var [cron, setCron] = useState(task ? task.cron : "0 9 * * 1-5");
|
|
27
|
+
|
|
28
|
+
var cronPreview = getCronPreview(cron);
|
|
29
|
+
var cronValid = cronPreview !== "Invalid cron expression" && cronPreview !== "";
|
|
30
|
+
|
|
31
|
+
function handleSubmit(e: React.FormEvent) {
|
|
32
|
+
e.preventDefault();
|
|
33
|
+
if (!name.trim() || !prompt.trim() || !cronValid) return;
|
|
34
|
+
onSave({ name: name.trim(), prompt: prompt.trim(), cron: cron.trim() });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function handleBackdrop(e: React.MouseEvent<HTMLDivElement>) {
|
|
38
|
+
if (e.target === e.currentTarget) onClose();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
|
44
|
+
onClick={handleBackdrop}
|
|
45
|
+
>
|
|
46
|
+
<div className="bg-base-200 border border-base-content/15 rounded-2xl shadow-2xl w-full max-w-md mx-4">
|
|
47
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-base-content/15">
|
|
48
|
+
<span className="text-[14px] font-semibold text-base-content">
|
|
49
|
+
{task ? "Edit Task" : "New Scheduled Task"}
|
|
50
|
+
</span>
|
|
51
|
+
<button
|
|
52
|
+
onClick={onClose}
|
|
53
|
+
className="btn btn-ghost btn-xs btn-square text-base-content/50"
|
|
54
|
+
>
|
|
55
|
+
<X size={14} />
|
|
56
|
+
</button>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
|
60
|
+
<div className="space-y-1.5">
|
|
61
|
+
<label className="text-[12px] font-semibold text-base-content/40 uppercase tracking-wider">Name</label>
|
|
62
|
+
<input
|
|
63
|
+
type="text"
|
|
64
|
+
className="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]"
|
|
65
|
+
placeholder="Daily standup summary"
|
|
66
|
+
value={name}
|
|
67
|
+
onChange={function (e) { setName(e.target.value); }}
|
|
68
|
+
autoFocus
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<div className="space-y-1.5">
|
|
73
|
+
<label className="text-[12px] font-semibold text-base-content/40 uppercase tracking-wider">Prompt</label>
|
|
74
|
+
<textarea
|
|
75
|
+
className="w-full px-3 py-2.5 bg-base-300 border border-base-content/15 rounded-xl text-base-content text-[13px] min-h-[96px] resize-y leading-relaxed focus:border-primary focus-visible:outline-none transition-colors duration-[120ms]"
|
|
76
|
+
placeholder="Summarize yesterday's work and create a plan for today..."
|
|
77
|
+
value={prompt}
|
|
78
|
+
onChange={function (e) { setPrompt(e.target.value); }}
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div className="space-y-1.5">
|
|
83
|
+
<label className="text-[12px] font-semibold text-base-content/40 uppercase tracking-wider">Cron Expression</label>
|
|
84
|
+
<input
|
|
85
|
+
type="text"
|
|
86
|
+
className={`w-full h-9 px-3 bg-base-300 border rounded-xl text-base-content text-[13px] font-mono focus:border-primary focus-visible:outline-none transition-colors duration-[120ms] ${cron.trim() && !cronValid ? "border-error" : "border-base-content/15"}`}
|
|
87
|
+
placeholder="0 9 * * 1-5"
|
|
88
|
+
value={cron}
|
|
89
|
+
onChange={function (e) { setCron(e.target.value); }}
|
|
90
|
+
/>
|
|
91
|
+
{cron.trim() && (
|
|
92
|
+
<p className={`text-[11px] mt-1 ${cronValid ? "text-primary/80" : "text-error"}`}>
|
|
93
|
+
{cronPreview}
|
|
94
|
+
</p>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<div className="flex gap-2 pt-1">
|
|
99
|
+
<button
|
|
100
|
+
type="submit"
|
|
101
|
+
className="btn btn-primary btn-sm flex-1"
|
|
102
|
+
disabled={!name.trim() || !prompt.trim() || !cronValid}
|
|
103
|
+
>
|
|
104
|
+
{task ? "Save Changes" : "Create Task"}
|
|
105
|
+
</button>
|
|
106
|
+
<button type="button" onClick={onClose} className="btn btn-ghost btn-sm border border-base-content/15">
|
|
107
|
+
Cancel
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
</form>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { useEffect, useRef
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
2
|
import { Terminal as XTerm } from "@xterm/xterm";
|
|
3
3
|
import { FitAddon } from "@xterm/addon-fit";
|
|
4
4
|
import { WebLinksAddon } from "@xterm/addon-web-links";
|
|
5
|
+
import { SearchAddon } from "@xterm/addon-search";
|
|
5
6
|
import "@xterm/xterm/css/xterm.css";
|
|
6
7
|
import { useWebSocket } from "../../hooks/useWebSocket";
|
|
7
|
-
import
|
|
8
|
+
import { useSidebar } from "../../hooks/useSidebar";
|
|
9
|
+
import type { ServerMessage, TerminalCreatedMessage, TerminalOutputMessage, TerminalExitedMessage } from "@lattice/shared";
|
|
8
10
|
|
|
9
11
|
function getXtermTheme(): Record<string, string> {
|
|
10
12
|
var root = document.documentElement;
|
|
@@ -39,13 +41,19 @@ function getXtermTheme(): Record<string, string> {
|
|
|
39
41
|
};
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
|
|
44
|
+
interface TerminalInstanceProps {
|
|
45
|
+
instanceId: string;
|
|
46
|
+
visible: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function TerminalInstance({ instanceId, visible }: TerminalInstanceProps) {
|
|
43
50
|
var containerRef = useRef<HTMLDivElement | null>(null);
|
|
44
51
|
var xtermRef = useRef<XTerm | null>(null);
|
|
45
52
|
var fitAddonRef = useRef<FitAddon | null>(null);
|
|
53
|
+
var searchAddonRef = useRef<SearchAddon | null>(null);
|
|
46
54
|
var termIdRef = useRef<string | null>(null);
|
|
55
|
+
var { activeProjectSlug } = useSidebar();
|
|
47
56
|
var { send, subscribe, unsubscribe } = useWebSocket();
|
|
48
|
-
var [ready, setReady] = useState(false);
|
|
49
57
|
|
|
50
58
|
useEffect(function() {
|
|
51
59
|
if (!containerRef.current) {
|
|
@@ -61,20 +69,25 @@ export function Terminal() {
|
|
|
61
69
|
|
|
62
70
|
var fitAddon = new FitAddon();
|
|
63
71
|
var webLinksAddon = new WebLinksAddon();
|
|
72
|
+
var searchAddon = new SearchAddon();
|
|
73
|
+
|
|
64
74
|
term.loadAddon(fitAddon);
|
|
65
75
|
term.loadAddon(webLinksAddon);
|
|
76
|
+
term.loadAddon(searchAddon);
|
|
66
77
|
term.open(containerRef.current);
|
|
67
78
|
fitAddon.fit();
|
|
68
79
|
|
|
69
80
|
xtermRef.current = term;
|
|
70
81
|
fitAddonRef.current = fitAddon;
|
|
82
|
+
searchAddonRef.current = searchAddon;
|
|
71
83
|
|
|
72
|
-
|
|
84
|
+
var waitingForCreate = true;
|
|
73
85
|
|
|
74
86
|
function onCreated(msg: ServerMessage) {
|
|
87
|
+
if (!waitingForCreate) return;
|
|
88
|
+
waitingForCreate = false;
|
|
75
89
|
var created = msg as TerminalCreatedMessage;
|
|
76
90
|
termIdRef.current = created.termId;
|
|
77
|
-
setReady(true);
|
|
78
91
|
}
|
|
79
92
|
|
|
80
93
|
function onOutput(msg: ServerMessage) {
|
|
@@ -84,8 +97,18 @@ export function Terminal() {
|
|
|
84
97
|
}
|
|
85
98
|
}
|
|
86
99
|
|
|
100
|
+
function onExited(msg: ServerMessage) {
|
|
101
|
+
var exited = msg as TerminalExitedMessage;
|
|
102
|
+
if (xtermRef.current && exited.termId === termIdRef.current) {
|
|
103
|
+
xtermRef.current.write("\r\n\x1b[31m[process exited]\x1b[0m\r\n");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
87
107
|
subscribe("terminal:created", onCreated);
|
|
88
108
|
subscribe("terminal:output", onOutput);
|
|
109
|
+
subscribe("terminal:exited", onExited);
|
|
110
|
+
|
|
111
|
+
send({ type: "terminal:create", projectSlug: activeProjectSlug || undefined });
|
|
89
112
|
|
|
90
113
|
term.onData(function(data: string) {
|
|
91
114
|
var termId = termIdRef.current;
|
|
@@ -112,17 +135,37 @@ export function Terminal() {
|
|
|
112
135
|
return function() {
|
|
113
136
|
unsubscribe("terminal:created", onCreated);
|
|
114
137
|
unsubscribe("terminal:output", onOutput);
|
|
138
|
+
unsubscribe("terminal:exited", onExited);
|
|
115
139
|
resizeObserver.disconnect();
|
|
116
140
|
term.dispose();
|
|
117
141
|
xtermRef.current = null;
|
|
118
142
|
fitAddonRef.current = null;
|
|
143
|
+
searchAddonRef.current = null;
|
|
119
144
|
};
|
|
120
145
|
}, []);
|
|
121
146
|
|
|
147
|
+
useEffect(function() {
|
|
148
|
+
if (visible && fitAddonRef.current) {
|
|
149
|
+
var timer = setTimeout(function() {
|
|
150
|
+
if (fitAddonRef.current) {
|
|
151
|
+
fitAddonRef.current.fit();
|
|
152
|
+
var termId = termIdRef.current;
|
|
153
|
+
var dim = fitAddonRef.current.proposeDimensions();
|
|
154
|
+
if (termId && dim) {
|
|
155
|
+
send({ type: "terminal:resize", termId: termId, cols: dim.cols, rows: dim.rows });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}, 50);
|
|
159
|
+
return function() { clearTimeout(timer); };
|
|
160
|
+
}
|
|
161
|
+
}, [visible]);
|
|
162
|
+
|
|
122
163
|
return (
|
|
123
164
|
<div
|
|
124
165
|
ref={containerRef}
|
|
125
|
-
className="w-full h-full
|
|
166
|
+
className="w-full h-full overflow-hidden bg-base-100"
|
|
167
|
+
style={{ display: visible ? "block" : "none" }}
|
|
168
|
+
data-instance-id={instanceId}
|
|
126
169
|
/>
|
|
127
170
|
);
|
|
128
171
|
}
|