@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.
Files changed (92) hide show
  1. package/bun.lock +705 -2
  2. package/client/index.html +1 -13
  3. package/client/package.json +6 -1
  4. package/client/src/App.tsx +2 -0
  5. package/client/src/commands.ts +36 -0
  6. package/client/src/components/chat/AttachmentChips.tsx +116 -0
  7. package/client/src/components/chat/ChatInput.tsx +250 -73
  8. package/client/src/components/chat/ChatView.tsx +242 -10
  9. package/client/src/components/chat/CommandPalette.tsx +162 -0
  10. package/client/src/components/chat/Message.tsx +23 -2
  11. package/client/src/components/chat/PromptQuestion.tsx +271 -0
  12. package/client/src/components/chat/TodoCard.tsx +57 -0
  13. package/client/src/components/chat/ToolResultRenderer.tsx +2 -1
  14. package/client/src/components/chat/VoiceRecorder.tsx +85 -0
  15. package/client/src/components/project-settings/ProjectMemory.tsx +12 -2
  16. package/client/src/components/project-settings/ProjectNotifications.tsx +48 -0
  17. package/client/src/components/project-settings/ProjectRules.tsx +10 -1
  18. package/client/src/components/project-settings/ProjectSettingsView.tsx +6 -0
  19. package/client/src/components/settings/Appearance.tsx +1 -0
  20. package/client/src/components/settings/ClaudeSettings.tsx +10 -0
  21. package/client/src/components/settings/Editor.tsx +123 -0
  22. package/client/src/components/settings/GlobalMcp.tsx +10 -1
  23. package/client/src/components/settings/GlobalMemory.tsx +19 -0
  24. package/client/src/components/settings/GlobalRules.tsx +149 -0
  25. package/client/src/components/settings/GlobalSkills.tsx +10 -0
  26. package/client/src/components/settings/Notifications.tsx +88 -0
  27. package/client/src/components/settings/SettingsView.tsx +12 -0
  28. package/client/src/components/settings/skill-shared.tsx +2 -1
  29. package/client/src/components/setup/SetupWizard.tsx +1 -1
  30. package/client/src/components/sidebar/NodeSettingsModal.tsx +23 -1
  31. package/client/src/components/sidebar/ProjectDropdown.tsx +176 -27
  32. package/client/src/components/sidebar/SettingsSidebar.tsx +11 -1
  33. package/client/src/components/sidebar/Sidebar.tsx +35 -2
  34. package/client/src/components/sidebar/UserIsland.tsx +18 -7
  35. package/client/src/components/ui/UpdatePrompt.tsx +47 -0
  36. package/client/src/components/workspace/FileBrowser.tsx +174 -0
  37. package/client/src/components/workspace/FileTree.tsx +129 -0
  38. package/client/src/components/workspace/FileViewer.tsx +211 -0
  39. package/client/src/components/workspace/NoteCard.tsx +119 -0
  40. package/client/src/components/workspace/NotesView.tsx +102 -0
  41. package/client/src/components/workspace/ScheduledTasksView.tsx +117 -0
  42. package/client/src/components/workspace/SplitPane.tsx +81 -0
  43. package/client/src/components/workspace/TabBar.tsx +185 -0
  44. package/client/src/components/workspace/TaskCard.tsx +158 -0
  45. package/client/src/components/workspace/TaskEditModal.tsx +114 -0
  46. package/client/src/components/{panels/Terminal.tsx → workspace/TerminalInstance.tsx} +50 -7
  47. package/client/src/components/workspace/TerminalView.tsx +110 -0
  48. package/client/src/components/workspace/WorkspaceView.tsx +116 -0
  49. package/client/src/hooks/useAttachments.ts +280 -0
  50. package/client/src/hooks/useEditorConfig.ts +28 -0
  51. package/client/src/hooks/useIdleDetection.ts +44 -0
  52. package/client/src/hooks/useInstallPrompt.ts +53 -0
  53. package/client/src/hooks/useNotifications.ts +54 -0
  54. package/client/src/hooks/useOnline.ts +6 -0
  55. package/client/src/hooks/useSession.ts +110 -4
  56. package/client/src/hooks/useSpinnerVerb.ts +36 -0
  57. package/client/src/hooks/useSwipeDrawer.ts +275 -0
  58. package/client/src/hooks/useVoiceRecorder.ts +123 -0
  59. package/client/src/hooks/useWorkspace.ts +48 -0
  60. package/client/src/providers/WebSocketProvider.tsx +18 -0
  61. package/client/src/router.tsx +48 -20
  62. package/client/src/stores/session.ts +136 -0
  63. package/client/src/stores/sidebar.ts +3 -2
  64. package/client/src/stores/workspace.ts +254 -0
  65. package/client/src/styles/global.css +123 -0
  66. package/client/src/utils/editorUrl.ts +62 -0
  67. package/client/vite.config.ts +53 -1
  68. package/package.json +1 -1
  69. package/server/src/daemon.ts +11 -1
  70. package/server/src/features/scheduler.ts +23 -0
  71. package/server/src/features/sticky-notes.ts +5 -3
  72. package/server/src/handlers/attachment.ts +172 -0
  73. package/server/src/handlers/chat.ts +43 -2
  74. package/server/src/handlers/editor.ts +40 -0
  75. package/server/src/handlers/fs.ts +10 -2
  76. package/server/src/handlers/memory.ts +3 -0
  77. package/server/src/handlers/notes.ts +4 -2
  78. package/server/src/handlers/scheduler.ts +18 -1
  79. package/server/src/handlers/session.ts +14 -8
  80. package/server/src/handlers/settings.ts +37 -2
  81. package/server/src/handlers/terminal.ts +13 -6
  82. package/server/src/project/pty-worker.cjs +83 -0
  83. package/server/src/project/sdk-bridge.ts +266 -11
  84. package/server/src/project/terminal.ts +78 -34
  85. package/shared/src/messages.ts +145 -4
  86. package/shared/src/models.ts +27 -1
  87. package/shared/src/project-settings.ts +1 -1
  88. package/tp.js +19 -0
  89. package/client/public/manifest.json +0 -24
  90. package/client/public/sw.js +0 -61
  91. package/client/src/components/panels/FileBrowser.tsx +0 -241
  92. package/client/src/components/panels/StickyNotes.tsx +0 -187
@@ -101,6 +101,13 @@ html, body {
101
101
  font-family: var(--font-mono);
102
102
  font-size: 13px;
103
103
  line-height: 1.6;
104
+ overflow-x: auto;
105
+ max-width: 100%;
106
+ }
107
+
108
+ .prose {
109
+ overflow-wrap: break-word;
110
+ word-break: break-word;
104
111
  }
105
112
 
106
113
  .prose code {
@@ -116,6 +123,79 @@ html, body {
116
123
  background: transparent;
117
124
  }
118
125
 
126
+ .prose .table-wrapper {
127
+ overflow-x: auto;
128
+ margin: 0.75em 0;
129
+ border-radius: 0.5rem;
130
+ border: 1px solid oklch(from var(--color-base-content) l c h / 0.08);
131
+ background: oklch(from var(--color-base-100) l c h / 0.6);
132
+ scrollbar-width: thin;
133
+ }
134
+
135
+ .prose table {
136
+ border-collapse: collapse;
137
+ width: 100%;
138
+ font-size: 12px;
139
+ font-family: var(--font-mono);
140
+ margin: 0;
141
+ border: none;
142
+ line-height: 1.5;
143
+ }
144
+
145
+ .prose thead {
146
+ background: oklch(from var(--color-base-content) l c h / 0.04);
147
+ position: sticky;
148
+ top: 0;
149
+ z-index: 1;
150
+ }
151
+
152
+ .prose th {
153
+ text-align: left;
154
+ font-weight: 600;
155
+ font-size: 10px;
156
+ text-transform: uppercase;
157
+ letter-spacing: 0.06em;
158
+ padding: 8px 14px;
159
+ color: oklch(from var(--color-base-content) l c h / 0.35);
160
+ border-bottom: 1px solid oklch(from var(--color-base-content) l c h / 0.1);
161
+ white-space: nowrap;
162
+ user-select: none;
163
+ }
164
+
165
+ .prose td {
166
+ padding: 8px 14px;
167
+ color: oklch(from var(--color-base-content) l c h / 0.65);
168
+ border-bottom: 1px solid oklch(from var(--color-base-content) l c h / 0.04);
169
+ vertical-align: top;
170
+ white-space: normal;
171
+ min-width: 60px;
172
+ }
173
+
174
+ .prose td code {
175
+ font-size: 11px;
176
+ white-space: nowrap;
177
+ padding: 1px 5px;
178
+ border-radius: 3px;
179
+ background: oklch(from var(--color-primary) l c h / 0.08);
180
+ color: oklch(from var(--color-primary) l c h / 0.8);
181
+ }
182
+
183
+ .prose tbody tr:last-child td {
184
+ border-bottom: none;
185
+ }
186
+
187
+ .prose tbody tr {
188
+ transition: background-color 150ms ease;
189
+ }
190
+
191
+ .prose tbody tr:nth-child(even) {
192
+ background: oklch(from var(--color-base-content) l c h / 0.015);
193
+ }
194
+
195
+ .prose tbody tr:hover {
196
+ background: oklch(from var(--color-primary) l c h / 0.04);
197
+ }
198
+
119
199
  .bg-lattice-grid {
120
200
  background-color: transparent;
121
201
  }
@@ -156,6 +236,40 @@ html, body {
156
236
  }
157
237
  }
158
238
 
239
+ /* Override DaisyUI drawer for smooth slide transitions on mobile.
240
+ DaisyUI defaults use opacity on the whole drawer-side which causes
241
+ an instant vanish. We keep drawer-side always opacity:1 and instead
242
+ slide the content panel + fade the overlay independently. */
243
+ @media (max-width: 1023px) {
244
+ /* Keep the container itself always fully opaque so the slide is visible.
245
+ Use visibility + pointer-events to control interactivity. */
246
+ .drawer-side {
247
+ opacity: 1 !important;
248
+ transition: visibility 0s linear 0.25s !important;
249
+ }
250
+
251
+ .drawer-toggle:checked ~ .drawer-side {
252
+ transition: visibility 0s linear 0s !important;
253
+ }
254
+
255
+ /* Sidebar content: slide in/out using translate (matches DaisyUI's property) */
256
+ .drawer-side > *:not(.drawer-overlay) {
257
+ will-change: translate;
258
+ transition: translate 0.25s cubic-bezier(0.4, 0, 0.2, 1);
259
+ }
260
+
261
+ /* Overlay: fade in/out */
262
+ .drawer-side > .drawer-overlay {
263
+ will-change: opacity;
264
+ opacity: 0;
265
+ transition: opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1);
266
+ }
267
+
268
+ .drawer-toggle:checked ~ .drawer-side > .drawer-overlay {
269
+ opacity: 1;
270
+ }
271
+ }
272
+
159
273
  .scrollbar-hidden {
160
274
  scrollbar-width: none;
161
275
  -ms-overflow-style: none;
@@ -165,6 +279,15 @@ html, body {
165
279
  display: none;
166
280
  }
167
281
 
282
+ @keyframes waveform {
283
+ 0%, 100% { height: 4px; opacity: 0.3; }
284
+ 50% { height: 16px; opacity: 0.8; }
285
+ }
286
+
287
+ .animate-waveform {
288
+ animation: waveform 0.6s ease-in-out infinite;
289
+ }
290
+
168
291
  @media (prefers-reduced-motion: reduce) {
169
292
  *, *::before, *::after {
170
293
  animation-duration: 0.01ms !important;
@@ -0,0 +1,62 @@
1
+ var JETBRAINS_IDS: Record<string, string> = {
2
+ webstorm: "webstorm",
3
+ intellij: "idea",
4
+ pycharm: "pycharm",
5
+ goland: "goland",
6
+ };
7
+
8
+ function toWindowsPath(linuxPath: string, wslDistro: string): string {
9
+ return "\\\\" + "wsl.localhost\\" + wslDistro + linuxPath.replace(/\//g, "\\");
10
+ }
11
+
12
+ function buildJetBrainsUrl(ideId: string, filePath: string, line?: number, projectName?: string): string {
13
+ var url = "jetbrains://" + ideId + "/navigate/reference?";
14
+ if (projectName) {
15
+ url += "project=" + encodeURIComponent(projectName);
16
+ }
17
+ if (filePath) {
18
+ url += (projectName ? "&" : "") + "path=" + encodeURIComponent(filePath);
19
+ if (line) {
20
+ url += "&line=" + line;
21
+ }
22
+ }
23
+ return url;
24
+ }
25
+
26
+ export interface EditorUrlOptions {
27
+ editorType: string;
28
+ projectPath: string;
29
+ filePath: string;
30
+ line?: number;
31
+ wslDistro?: string;
32
+ ideProjectName?: string;
33
+ }
34
+
35
+ export function getEditorUrl(editorType: string, projectPath: string, filePath: string, line?: number, wslDistro?: string, ideProjectName?: string): string | null {
36
+ var fullPath = filePath === "." ? projectPath : projectPath + "/" + filePath;
37
+ var resolvedPath = wslDistro ? toWindowsPath(fullPath, wslDistro) : fullPath;
38
+
39
+ var jetbrainsId = JETBRAINS_IDS[editorType];
40
+ if (jetbrainsId) {
41
+ if (ideProjectName) {
42
+ var jbPath = filePath === "." ? "" : filePath;
43
+ return buildJetBrainsUrl(jetbrainsId, jbPath, line, ideProjectName);
44
+ }
45
+ return buildJetBrainsUrl(jetbrainsId, resolvedPath, line);
46
+ }
47
+
48
+ if (editorType === "vscode" || editorType === "vscode-insiders" || editorType === "cursor") {
49
+ var scheme = editorType;
50
+ var isFile = filePath !== ".";
51
+ var lineSuffix = isFile ? ":" + (line || 1) : "";
52
+ if (wslDistro) {
53
+ return scheme + "://vscode-remote/wsl+" + wslDistro + fullPath + lineSuffix;
54
+ }
55
+ return scheme + "://file/" + resolvedPath + lineSuffix;
56
+ }
57
+ if (editorType === "sublime") {
58
+ return "subl://open?url=file://" + encodeURIComponent(resolvedPath) + (line ? "&line=" + line : "");
59
+ }
60
+
61
+ return null;
62
+ }
@@ -1,9 +1,52 @@
1
1
  import { defineConfig } from "vite";
2
2
  import react from "@vitejs/plugin-react";
3
3
  import tailwindcss from "@tailwindcss/vite";
4
+ import { VitePWA } from "vite-plugin-pwa";
4
5
 
5
6
  export default defineConfig({
6
- plugins: [tailwindcss(), react()],
7
+ plugins: [
8
+ tailwindcss(),
9
+ react(),
10
+ VitePWA({
11
+ registerType: "prompt",
12
+ workbox: {
13
+ globPatterns: ["**/*.{js,css,html,svg,png,woff2}"],
14
+ navigateFallback: "/index.html",
15
+ navigateFallbackDenylist: [/^\/ws/, /^\/api/],
16
+ runtimeCaching: [
17
+ {
18
+ urlPattern: /^https?:\/\/.*\.(?:js|css|woff2)$/,
19
+ handler: "CacheFirst",
20
+ options: {
21
+ cacheName: "static-assets",
22
+ expiration: { maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 },
23
+ },
24
+ },
25
+ {
26
+ urlPattern: /^https?:\/\/.*\.(?:svg|png|jpg|jpeg|gif|webp)$/,
27
+ handler: "CacheFirst",
28
+ options: {
29
+ cacheName: "images",
30
+ expiration: { maxEntries: 50, maxAgeSeconds: 30 * 24 * 60 * 60 },
31
+ },
32
+ },
33
+ ],
34
+ },
35
+ manifest: {
36
+ name: "Lattice",
37
+ short_name: "Lattice",
38
+ description: "Multi-machine agentic dashboard for Claude Code",
39
+ display: "standalone",
40
+ start_url: "/",
41
+ theme_color: "#0d0d0d",
42
+ background_color: "#0d0d0d",
43
+ icons: [
44
+ { src: "/icons/icon-192.svg", sizes: "192x192", type: "image/svg+xml", purpose: "maskable" },
45
+ { src: "/icons/icon-512.svg", sizes: "512x512", type: "image/svg+xml", purpose: "maskable" },
46
+ ],
47
+ },
48
+ }),
49
+ ],
7
50
  server: {
8
51
  host: "0.0.0.0",
9
52
  open: true,
@@ -11,9 +54,18 @@ export default defineConfig({
11
54
  "/ws": {
12
55
  target: "ws://localhost:7654",
13
56
  ws: true,
57
+ configure: function (proxy) {
58
+ proxy.on("error", function () {});
59
+ proxy.on("proxyReqWs", function (_proxyReq, _req, socket) {
60
+ socket.on("error", function () {});
61
+ });
62
+ },
14
63
  },
15
64
  "/api": {
16
65
  target: "http://localhost:7654",
66
+ configure: function (proxy) {
67
+ proxy.on("error", function () {});
68
+ },
17
69
  },
18
70
  },
19
71
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cryptiklemur/lattice",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Multi-machine agentic dashboard for Claude Code. Monitor sessions, manage MCP servers and skills, orchestrate across mesh-networked nodes.",
5
5
  "license": "MIT",
6
6
  "author": "Aaron Scherer <me@aaronscherer.me>",
@@ -14,7 +14,9 @@ import { ensureCerts } from "./tls";
14
14
  import type { ClientMessage, MeshMessage } from "@lattice/shared";
15
15
  import "./handlers/session";
16
16
  import "./handlers/chat";
17
- import { loadInterruptedSessions } from "./project/sdk-bridge";
17
+ import "./handlers/attachment";
18
+ import { loadInterruptedSessions, unwatchSessionLock } from "./project/sdk-bridge";
19
+ import { clearActiveSession, getActiveSession } from "./handlers/chat";
18
20
  import "./handlers/fs";
19
21
  import "./handlers/terminal";
20
22
  import "./handlers/settings";
@@ -25,9 +27,11 @@ import "./handlers/scheduler";
25
27
  import "./handlers/notes";
26
28
  import "./handlers/skills";
27
29
  import "./handlers/memory";
30
+ import "./handlers/editor";
28
31
  import { startScheduler } from "./features/scheduler";
29
32
  import { loadNotes } from "./features/sticky-notes";
30
33
  import { cleanupClientTerminals } from "./handlers/terminal";
34
+ import { cleanupClient as cleanupClientAttachments } from "./handlers/attachment";
31
35
 
32
36
  interface WsData {
33
37
  id: string;
@@ -299,8 +303,14 @@ export async function startDaemon(portOverride?: number | null): Promise<void> {
299
303
  }
300
304
  },
301
305
  close(ws: ServerWebSocket<WsData>) {
306
+ var activeSession = getActiveSession(ws.data.id);
307
+ if (activeSession) {
308
+ unwatchSessionLock(activeSession.sessionId);
309
+ }
310
+ clearActiveSession(ws.data.id);
302
311
  removeClient(ws.data.id);
303
312
  cleanupClientTerminals(ws.data.id);
313
+ cleanupClientAttachments(ws.data.id);
304
314
  console.log(`[lattice] Client disconnected: ${ws.data.id}`);
305
315
  },
306
316
  },
@@ -245,6 +245,29 @@ export function createTask(data: {
245
245
  return task;
246
246
  }
247
247
 
248
+ export function updateTask(taskId: string, data: { name?: string; prompt?: string; cron?: string }): ScheduledTask | null {
249
+ var task: ScheduledTask | null = null;
250
+ for (var i = 0; i < tasks.length; i++) {
251
+ if (tasks[i].id === taskId) {
252
+ task = tasks[i];
253
+ break;
254
+ }
255
+ }
256
+ if (!task) return null;
257
+
258
+ if (data.cron && data.cron !== task.cron) {
259
+ var parsed = parseCron(data.cron);
260
+ if (!parsed) return null;
261
+ task.cron = data.cron;
262
+ task.nextRunAt = task.enabled ? nextRunTime(data.cron) : null;
263
+ }
264
+ if (data.name !== undefined) task.name = data.name;
265
+ if (data.prompt !== undefined) task.prompt = data.prompt;
266
+ task.updatedAt = Date.now();
267
+ saveSchedules();
268
+ return task;
269
+ }
270
+
248
271
  export function deleteTask(taskId: string): boolean {
249
272
  var idx = -1;
250
273
  for (var i = 0; i < tasks.length; i++) {
@@ -61,17 +61,19 @@ function saveNotes(): void {
61
61
  }
62
62
  }
63
63
 
64
- export function listNotes(): StickyNote[] {
65
- return notes.slice();
64
+ export function listNotes(projectSlug?: string): StickyNote[] {
65
+ if (!projectSlug) return notes.slice();
66
+ return notes.filter(function (n) { return n.projectSlug === projectSlug; });
66
67
  }
67
68
 
68
- export function createNote(content: string): StickyNote {
69
+ export function createNote(content: string, projectSlug?: string): StickyNote {
69
70
  var now = Date.now();
70
71
  var note: StickyNote = {
71
72
  id: "note_" + now + "_" + randomBytes(3).toString("hex"),
72
73
  content,
73
74
  createdAt: now,
74
75
  updatedAt: now,
76
+ projectSlug,
75
77
  };
76
78
  notes.push(note);
77
79
  saveNotes();
@@ -0,0 +1,172 @@
1
+ import type { Attachment } from "@lattice/shared";
2
+ import type { AttachmentChunkMessage, AttachmentCompleteMessage, ClientMessage } from "@lattice/shared";
3
+ import { registerHandler } from "../ws/router";
4
+ import { sendTo } from "../ws/broadcast";
5
+
6
+ interface PendingUpload {
7
+ chunks: Map<number, Buffer>;
8
+ totalChunks: number;
9
+ receivedCount: number;
10
+ createdAt: number;
11
+ }
12
+
13
+ var stores = new Map<string, Map<string, PendingUpload>>();
14
+ var completed = new Map<string, Map<string, Attachment>>();
15
+
16
+ var TTL_MS = 5 * 60 * 1000;
17
+ var CLEANUP_INTERVAL_MS = 60 * 1000;
18
+
19
+ function getClientStore(clientId: string): Map<string, PendingUpload> {
20
+ var store = stores.get(clientId);
21
+ if (!store) {
22
+ store = new Map();
23
+ stores.set(clientId, store);
24
+ }
25
+ return store;
26
+ }
27
+
28
+ function getClientCompleted(clientId: string): Map<string, Attachment> {
29
+ var store = completed.get(clientId);
30
+ if (!store) {
31
+ store = new Map();
32
+ completed.set(clientId, store);
33
+ }
34
+ return store;
35
+ }
36
+
37
+ registerHandler("attachment", function (clientId: string, message: ClientMessage) {
38
+ if (message.type === "attachment:chunk") {
39
+ var msg = message as AttachmentChunkMessage;
40
+ var store = getClientStore(clientId);
41
+
42
+ var pending = store.get(msg.attachmentId);
43
+ if (!pending) {
44
+ pending = {
45
+ chunks: new Map(),
46
+ totalChunks: msg.totalChunks,
47
+ receivedCount: 0,
48
+ createdAt: Date.now(),
49
+ };
50
+ store.set(msg.attachmentId, pending);
51
+ }
52
+
53
+ if (pending.chunks.has(msg.chunkIndex)) {
54
+ sendTo(clientId, {
55
+ type: "attachment:error",
56
+ attachmentId: msg.attachmentId,
57
+ error: "Duplicate chunk index: " + msg.chunkIndex,
58
+ });
59
+ return;
60
+ }
61
+
62
+ pending.chunks.set(msg.chunkIndex, Buffer.from(msg.data, "base64"));
63
+ pending.receivedCount++;
64
+
65
+ sendTo(clientId, {
66
+ type: "attachment:progress",
67
+ attachmentId: msg.attachmentId,
68
+ received: pending.receivedCount,
69
+ total: pending.totalChunks,
70
+ });
71
+ return;
72
+ }
73
+
74
+ if (message.type === "attachment:complete") {
75
+ var msg = message as AttachmentCompleteMessage;
76
+ var store = getClientStore(clientId);
77
+ var pending = store.get(msg.attachmentId);
78
+
79
+ if (!pending) {
80
+ sendTo(clientId, {
81
+ type: "attachment:error",
82
+ attachmentId: msg.attachmentId,
83
+ error: "No chunks received for this attachment",
84
+ });
85
+ return;
86
+ }
87
+
88
+ if (pending.receivedCount !== pending.totalChunks) {
89
+ sendTo(clientId, {
90
+ type: "attachment:error",
91
+ attachmentId: msg.attachmentId,
92
+ error: "Missing chunks: received " + pending.receivedCount + " of " + pending.totalChunks,
93
+ });
94
+ return;
95
+ }
96
+
97
+ var buffers: Buffer[] = [];
98
+ for (var i = 0; i < pending.totalChunks; i++) {
99
+ var chunk = pending.chunks.get(i);
100
+ if (!chunk) {
101
+ sendTo(clientId, {
102
+ type: "attachment:error",
103
+ attachmentId: msg.attachmentId,
104
+ error: "Missing chunk at index " + i,
105
+ });
106
+ return;
107
+ }
108
+ buffers.push(chunk);
109
+ }
110
+
111
+ var assembled = Buffer.concat(buffers);
112
+ var isText = msg.attachmentType === "paste" || isTextMimeType(msg.mimeType);
113
+ var content = isText ? assembled.toString("utf-8") : assembled.toString("base64");
114
+
115
+ var attachment: Attachment = {
116
+ type: msg.attachmentType,
117
+ name: msg.name,
118
+ content,
119
+ mimeType: msg.mimeType,
120
+ size: msg.size,
121
+ lineCount: msg.lineCount,
122
+ };
123
+
124
+ var completedStore = getClientCompleted(clientId);
125
+ completedStore.set(msg.attachmentId, attachment);
126
+ store.delete(msg.attachmentId);
127
+ return;
128
+ }
129
+ });
130
+
131
+ function isTextMimeType(mime: string): boolean {
132
+ if (mime.startsWith("text/")) return true;
133
+ var textTypes = [
134
+ "application/json",
135
+ "application/xml",
136
+ "application/javascript",
137
+ "application/typescript",
138
+ "application/x-yaml",
139
+ "application/yaml",
140
+ "image/svg+xml",
141
+ ];
142
+ return textTypes.indexOf(mime) !== -1;
143
+ }
144
+
145
+ export function getAttachments(clientId: string, ids: string[]): Attachment[] {
146
+ var store = getClientCompleted(clientId);
147
+ var result: Attachment[] = [];
148
+ for (var i = 0; i < ids.length; i++) {
149
+ var att = store.get(ids[i]);
150
+ if (att) {
151
+ result.push(att);
152
+ store.delete(ids[i]);
153
+ }
154
+ }
155
+ return result;
156
+ }
157
+
158
+ export function cleanupClient(clientId: string): void {
159
+ stores.delete(clientId);
160
+ completed.delete(clientId);
161
+ }
162
+
163
+ setInterval(function () {
164
+ var now = Date.now();
165
+ stores.forEach(function (store) {
166
+ store.forEach(function (pending, id) {
167
+ if (now - pending.createdAt > TTL_MS) {
168
+ store.delete(id);
169
+ }
170
+ });
171
+ });
172
+ }, CLEANUP_INTERVAL_MS);
@@ -1,9 +1,10 @@
1
- import type { ChatSendMessage, ChatPermissionResponseMessage, ChatSetPermissionModeMessage, ClientMessage } from "@lattice/shared";
1
+ import type { ChatSendMessage, ChatPermissionResponseMessage, ChatSetPermissionModeMessage, ChatPromptResponseMessage, ClientMessage } from "@lattice/shared";
2
2
  import { registerHandler } from "../ws/router";
3
3
  import { sendTo } from "../ws/broadcast";
4
4
  import { getProjectBySlug } from "../project/registry";
5
5
  import { loadConfig } from "../config";
6
6
  import { startChatStream, getPendingPermission, deletePendingPermission, addAutoApprovedTool, setSessionPermissionOverride, getActiveStream, buildPermissionRule } from "../project/sdk-bridge";
7
+ import { getAttachments } from "./attachment";
7
8
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
9
  import { join } from "node:path";
9
10
 
@@ -122,10 +123,15 @@ registerHandler("chat", function (clientId: string, message: ClientMessage) {
122
123
  var config = loadConfig();
123
124
  var env = Object.assign({}, config.globalEnv, project.env);
124
125
 
126
+ var attachments = sendMsg.attachmentIds
127
+ ? getAttachments(clientId, sendMsg.attachmentIds)
128
+ : [];
129
+
125
130
  startChatStream({
126
131
  projectSlug: active.projectSlug,
127
132
  sessionId: active.sessionId,
128
133
  text: sendMsg.text,
134
+ attachments,
129
135
  clientId,
130
136
  cwd: project.path,
131
137
  env: Object.keys(env).length > 0 ? env : undefined,
@@ -137,7 +143,17 @@ registerHandler("chat", function (clientId: string, message: ClientMessage) {
137
143
  }
138
144
 
139
145
  if (message.type === "chat:cancel") {
140
- sendTo(clientId, { type: "chat:error", message: "Cancel not yet implemented." });
146
+ var active = activeSessionByClient.get(clientId);
147
+ if (!active) {
148
+ sendTo(clientId, { type: "chat:error", message: "No active session." });
149
+ return;
150
+ }
151
+ var stream = getActiveStream(active.sessionId);
152
+ if (!stream) {
153
+ sendTo(clientId, { type: "chat:error", message: "No active stream to cancel." });
154
+ return;
155
+ }
156
+ stream.interrupt().catch(function () {});
141
157
  return;
142
158
  }
143
159
 
@@ -176,6 +192,31 @@ registerHandler("chat", function (clientId: string, message: ClientMessage) {
176
192
  return;
177
193
  }
178
194
 
195
+ if (message.type === "chat:prompt_response") {
196
+ var promptRespMsg = message as ChatPromptResponseMessage;
197
+ var pendingPrompt = getPendingPermission(promptRespMsg.requestId);
198
+ if (!pendingPrompt || pendingPrompt.promptType !== "question") {
199
+ return;
200
+ }
201
+
202
+ var updatedInput = Object.assign({}, pendingPrompt.input, {
203
+ answers: promptRespMsg.answers,
204
+ });
205
+ if (promptRespMsg.annotations) {
206
+ (updatedInput as Record<string, unknown>).annotations = promptRespMsg.annotations;
207
+ }
208
+
209
+ pendingPrompt.resolve({
210
+ behavior: "allow",
211
+ updatedInput: updatedInput,
212
+ toolUseID: pendingPrompt.toolUseID,
213
+ });
214
+
215
+ sendTo(clientId, { type: "chat:prompt_resolved", requestId: promptRespMsg.requestId });
216
+ deletePendingPermission(promptRespMsg.requestId);
217
+ return;
218
+ }
219
+
179
220
  if (message.type === "chat:set_permission_mode") {
180
221
  var modeMsg = message as ChatSetPermissionModeMessage;
181
222
  var activeSession = activeSessionByClient.get(clientId);
@@ -0,0 +1,40 @@
1
+ import { execSync } from "node:child_process";
2
+ import type { ClientMessage, EditorDetectMessage } from "@lattice/shared";
3
+ import { registerHandler } from "../ws/router";
4
+ import { sendTo } from "../ws/broadcast";
5
+
6
+ var binaryNames: Record<string, string[]> = {
7
+ "vscode": ["code"],
8
+ "vscode-insiders": ["code-insiders"],
9
+ "cursor": ["cursor"],
10
+ "webstorm": ["webstorm", "webstorm.sh", "wstorm"],
11
+ "intellij": ["idea", "idea.sh"],
12
+ "pycharm": ["pycharm", "pycharm.sh", "charm"],
13
+ "goland": ["goland", "goland.sh"],
14
+ "notepad++": ["notepad++"],
15
+ "sublime": ["subl", "sublime_text"],
16
+ };
17
+
18
+ function detectEditorPath(editorType: string): string | null {
19
+ var names = binaryNames[editorType];
20
+ if (!names) return null;
21
+
22
+ for (var i = 0; i < names.length; i++) {
23
+ try {
24
+ var result = execSync("which " + names[i], { encoding: "utf-8", timeout: 3000 }).trim();
25
+ if (result) return result;
26
+ } catch {
27
+ // not found
28
+ }
29
+ }
30
+ return null;
31
+ }
32
+
33
+ registerHandler("editor", function (clientId: string, message: ClientMessage) {
34
+ if (message.type === "editor:detect") {
35
+ var detectMsg = message as EditorDetectMessage;
36
+ var detectedPath = detectEditorPath(detectMsg.editorType);
37
+ sendTo(clientId, { type: "editor:detect_result", editorType: detectMsg.editorType, path: detectedPath });
38
+ return;
39
+ }
40
+ });