@cryptiklemur/lattice 1.3.0 → 1.5.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 (109) hide show
  1. package/bun.lock +776 -2
  2. package/client/index.html +1 -13
  3. package/client/package.json +7 -1
  4. package/client/src/App.tsx +2 -0
  5. package/client/src/commands.ts +36 -0
  6. package/client/src/components/analytics/AnalyticsView.tsx +61 -0
  7. package/client/src/components/analytics/ChartCard.tsx +22 -0
  8. package/client/src/components/analytics/PeriodSelector.tsx +42 -0
  9. package/client/src/components/analytics/QuickStats.tsx +99 -0
  10. package/client/src/components/analytics/charts/CostAreaChart.tsx +83 -0
  11. package/client/src/components/analytics/charts/CostDistributionChart.tsx +62 -0
  12. package/client/src/components/analytics/charts/CostDonutChart.tsx +93 -0
  13. package/client/src/components/analytics/charts/CumulativeCostChart.tsx +62 -0
  14. package/client/src/components/analytics/charts/SessionBubbleChart.tsx +122 -0
  15. package/client/src/components/chat/AttachmentChips.tsx +116 -0
  16. package/client/src/components/chat/ChatInput.tsx +250 -73
  17. package/client/src/components/chat/ChatView.tsx +242 -10
  18. package/client/src/components/chat/CommandPalette.tsx +162 -0
  19. package/client/src/components/chat/Message.tsx +23 -2
  20. package/client/src/components/chat/PromptQuestion.tsx +271 -0
  21. package/client/src/components/chat/TodoCard.tsx +57 -0
  22. package/client/src/components/chat/ToolResultRenderer.tsx +2 -1
  23. package/client/src/components/chat/VoiceRecorder.tsx +85 -0
  24. package/client/src/components/dashboard/DashboardView.tsx +5 -0
  25. package/client/src/components/project-settings/ProjectMemory.tsx +12 -2
  26. package/client/src/components/project-settings/ProjectNotifications.tsx +48 -0
  27. package/client/src/components/project-settings/ProjectRules.tsx +10 -1
  28. package/client/src/components/project-settings/ProjectSettingsView.tsx +6 -0
  29. package/client/src/components/settings/Appearance.tsx +1 -0
  30. package/client/src/components/settings/ClaudeSettings.tsx +10 -0
  31. package/client/src/components/settings/Editor.tsx +123 -0
  32. package/client/src/components/settings/GlobalMcp.tsx +10 -1
  33. package/client/src/components/settings/GlobalMemory.tsx +19 -0
  34. package/client/src/components/settings/GlobalRules.tsx +149 -0
  35. package/client/src/components/settings/GlobalSkills.tsx +10 -0
  36. package/client/src/components/settings/Notifications.tsx +88 -0
  37. package/client/src/components/settings/SettingsView.tsx +12 -0
  38. package/client/src/components/settings/skill-shared.tsx +2 -1
  39. package/client/src/components/setup/SetupWizard.tsx +1 -1
  40. package/client/src/components/sidebar/NodeSettingsModal.tsx +23 -1
  41. package/client/src/components/sidebar/ProjectDropdown.tsx +176 -27
  42. package/client/src/components/sidebar/SettingsSidebar.tsx +11 -1
  43. package/client/src/components/sidebar/Sidebar.tsx +43 -2
  44. package/client/src/components/sidebar/UserIsland.tsx +18 -7
  45. package/client/src/components/ui/UpdatePrompt.tsx +47 -0
  46. package/client/src/components/workspace/FileBrowser.tsx +174 -0
  47. package/client/src/components/workspace/FileTree.tsx +129 -0
  48. package/client/src/components/workspace/FileViewer.tsx +211 -0
  49. package/client/src/components/workspace/NoteCard.tsx +119 -0
  50. package/client/src/components/workspace/NotesView.tsx +102 -0
  51. package/client/src/components/workspace/ScheduledTasksView.tsx +117 -0
  52. package/client/src/components/workspace/SplitPane.tsx +81 -0
  53. package/client/src/components/workspace/TabBar.tsx +185 -0
  54. package/client/src/components/workspace/TaskCard.tsx +158 -0
  55. package/client/src/components/workspace/TaskEditModal.tsx +114 -0
  56. package/client/src/components/{panels/Terminal.tsx → workspace/TerminalInstance.tsx} +50 -7
  57. package/client/src/components/workspace/TerminalView.tsx +110 -0
  58. package/client/src/components/workspace/WorkspaceView.tsx +116 -0
  59. package/client/src/hooks/useAnalytics.ts +75 -0
  60. package/client/src/hooks/useAttachments.ts +280 -0
  61. package/client/src/hooks/useEditorConfig.ts +28 -0
  62. package/client/src/hooks/useIdleDetection.ts +44 -0
  63. package/client/src/hooks/useInstallPrompt.ts +53 -0
  64. package/client/src/hooks/useNotifications.ts +54 -0
  65. package/client/src/hooks/useOnline.ts +6 -0
  66. package/client/src/hooks/useSession.ts +110 -4
  67. package/client/src/hooks/useSpinnerVerb.ts +36 -0
  68. package/client/src/hooks/useSwipeDrawer.ts +275 -0
  69. package/client/src/hooks/useVoiceRecorder.ts +123 -0
  70. package/client/src/hooks/useWorkspace.ts +48 -0
  71. package/client/src/providers/WebSocketProvider.tsx +18 -0
  72. package/client/src/router.tsx +52 -20
  73. package/client/src/stores/analytics.ts +54 -0
  74. package/client/src/stores/session.ts +136 -0
  75. package/client/src/stores/sidebar.ts +11 -2
  76. package/client/src/stores/workspace.ts +254 -0
  77. package/client/src/styles/global.css +123 -0
  78. package/client/src/utils/editorUrl.ts +62 -0
  79. package/client/vite.config.ts +54 -1
  80. package/package.json +1 -1
  81. package/server/src/analytics/engine.ts +491 -0
  82. package/server/src/daemon.ts +12 -1
  83. package/server/src/features/scheduler.ts +23 -0
  84. package/server/src/features/sticky-notes.ts +5 -3
  85. package/server/src/handlers/analytics.ts +34 -0
  86. package/server/src/handlers/attachment.ts +172 -0
  87. package/server/src/handlers/chat.ts +43 -2
  88. package/server/src/handlers/editor.ts +40 -0
  89. package/server/src/handlers/fs.ts +10 -2
  90. package/server/src/handlers/memory.ts +3 -0
  91. package/server/src/handlers/notes.ts +4 -2
  92. package/server/src/handlers/scheduler.ts +18 -1
  93. package/server/src/handlers/session.ts +14 -8
  94. package/server/src/handlers/settings.ts +37 -2
  95. package/server/src/handlers/terminal.ts +13 -6
  96. package/server/src/project/pty-worker.cjs +83 -0
  97. package/server/src/project/sdk-bridge.ts +266 -11
  98. package/server/src/project/session.ts +4 -4
  99. package/server/src/project/terminal.ts +78 -34
  100. package/shared/src/analytics.ts +24 -0
  101. package/shared/src/index.ts +1 -0
  102. package/shared/src/messages.ts +173 -4
  103. package/shared/src/models.ts +27 -1
  104. package/shared/src/project-settings.ts +1 -1
  105. package/tp.js +19 -0
  106. package/client/public/manifest.json +0 -24
  107. package/client/public/sw.js +0 -61
  108. package/client/src/components/panels/FileBrowser.tsx +0 -241
  109. 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, useState } from "react";
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 type { ServerMessage, TerminalCreatedMessage, TerminalOutputMessage } from "@lattice/shared";
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
- export function Terminal() {
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
- send({ type: "terminal:create" });
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 min-h-[200px] overflow-hidden bg-base-100"
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
  }