@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,271 @@
1
+ import { useState, useRef, useEffect } from "react";
2
+ import { Check, Circle, CheckCircle2, MessageCircleQuestion, ChevronDown, Send } from "lucide-react";
3
+ import type { HistoryMessage } from "@lattice/shared";
4
+ import { useWebSocket } from "../../hooks/useWebSocket";
5
+ import { resolvePromptQuestion } from "../../stores/session";
6
+
7
+ interface PromptQuestionProps {
8
+ message: HistoryMessage;
9
+ }
10
+
11
+ var LETTERS = ["A", "B", "C", "D"];
12
+
13
+ export function PromptQuestion(props: PromptQuestionProps) {
14
+ var msg = props.message;
15
+ var { send } = useWebSocket();
16
+ var [otherText, setOtherText] = useState("");
17
+ var [selectedMulti, setSelectedMulti] = useState<Set<string>>(new Set());
18
+ var [focusIndex, setFocusIndex] = useState(-1);
19
+ var [expanded, setExpanded] = useState(false);
20
+ var optionsRef = useRef<HTMLDivElement>(null);
21
+
22
+ var questions = msg.promptQuestions || [];
23
+ var answers = msg.promptAnswers;
24
+ var status = msg.promptStatus || "pending";
25
+ var requestId = msg.toolId || "";
26
+
27
+ useEffect(function () {
28
+ if (status === "pending" && optionsRef.current) {
29
+ var firstBtn = optionsRef.current.querySelector("button");
30
+ if (firstBtn) firstBtn.focus();
31
+ }
32
+ }, [status]);
33
+
34
+ function submitAnswer(questionText: string, answer: string) {
35
+ var answerMap: Record<string, string> = {};
36
+ answerMap[questionText] = answer;
37
+ resolvePromptQuestion(requestId, answerMap);
38
+ send({
39
+ type: "chat:prompt_response",
40
+ requestId: requestId,
41
+ answers: answerMap,
42
+ });
43
+ }
44
+
45
+ function submitMultiSelect(questionText: string) {
46
+ var parts = Array.from(selectedMulti);
47
+ if (otherText.trim()) parts.push(otherText.trim());
48
+ submitAnswer(questionText, parts.join(", "));
49
+ }
50
+
51
+ function submitOther(questionText: string) {
52
+ if (!otherText.trim()) return;
53
+ submitAnswer(questionText, otherText.trim());
54
+ }
55
+
56
+ function handleKeyDown(e: React.KeyboardEvent, optionCount: number, q: { question: string; multiSelect: boolean }, options: Array<{ label: string }>) {
57
+ if (e.key === "ArrowDown" || e.key === "ArrowRight") {
58
+ e.preventDefault();
59
+ var next = focusIndex < optionCount - 1 ? focusIndex + 1 : 0;
60
+ setFocusIndex(next);
61
+ var btns = optionsRef.current?.querySelectorAll("[data-option]");
62
+ if (btns && btns[next]) (btns[next] as HTMLElement).focus();
63
+ }
64
+ if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
65
+ e.preventDefault();
66
+ var prev = focusIndex > 0 ? focusIndex - 1 : optionCount - 1;
67
+ setFocusIndex(prev);
68
+ var btns = optionsRef.current?.querySelectorAll("[data-option]");
69
+ if (btns && btns[prev]) (btns[prev] as HTMLElement).focus();
70
+ }
71
+ if (e.key === "Enter" && !q.multiSelect && focusIndex >= 0 && focusIndex < options.length) {
72
+ e.preventDefault();
73
+ submitAnswer(q.question, options[focusIndex].label);
74
+ }
75
+ }
76
+
77
+ if (status === "answered" && answers) {
78
+ var firstAnswer = Object.entries(answers)[0];
79
+ var answeredQuestion = questions.length > 0 ? questions[0] : null;
80
+ return (
81
+ <div className="px-5 py-1.5" aria-live="polite">
82
+ <div
83
+ className="rounded-xl border border-success/15 bg-base-300/40 overflow-hidden cursor-pointer transition-colors hover:bg-base-300/60"
84
+ onClick={function () { setExpanded(!expanded); }}
85
+ >
86
+ <div className="flex items-center gap-2.5 px-4 py-2.5">
87
+ <CheckCircle2 size={15} className="text-success/60 flex-shrink-0" />
88
+ {answeredQuestion && (
89
+ <span className="text-[10px] font-mono font-semibold uppercase tracking-wider text-base-content/30">{answeredQuestion.header}</span>
90
+ )}
91
+ <span className="text-[12px] text-base-content/50">{firstAnswer ? firstAnswer[0] : ""}</span>
92
+ <span className="flex-1" />
93
+ <span className="text-[12px] font-medium text-base-content/70">{firstAnswer ? firstAnswer[1] : ""}</span>
94
+ <ChevronDown
95
+ size={12}
96
+ className={"text-base-content/25 transition-transform duration-200 " + (expanded ? "rotate-180" : "")}
97
+ />
98
+ </div>
99
+
100
+ {expanded && answeredQuestion && (
101
+ <div className="px-4 pb-3 border-t border-base-content/5">
102
+ <div className="flex flex-col gap-1 pt-2">
103
+ {answeredQuestion.options.map(function (opt, oi) {
104
+ var isChosen = firstAnswer && firstAnswer[1] === opt.label;
105
+ return (
106
+ <div
107
+ key={oi}
108
+ className={
109
+ "flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-[12px] " +
110
+ (isChosen ? "bg-success/8 text-base-content/70" : "text-base-content/30")
111
+ }
112
+ >
113
+ <span className={
114
+ "w-5 h-5 rounded-md flex items-center justify-center text-[10px] font-mono font-bold flex-shrink-0 " +
115
+ (isChosen ? "bg-success/20 text-success/80" : "bg-base-content/5 text-base-content/20")
116
+ }>
117
+ {LETTERS[oi] || ""}
118
+ </span>
119
+ <span className={isChosen ? "font-medium" : ""}>{opt.label}</span>
120
+ {isChosen && <Check size={12} className="text-success/60 ml-auto" />}
121
+ </div>
122
+ );
123
+ })}
124
+ </div>
125
+ </div>
126
+ )}
127
+ </div>
128
+ </div>
129
+ );
130
+ }
131
+
132
+ if (status === "timed_out") {
133
+ return (
134
+ <div className="px-5 py-1.5">
135
+ <div className="flex items-center gap-2.5 px-4 py-2.5 rounded-xl bg-warning/5 border border-warning/15 text-[12px] text-warning/50 font-mono">
136
+ <Circle size={13} className="flex-shrink-0" />
137
+ Prompt timed out — no response sent
138
+ </div>
139
+ </div>
140
+ );
141
+ }
142
+
143
+ return (
144
+ <div className="px-5 py-2" aria-live="polite">
145
+ {questions.map(function (q, qi) {
146
+ return (
147
+ <div
148
+ key={qi}
149
+ className="rounded-xl border border-primary/15 bg-base-300/50 overflow-hidden shadow-[0_2px_8px_oklch(from_var(--color-primary)_l_c_h/0.06)]"
150
+ >
151
+ <div className="flex items-center gap-2.5 px-4 py-2.5 border-b border-base-content/5 bg-base-content/[0.02]">
152
+ <MessageCircleQuestion size={14} className="text-primary/40 flex-shrink-0" />
153
+ <span className="text-[10px] font-mono font-bold uppercase tracking-widest text-primary/35">{q.header}</span>
154
+ <span className="flex-1" />
155
+ <span className="text-[9px] font-mono text-base-content/20">select one</span>
156
+ </div>
157
+
158
+ <div className="px-4 py-3.5">
159
+ <div className="text-[13px] text-base-content/80 mb-3.5 leading-relaxed">{q.question}</div>
160
+
161
+ <div
162
+ ref={optionsRef}
163
+ className="flex flex-col gap-1.5"
164
+ role={q.multiSelect ? "group" : "radiogroup"}
165
+ aria-label={q.question}
166
+ onKeyDown={function (e) { handleKeyDown(e, q.options.length, q, q.options); }}
167
+ >
168
+ {q.options.map(function (opt, oi) {
169
+ var isSelected = selectedMulti.has(opt.label);
170
+ var isFocused = focusIndex === oi;
171
+ return (
172
+ <button
173
+ key={oi}
174
+ data-option={oi}
175
+ role={q.multiSelect ? "checkbox" : "radio"}
176
+ aria-checked={isSelected}
177
+ tabIndex={q.multiSelect ? 0 : (isFocused || (focusIndex === -1 && oi === 0) ? 0 : -1)}
178
+ onFocus={function () { setFocusIndex(oi); }}
179
+ onClick={function () {
180
+ if (q.multiSelect) {
181
+ setSelectedMulti(function (prev) {
182
+ var next = new Set(prev);
183
+ if (next.has(opt.label)) {
184
+ next.delete(opt.label);
185
+ } else {
186
+ next.add(opt.label);
187
+ }
188
+ return next;
189
+ });
190
+ } else {
191
+ submitAnswer(q.question, opt.label);
192
+ }
193
+ }}
194
+ className={
195
+ "group text-left flex items-start gap-3 px-3.5 py-2.5 rounded-lg text-[12px] cursor-pointer transition-all duration-150 outline-none " +
196
+ (isSelected
197
+ ? "bg-primary/10 border border-primary/25 text-base-content/85 shadow-[0_0_0_1px_oklch(from_var(--color-primary)_l_c_h/0.1)]"
198
+ : "bg-base-content/[0.02] border border-base-content/8 text-base-content/60 hover:bg-base-content/5 hover:border-base-content/15 hover:text-base-content/75") +
199
+ " focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-1 focus-visible:ring-offset-base-300"
200
+ }
201
+ >
202
+ <span className={
203
+ "w-5 h-5 rounded-md flex items-center justify-center text-[10px] font-mono font-bold flex-shrink-0 mt-px transition-colors duration-150 " +
204
+ (isSelected
205
+ ? "bg-primary/25 text-primary"
206
+ : "bg-base-content/6 text-base-content/30 group-hover:bg-base-content/10 group-hover:text-base-content/45")
207
+ }>
208
+ {q.multiSelect
209
+ ? (isSelected ? <Check size={11} /> : LETTERS[oi] || "")
210
+ : LETTERS[oi] || ""
211
+ }
212
+ </span>
213
+ <div className="flex-1 min-w-0">
214
+ <div className="font-medium leading-snug">{opt.label}</div>
215
+ {opt.description && (
216
+ <div className="text-[11px] text-base-content/35 mt-0.5 leading-relaxed group-hover:text-base-content/45 transition-colors duration-150">{opt.description}</div>
217
+ )}
218
+ </div>
219
+ </button>
220
+ );
221
+ })}
222
+
223
+ <div className="flex items-center gap-2 mt-1.5 pt-1.5 border-t border-base-content/5">
224
+ <input
225
+ type="text"
226
+ placeholder="Other..."
227
+ value={otherText}
228
+ onChange={function (e) { setOtherText(e.target.value); }}
229
+ onKeyDown={function (e) {
230
+ if (e.key === "Enter" && !q.multiSelect) {
231
+ submitOther(q.question);
232
+ }
233
+ }}
234
+ aria-label="Custom answer"
235
+ className="flex-1 bg-base-content/[0.02] border border-base-content/8 rounded-lg px-3 py-2 text-[12px] text-base-content/60 placeholder:text-base-content/20 outline-none focus:border-primary/30 focus:bg-base-content/[0.04] transition-colors duration-150"
236
+ />
237
+ {!q.multiSelect && otherText.trim() && (
238
+ <button
239
+ onClick={function () { submitOther(q.question); }}
240
+ className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-primary/15 text-primary text-[11px] font-medium hover:bg-primary/25 transition-colors duration-150 cursor-pointer focus-visible:ring-2 focus-visible:ring-primary/40"
241
+ aria-label="Submit custom answer"
242
+ >
243
+ <Send size={10} />
244
+ Send
245
+ </button>
246
+ )}
247
+ </div>
248
+
249
+ {q.multiSelect && (
250
+ <button
251
+ onClick={function () { submitMultiSelect(q.question); }}
252
+ disabled={selectedMulti.size === 0 && !otherText.trim()}
253
+ className={
254
+ "mt-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-[12px] font-medium transition-all duration-150 cursor-pointer " +
255
+ (selectedMulti.size > 0 || otherText.trim()
256
+ ? "bg-primary text-primary-content hover:bg-primary/85 shadow-sm focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 focus-visible:ring-offset-base-300"
257
+ : "bg-base-content/5 text-base-content/20 cursor-not-allowed")
258
+ }
259
+ >
260
+ <Send size={12} />
261
+ Submit ({selectedMulti.size} selected)
262
+ </button>
263
+ )}
264
+ </div>
265
+ </div>
266
+ </div>
267
+ );
268
+ })}
269
+ </div>
270
+ );
271
+ }
@@ -0,0 +1,57 @@
1
+ import { Circle, CircleDot, CheckCircle2, ListTodo } from "lucide-react";
2
+ import type { HistoryMessage } from "@lattice/shared";
3
+
4
+ interface TodoCardProps {
5
+ message: HistoryMessage;
6
+ }
7
+
8
+ function StatusIcon(props: { status: string }) {
9
+ if (props.status === "completed") {
10
+ return <CheckCircle2 size={13} className="text-success/70 flex-shrink-0" />;
11
+ }
12
+ if (props.status === "in_progress") {
13
+ return <CircleDot size={13} className="text-primary/70 flex-shrink-0 animate-pulse" />;
14
+ }
15
+ return <Circle size={13} className="text-base-content/25 flex-shrink-0" />;
16
+ }
17
+
18
+ export function TodoCard(props: TodoCardProps) {
19
+ var todos = props.message.todos || [];
20
+ if (todos.length === 0) return null;
21
+
22
+ var completed = todos.filter(function (t) { return t.status === "completed"; }).length;
23
+ var total = todos.length;
24
+
25
+ return (
26
+ <div className="px-5 py-2">
27
+ <div className="rounded-xl border border-base-content/8 bg-base-300/60 overflow-hidden shadow-sm">
28
+ <div className="flex items-center gap-2 px-4 py-2 border-b border-base-content/6 bg-base-content/3">
29
+ <ListTodo size={14} className="text-base-content/40 flex-shrink-0" />
30
+ <span className="text-[10px] font-mono font-semibold uppercase tracking-wider text-base-content/35">Tasks</span>
31
+ <span className="text-[10px] font-mono text-base-content/25">{completed}/{total}</span>
32
+ </div>
33
+
34
+ <div className="px-3 py-2">
35
+ {todos.map(function (todo) {
36
+ return (
37
+ <div
38
+ key={todo.id}
39
+ className={
40
+ "flex items-start gap-2 px-2 py-1.5 rounded-md text-[12px] " +
41
+ (todo.status === "completed" ? "text-base-content/35" : "text-base-content/65")
42
+ }
43
+ >
44
+ <div className="mt-0.5">
45
+ <StatusIcon status={todo.status} />
46
+ </div>
47
+ <span className={todo.status === "completed" ? "line-through" : ""}>
48
+ {todo.content}
49
+ </span>
50
+ </div>
51
+ );
52
+ })}
53
+ </div>
54
+ </div>
55
+ </div>
56
+ );
57
+ }
@@ -1,6 +1,7 @@
1
1
  import { useState, useEffect } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import Markdown from "react-markdown";
4
+ import remarkGfm from "remark-gfm";
4
5
  import { Columns2, AlignLeft, FileText, Image } from "lucide-react";
5
6
 
6
7
  interface ToolResultRendererProps {
@@ -324,7 +325,7 @@ export function ToolResultRenderer(props: ToolResultRendererProps) {
324
325
  <div className="px-2.5 py-2">
325
326
  <div className="text-[9px] text-base-content/25 uppercase tracking-wider font-semibold mb-0.5">Result</div>
326
327
  <div className="prose prose-sm max-w-none text-[11px] [&_table]:text-[11px] [&_th]:px-2 [&_th]:py-1 [&_td]:px-2 [&_td]:py-1 [&_th]:text-base-content/60 [&_td]:text-base-content/45 [&_table]:border-base-content/10 [&_th]:border-base-content/10 [&_td]:border-base-content/10 [&_th]:bg-base-100/50">
327
- <Markdown>{result}</Markdown>
328
+ <Markdown remarkPlugins={[remarkGfm]}>{result}</Markdown>
328
329
  </div>
329
330
  </div>
330
331
  );
@@ -0,0 +1,85 @@
1
+ import { Mic } from "lucide-react";
2
+
3
+ interface VoiceRecorderProps {
4
+ isRecording: boolean;
5
+ isSupported: boolean;
6
+ isSpeaking: boolean;
7
+ elapsed: number;
8
+ interimTranscript: string;
9
+ onStart: () => void;
10
+ onStop: () => void;
11
+ onCancel: () => void;
12
+ }
13
+
14
+ function formatTime(seconds: number): string {
15
+ var m = Math.floor(seconds / 60);
16
+ var s = seconds % 60;
17
+ return m + ":" + (s < 10 ? "0" : "") + s;
18
+ }
19
+
20
+ export function VoiceRecorder(props: VoiceRecorderProps) {
21
+ if (!props.isRecording) {
22
+ return (
23
+ <button
24
+ aria-label={props.isSupported ? "Start voice input" : "Voice input not supported"}
25
+ disabled={!props.isSupported}
26
+ onClick={props.onStart}
27
+ className={
28
+ "w-7 h-7 rounded-md flex items-center justify-center transition-colors flex-shrink-0 " +
29
+ (props.isSupported
30
+ ? "text-base-content/30 hover:text-base-content/50 border border-base-content/10 hover:border-base-content/20"
31
+ : "text-base-content/15 cursor-not-allowed")
32
+ }
33
+ title={props.isSupported ? "Voice input" : "Voice input not supported in this browser"}
34
+ >
35
+ <Mic size={13} />
36
+ </button>
37
+ );
38
+ }
39
+
40
+ return (
41
+ <div className="flex items-center gap-2 flex-1 min-w-0">
42
+ <button
43
+ aria-label="Stop recording"
44
+ onClick={props.onStop}
45
+ className="w-7 h-7 rounded-md flex items-center justify-center bg-error/15 text-error border border-error/30 animate-pulse flex-shrink-0"
46
+ >
47
+ <Mic size={13} />
48
+ </button>
49
+
50
+ <div className="flex items-center gap-[2px] h-5" aria-hidden="true">
51
+ {Array.from({ length: 8 }).map(function (_, i) {
52
+ return (
53
+ <div
54
+ key={i}
55
+ className={
56
+ "w-[2px] rounded-sm bg-error transition-all duration-300 " +
57
+ (props.isSpeaking ? "animate-waveform" : "")
58
+ }
59
+ style={{
60
+ height: props.isSpeaking ? undefined : "4px",
61
+ opacity: props.isSpeaking ? undefined : 0.3,
62
+ animationDelay: (i * 0.1) + "s",
63
+ }}
64
+ />
65
+ );
66
+ })}
67
+ </div>
68
+
69
+ <span className="text-[12px] text-error font-mono flex-shrink-0">{formatTime(props.elapsed)}</span>
70
+
71
+ {props.interimTranscript && (
72
+ <span className="text-[11px] text-base-content/30 truncate flex-1 min-w-0">
73
+ {props.interimTranscript.slice(-60)}
74
+ </span>
75
+ )}
76
+
77
+ <button
78
+ onClick={props.onCancel}
79
+ className="text-[11px] text-base-content/40 hover:text-base-content/60 border border-base-content/10 rounded-md px-2 py-0.5 flex-shrink-0"
80
+ >
81
+ Cancel
82
+ </button>
83
+ </div>
84
+ );
85
+ }
@@ -109,6 +109,20 @@ export function ProjectClaude({ settings, updateSection }: ProjectClaudeProps) {
109
109
  rows={10}
110
110
  className="w-full px-3 py-2.5 bg-base-300 border border-base-content/15 rounded-xl text-base-content text-[12px] font-mono leading-relaxed resize-y focus:border-primary focus-visible:outline-none transition-colors duration-[120ms]"
111
111
  />
112
+ {(function () {
113
+ var lineCount = claudeMd ? claudeMd.split("\n").length : 0;
114
+ var overLimit = lineCount > 200;
115
+ return (
116
+ <div className="flex items-center justify-between mt-1.5">
117
+ <div className={"text-[11px] " + (overLimit ? "text-warning" : "text-base-content/25")}>
118
+ {lineCount} line{lineCount !== 1 ? "s" : ""}{overLimit ? " — consider moving content to rules or imports" : ""}
119
+ </div>
120
+ {overLimit && (
121
+ <div className="text-[10px] text-warning/70">soft limit: 200 lines</div>
122
+ )}
123
+ </div>
124
+ );
125
+ })()}
112
126
  <button
113
127
  type="button"
114
128
  onClick={function () { setShowGlobalMd(!showGlobalMd); }}
@@ -1,6 +1,7 @@
1
1
  import { useState, useEffect } from "react";
2
- import { Plus, Trash2, Pencil, X, Loader2, Brain } from "lucide-react";
2
+ import { Plus, Trash2, Pencil, X, Loader2, Brain, ExternalLink } from "lucide-react";
3
3
  import Markdown from "react-markdown";
4
+ import remarkGfm from "remark-gfm";
4
5
  import { useWebSocket } from "../../hooks/useWebSocket";
5
6
  import type { ServerMessage } from "@lattice/shared";
6
7
 
@@ -167,7 +168,7 @@ function MemoryViewModal({
167
168
 
168
169
  <div className="px-5 py-4">
169
170
  <div className="prose prose-sm max-w-none prose-headings:text-base-content prose-headings:font-mono prose-p:text-base-content/70 prose-strong:text-base-content prose-code:text-base-content/60 prose-code:bg-base-100/50 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-[11px] prose-pre:bg-base-100 prose-pre:text-base-content/70 prose-pre:text-[11px] prose-a:text-primary prose-li:text-base-content/70 prose-li:marker:text-base-content/30 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
170
- <Markdown>{parsed.body}</Markdown>
171
+ <Markdown remarkPlugins={[remarkGfm]}>{parsed.body}</Markdown>
171
172
  </div>
172
173
  </div>
173
174
  </div>
@@ -404,6 +405,15 @@ export function ProjectMemory({ projectSlug }: ProjectMemoryProps) {
404
405
 
405
406
  return (
406
407
  <div className="py-2 space-y-6">
408
+ <a
409
+ href="https://docs.anthropic.com/en/docs/claude-code/memory"
410
+ target="_blank"
411
+ rel="noopener noreferrer"
412
+ className="text-[11px] text-base-content/30 hover:text-primary/70 flex items-center gap-1 transition-colors"
413
+ >
414
+ <ExternalLink size={11} />
415
+ Claude Code docs
416
+ </a>
407
417
  <div className="flex items-center justify-end">
408
418
  <button
409
419
  onClick={function () { setEditState({ memory: null, content: "" }); }}
@@ -0,0 +1,48 @@
1
+ import { useState } from "react";
2
+
3
+ interface ProjectNotificationsProps {
4
+ projectSlug?: string;
5
+ }
6
+
7
+ function loadMuted(slug: string): boolean {
8
+ return localStorage.getItem("lattice-mute-" + slug) === "1";
9
+ }
10
+
11
+ function saveMuted(slug: string, muted: boolean): void {
12
+ localStorage.setItem("lattice-mute-" + slug, muted ? "1" : "0");
13
+ }
14
+
15
+ export function ProjectNotifications({ projectSlug }: ProjectNotificationsProps) {
16
+ var slug = projectSlug ?? "";
17
+ var [muted, setMuted] = useState(function () { return loadMuted(slug); });
18
+
19
+ function toggle() {
20
+ var next = !muted;
21
+ setMuted(next);
22
+ saveMuted(slug, next);
23
+ }
24
+
25
+ return (
26
+ <div className="py-2 space-y-6">
27
+ <div>
28
+ <h2 className="text-[12px] font-semibold text-base-content/40 mb-3">
29
+ Project Notifications
30
+ </h2>
31
+ <div className="flex items-center justify-between py-2 px-3 bg-base-300 border border-base-content/15 rounded-xl">
32
+ <div>
33
+ <div className="text-[13px] text-base-content">Mute all notifications</div>
34
+ <div className="text-[11px] text-base-content/30 mt-0.5">
35
+ Suppress all browser notifications from this project
36
+ </div>
37
+ </div>
38
+ <input
39
+ type="checkbox"
40
+ className="toggle toggle-sm toggle-primary"
41
+ checked={muted}
42
+ onChange={toggle}
43
+ />
44
+ </div>
45
+ </div>
46
+ </div>
47
+ );
48
+ }
@@ -1,5 +1,5 @@
1
1
  import { useState, useEffect } from "react";
2
- import { X, Plus, ChevronDown, ChevronRight } from "lucide-react";
2
+ import { X, Plus, ChevronDown, ChevronRight, ExternalLink } from "lucide-react";
3
3
  import { SaveFooter } from "../ui/SaveFooter";
4
4
  import { useSaveState } from "../../hooks/useSaveState";
5
5
  import type { ProjectSettings } from "@lattice/shared";
@@ -116,6 +116,15 @@ export function ProjectRules({
116
116
 
117
117
  return (
118
118
  <div className="py-2">
119
+ <a
120
+ href="https://docs.anthropic.com/en/docs/claude-code/memory"
121
+ target="_blank"
122
+ rel="noopener noreferrer"
123
+ className="text-[11px] text-base-content/30 hover:text-primary/70 flex items-center gap-1 mb-4 transition-colors"
124
+ >
125
+ <ExternalLink size={11} />
126
+ Claude Code docs
127
+ </a>
119
128
  <div className="mb-6">
120
129
  <h2 className="text-[12px] font-semibold text-base-content/40 mb-3">
121
130
  Global Rules
@@ -11,9 +11,11 @@ import { ProjectSkills } from "./ProjectSkills";
11
11
  import { ProjectRules } from "./ProjectRules";
12
12
  import { ProjectMcp } from "./ProjectMcp";
13
13
  import { ProjectMemory } from "./ProjectMemory";
14
+ import { ProjectNotifications } from "./ProjectNotifications";
14
15
 
15
16
  var SECTION_CONFIG: Record<string, { title: string }> = {
16
17
  general: { title: "General" },
18
+ notifications: { title: "Notifications" },
17
19
  claude: { title: "Claude" },
18
20
  environment: { title: "Environment" },
19
21
  mcp: { title: "MCP Servers" },
@@ -61,6 +63,10 @@ function renderSection(
61
63
  return <ProjectMemory projectSlug={projectSlug} />;
62
64
  }
63
65
 
66
+ if (section === "notifications") {
67
+ return <ProjectNotifications projectSlug={projectSlug} />;
68
+ }
69
+
64
70
  return (
65
71
  <div className="py-2 text-[13px] text-base-content/40">
66
72
  {section} section coming soon.
@@ -146,6 +146,7 @@ export function Appearance() {
146
146
  currentThemeId={currentThemeId}
147
147
  onSelect={handleThemeSelect}
148
148
  />
149
+
149
150
  </div>
150
151
  );
151
152
  }
@@ -1,4 +1,5 @@
1
1
  import { useState, useEffect } from "react";
2
+ import { ExternalLink } from "lucide-react";
2
3
  import { useWebSocket } from "../../hooks/useWebSocket";
3
4
  import { useSaveState } from "../../hooks/useSaveState";
4
5
  import { SaveFooter } from "../ui/SaveFooter";
@@ -82,6 +83,15 @@ export function ClaudeSettings() {
82
83
 
83
84
  return (
84
85
  <div className="py-2">
86
+ <a
87
+ href="https://docs.anthropic.com/en/docs/claude-code/memory"
88
+ target="_blank"
89
+ rel="noopener noreferrer"
90
+ className="text-[11px] text-base-content/30 hover:text-primary/70 flex items-center gap-1 mb-4 transition-colors"
91
+ >
92
+ <ExternalLink size={11} />
93
+ Claude Code docs
94
+ </a>
85
95
  <div className="mb-5">
86
96
  <label htmlFor="claude-default-model" className="block text-[12px] font-semibold text-base-content/40 mb-2">Default Model</label>
87
97
  <select
@@ -138,6 +148,20 @@ export function ClaudeSettings() {
138
148
  rows={14}
139
149
  className="w-full px-3 py-2.5 bg-base-300 border border-base-content/15 rounded-xl text-base-content text-[12px] font-mono leading-relaxed resize-y focus:border-primary focus-visible:outline-none transition-colors duration-[120ms]"
140
150
  />
151
+ {(function () {
152
+ var lineCount = claudeMd ? claudeMd.split("\n").length : 0;
153
+ var overLimit = lineCount > 200;
154
+ return (
155
+ <div className="flex items-center justify-between mt-1.5">
156
+ <div className={"text-[11px] " + (overLimit ? "text-warning" : "text-base-content/25")}>
157
+ {lineCount} line{lineCount !== 1 ? "s" : ""}{overLimit ? " — consider moving content to rules or imports" : ""}
158
+ </div>
159
+ {overLimit && (
160
+ <div className="text-[10px] text-warning/70">soft limit: 200 lines</div>
161
+ )}
162
+ </div>
163
+ );
164
+ })()}
141
165
  </div>
142
166
 
143
167
  <SaveFooter