@cryptiklemur/lattice 1.15.0 → 1.16.1

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 (64) hide show
  1. package/.github/workflows/ci.yml +51 -2
  2. package/bun.lock +9 -0
  3. package/client/src/components/analytics/QuickStats.tsx +3 -3
  4. package/client/src/components/analytics/charts/NodeFleetOverview.tsx +1 -1
  5. package/client/src/components/chat/ChatView.tsx +114 -6
  6. package/client/src/components/chat/Message.tsx +41 -6
  7. package/client/src/components/chat/PromptQuestion.tsx +4 -4
  8. package/client/src/components/chat/TodoCard.tsx +2 -2
  9. package/client/src/components/dashboard/DashboardView.tsx +2 -0
  10. package/client/src/components/project-settings/ProjectEnvironment.tsx +1 -1
  11. package/client/src/components/project-settings/ProjectRules.tsx +3 -3
  12. package/client/src/components/project-settings/ProjectSkills.tsx +2 -2
  13. package/client/src/components/settings/BudgetSettings.tsx +161 -0
  14. package/client/src/components/settings/Environment.tsx +1 -1
  15. package/client/src/components/settings/SettingsView.tsx +3 -0
  16. package/client/src/components/sidebar/SessionList.tsx +33 -12
  17. package/client/src/components/sidebar/SettingsSidebar.tsx +2 -1
  18. package/client/src/components/sidebar/Sidebar.tsx +152 -2
  19. package/client/src/components/sidebar/UserIsland.tsx +76 -37
  20. package/client/src/components/ui/IconPicker.tsx +9 -36
  21. package/client/src/components/workspace/BookmarksView.tsx +156 -0
  22. package/client/src/components/workspace/TabBar.tsx +34 -5
  23. package/client/src/components/workspace/WorkspaceView.tsx +29 -6
  24. package/client/src/hooks/useBookmarks.ts +57 -0
  25. package/client/src/hooks/useProjects.ts +1 -1
  26. package/client/src/hooks/useSession.ts +38 -1
  27. package/client/src/hooks/useTimeTick.ts +35 -0
  28. package/client/src/hooks/useVoiceRecorder.ts +17 -3
  29. package/client/src/hooks/useWorkspace.ts +10 -1
  30. package/client/src/stores/bookmarks.ts +45 -0
  31. package/client/src/stores/session.ts +24 -0
  32. package/client/src/stores/sidebar.ts +2 -2
  33. package/client/src/stores/workspace.ts +114 -3
  34. package/client/src/vite-env.d.ts +6 -0
  35. package/client/tsconfig.json +4 -0
  36. package/package.json +2 -1
  37. package/playwright.config.ts +19 -0
  38. package/server/src/analytics/engine.ts +43 -9
  39. package/server/src/daemon.ts +3 -0
  40. package/server/src/handlers/bookmarks.ts +50 -0
  41. package/server/src/handlers/chat.ts +64 -0
  42. package/server/src/handlers/fs.ts +1 -1
  43. package/server/src/handlers/memory.ts +1 -1
  44. package/server/src/handlers/mesh.ts +1 -1
  45. package/server/src/handlers/project-settings.ts +2 -2
  46. package/server/src/handlers/session.ts +2 -2
  47. package/server/src/handlers/settings.ts +5 -2
  48. package/server/src/handlers/skills.ts +1 -1
  49. package/server/src/project/bookmarks.ts +83 -0
  50. package/server/src/project/context-breakdown.ts +1 -1
  51. package/server/src/project/registry.ts +5 -5
  52. package/server/src/project/sdk-bridge.ts +15 -3
  53. package/server/src/project/session.ts +1 -1
  54. package/server/tsconfig.json +4 -0
  55. package/shared/src/messages.ts +53 -2
  56. package/shared/src/models.ts +14 -0
  57. package/shared/src/project-settings.ts +0 -1
  58. package/shared/tsconfig.json +4 -0
  59. package/tests/accessibility.spec.ts +77 -0
  60. package/tests/keyboard-shortcuts.spec.ts +74 -0
  61. package/tests/message-actions.spec.ts +112 -0
  62. package/tests/onboarding.spec.ts +72 -0
  63. package/tests/session-flow.spec.ts +117 -0
  64. package/tests/session-preview.spec.ts +83 -0
@@ -37,8 +37,14 @@ jobs:
37
37
  - name: Install dependencies
38
38
  run: bun install --frozen-lockfile
39
39
 
40
- - name: Typecheck
41
- run: bunx tsc --noEmit -p tsconfig.json
40
+ - name: Build shared types
41
+ run: bunx tsc -p shared/tsconfig.json
42
+
43
+ - name: Typecheck server
44
+ run: bunx tsc --noEmit -p server/tsconfig.json
45
+
46
+ - name: Typecheck client
47
+ run: bunx tsc --noEmit -p client/tsconfig.json
42
48
 
43
49
  build:
44
50
  name: Build
@@ -70,3 +76,46 @@ jobs:
70
76
 
71
77
  - name: Build client
72
78
  run: cd client && npx vite build
79
+
80
+ playwright:
81
+ name: Playwright Tests
82
+ runs-on: ubuntu-latest
83
+ continue-on-error: true
84
+ steps:
85
+ - name: Checkout
86
+ uses: actions/checkout@v5
87
+
88
+ - name: Setup Bun
89
+ uses: oven-sh/setup-bun@v2
90
+ with:
91
+ bun-version: latest
92
+
93
+ - name: Setup Node.js
94
+ uses: actions/setup-node@v4
95
+ with:
96
+ node-version: 22
97
+
98
+ - name: Cache bun install
99
+ uses: actions/cache@v4
100
+ with:
101
+ path: ~/.bun/install/cache
102
+ key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
103
+ restore-keys: |
104
+ ${{ runner.os }}-bun-
105
+
106
+ - name: Install dependencies
107
+ run: bun install --frozen-lockfile
108
+
109
+ - name: Install Playwright browsers
110
+ run: bunx playwright install --with-deps chromium
111
+
112
+ - name: Run Playwright tests
113
+ run: bunx playwright test
114
+
115
+ - name: Upload test results
116
+ if: always()
117
+ uses: actions/upload-artifact@v4
118
+ with:
119
+ name: playwright-results
120
+ path: test-results/
121
+ retention-days: 7
package/bun.lock CHANGED
@@ -5,6 +5,7 @@
5
5
  "": {
6
6
  "name": "@lattice/root",
7
7
  "devDependencies": {
8
+ "@playwright/test": "^1.52.0",
8
9
  "@semantic-release/commit-analyzer": "^13.0.1",
9
10
  "@semantic-release/github": "^12.0.6",
10
11
  "@semantic-release/npm": "^13.1.5",
@@ -352,6 +353,8 @@
352
353
 
353
354
  "@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
354
355
 
356
+ "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="],
357
+
355
358
  "@pnpm/config.env-replace": ["@pnpm/config.env-replace@1.1.0", "", {}, "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w=="],
356
359
 
357
360
  "@pnpm/network.ca-file": ["@pnpm/network.ca-file@1.0.2", "", { "dependencies": { "graceful-fs": "4.2.10" } }, "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA=="],
@@ -1316,6 +1319,10 @@
1316
1319
 
1317
1320
  "pkg-conf": ["pkg-conf@2.1.0", "", { "dependencies": { "find-up": "^2.0.0", "load-json-file": "^4.0.0" } }, "sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g=="],
1318
1321
 
1322
+ "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
1323
+
1324
+ "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
1325
+
1319
1326
  "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
1320
1327
 
1321
1328
  "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
@@ -2072,6 +2079,8 @@
2072
2079
 
2073
2080
  "pkg-conf/find-up": ["find-up@2.1.0", "", { "dependencies": { "locate-path": "^2.0.0" } }, "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ=="],
2074
2081
 
2082
+ "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
2083
+
2075
2084
  "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
2076
2085
 
2077
2086
  "qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
@@ -51,9 +51,9 @@ export function QuickStats() {
51
51
  var d = analytics.data;
52
52
  var colors = getChartColors();
53
53
 
54
- var costSparkData = d.costOverTime.slice(-7).map(function (e) { return { v: e.total }; });
55
- var sessionsSparkData = d.sessionsOverTime.slice(-7).map(function (e) { return { v: e.count }; });
56
- var tokensSparkData = d.tokensOverTime.slice(-7).map(function (e) { return { v: e.input + e.output }; });
54
+ var costSparkData = d.costOverTime.slice(-7).map(function (e: typeof d.costOverTime[number]) { return { v: e.total }; });
55
+ var sessionsSparkData = d.sessionsOverTime.slice(-7).map(function (e: typeof d.sessionsOverTime[number]) { return { v: e.count }; });
56
+ var tokensSparkData = d.tokensOverTime.slice(-7).map(function (e: typeof d.tokensOverTime[number]) { return { v: e.input + e.output }; });
57
57
 
58
58
  var totalTokens = d.totalTokens.input + d.totalTokens.output;
59
59
  var cacheHitPct = Math.round(d.cacheHitRate * 100);
@@ -67,7 +67,7 @@ export function NodeFleetOverview(props: NodeFleetOverviewProps) {
67
67
  <div className="mt-2.5 pt-2 border-t border-base-content/5">
68
68
  <div className="text-[9px] font-mono text-base-content/25 uppercase tracking-widest mb-1.5">Projects</div>
69
69
  <div className="flex flex-wrap gap-1">
70
- {node.projects.map(function (p) {
70
+ {node.projects.map(function (p: typeof node.projects[number]) {
71
71
  return (
72
72
  <span
73
73
  key={p.slug}
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useRef, useCallback, useState, useMemo } from "react";
2
- import { Terminal, Info, ArrowDown, Pencil, Copy, Check, Menu, AlertTriangle, Zap, Square, X } from "lucide-react";
2
+ import { Terminal, Info, ArrowDown, Pencil, Copy, Check, Menu, AlertTriangle, Zap, Square, X, Bookmark } from "lucide-react";
3
3
  import { LatticeLogomark } from "../ui/LatticeLogomark";
4
4
  import { useFocusTrap } from "../../hooks/useFocusTrap";
5
5
  import { useVirtualizer } from "@tanstack/react-virtual";
@@ -8,7 +8,7 @@ import { useProjects } from "../../hooks/useProjects";
8
8
  import { useWebSocket } from "../../hooks/useWebSocket";
9
9
  import { setSessionTitle, setIsProcessing, setCurrentStatus, setWasInterrupted, setPendingPrefill } from "../../stores/session";
10
10
  import { openSettings, openProjectSettings } from "../../stores/sidebar";
11
- import { openTab } from "../../stores/workspace";
11
+ import { openTab, updateSessionTabTitle } from "../../stores/workspace";
12
12
  import { builtinCommands } from "../../commands";
13
13
  import { Message } from "./Message";
14
14
  import { ToolGroup } from "./ToolGroup";
@@ -19,15 +19,26 @@ import { StatusBar } from "./StatusBar";
19
19
  import { useSidebar } from "../../hooks/useSidebar";
20
20
  import { useOnline } from "../../hooks/useOnline";
21
21
  import { useSpinnerVerb } from "../../hooks/useSpinnerVerb";
22
+ import { useBookmarks } from "../../hooks/useBookmarks";
22
23
  import { formatSessionTitle } from "../../utils/formatSessionTitle";
23
24
 
24
- export function ChatView() {
25
- var { messages, isProcessing, sendMessage, activeSessionId, activeSessionTitle, currentStatus, contextUsage, contextBreakdown, lastResponseCost, lastResponseDuration, historyLoading, wasInterrupted, promptSuggestion, failedInput, clearFailedInput, messageQueue, enqueueMessage, removeQueuedMessage, updateQueuedMessage, isBusy, isPlanMode, pendingPrefill } = useSession();
25
+ export function ChatView({ sessionId: tabSessionId, projectSlug: tabProjectSlug }: { sessionId?: string; projectSlug?: string } = {}) {
26
+ var { messages, isProcessing, sendMessage, activeSessionId, activeSessionTitle, currentStatus, contextUsage, contextBreakdown, lastResponseCost, lastResponseDuration, historyLoading, wasInterrupted, promptSuggestion, failedInput, clearFailedInput, messageQueue, enqueueMessage, removeQueuedMessage, updateQueuedMessage, isBusy, isPlanMode, pendingPrefill, activateSession, budgetStatus, budgetExceeded, sendBudgetOverride, dismissBudgetExceeded } = useSession();
26
27
  var { activeProject } = useProjects();
27
28
  var { toggleDrawer } = useSidebar();
29
+
30
+ useEffect(function () {
31
+ if (!tabSessionId || !tabProjectSlug) return;
32
+ if (activeSessionId === tabSessionId) return;
33
+ activateSession(tabProjectSlug, tabSessionId);
34
+ }, [tabSessionId, tabProjectSlug]);
28
35
  var online = useOnline();
29
36
  var ws = useWebSocket();
30
37
  var spinnerVerb = useSpinnerVerb(isProcessing);
38
+ var { bookmarks, requestSessionBookmarks } = useBookmarks();
39
+ var [showBookmarkDropdown, setShowBookmarkDropdown] = useState<boolean>(false);
40
+ var bookmarkDropdownRef = useRef<HTMLDivElement>(null);
41
+ var bookmarkBtnRef = useRef<HTMLButtonElement>(null);
31
42
  var scrollParentRef = useRef<HTMLDivElement>(null);
32
43
  var prevLengthRef = useRef<number>(0);
33
44
  var isLiveChatRef = useRef<boolean>(false);
@@ -50,6 +61,24 @@ export function ChatView() {
50
61
  }
51
62
  }, [pendingPrefill, historyLoading]);
52
63
 
64
+ useEffect(function () {
65
+ if (activeSessionId && !historyLoading) {
66
+ requestSessionBookmarks();
67
+ }
68
+ }, [activeSessionId, historyLoading]);
69
+
70
+ useEffect(function () {
71
+ if (!showBookmarkDropdown) return;
72
+ function handleClick(e: MouseEvent) {
73
+ var target = e.target as Node;
74
+ if (bookmarkDropdownRef.current && bookmarkDropdownRef.current.contains(target)) return;
75
+ if (bookmarkBtnRef.current && bookmarkBtnRef.current.contains(target)) return;
76
+ setShowBookmarkDropdown(false);
77
+ }
78
+ document.addEventListener("mousedown", handleClick);
79
+ return function () { document.removeEventListener("mousedown", handleClick); };
80
+ }, [showBookmarkDropdown]);
81
+
53
82
  var [copiedField, setCopiedField] = useState<string | null>(null);
54
83
  var [isRenaming, setIsRenaming] = useState<boolean>(false);
55
84
  var [renameValue, setRenameValue] = useState<string>("");
@@ -202,6 +231,7 @@ export function ChatView() {
202
231
  if (renameValue.trim() !== activeSessionTitle) {
203
232
  ws.send({ type: "session:rename", sessionId: activeSessionId, title: renameValue.trim() });
204
233
  setSessionTitle(renameValue.trim());
234
+ updateSessionTabTitle(activeSessionId, renameValue.trim());
205
235
  }
206
236
  }
207
237
  setIsRenaming(false);
@@ -309,6 +339,7 @@ export function ChatView() {
309
339
  if (args && activeSessionId) {
310
340
  ws.send({ type: "session:rename", sessionId: activeSessionId, title: args });
311
341
  setSessionTitle(args);
342
+ updateSessionTabTitle(activeSessionId, args);
312
343
  } else {
313
344
  handleRenameStart();
314
345
  }
@@ -420,6 +451,53 @@ export function ChatView() {
420
451
  <Pencil size={12} />
421
452
  </button>
422
453
  )}
454
+ {activeSessionId && bookmarks.length > 0 && (
455
+ <div className="relative">
456
+ <button
457
+ ref={bookmarkBtnRef}
458
+ onClick={function () { setShowBookmarkDropdown(!showBookmarkDropdown); }}
459
+ aria-label="View bookmarks"
460
+ className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-mono text-warning/70 hover:text-warning hover:bg-warning/10 transition-colors"
461
+ >
462
+ <Bookmark size={11} />
463
+ <span>{bookmarks.length}</span>
464
+ </button>
465
+ {showBookmarkDropdown && (
466
+ <div
467
+ ref={bookmarkDropdownRef}
468
+ className="absolute top-full left-0 mt-1 z-50 bg-base-300 border border-base-content/15 rounded-lg shadow-xl py-1 w-[280px] max-h-[300px] overflow-y-auto"
469
+ >
470
+ <div className="px-2.5 py-1.5 text-[10px] uppercase tracking-widest text-base-content/40 font-mono font-bold">
471
+ Bookmarks
472
+ </div>
473
+ {bookmarks.map(function (bm) {
474
+ return (
475
+ <button
476
+ key={bm.id}
477
+ type="button"
478
+ onClick={function () {
479
+ setShowBookmarkDropdown(false);
480
+ var el = document.getElementById("msg-" + bm.messageUuid);
481
+ if (el) {
482
+ el.scrollIntoView({ behavior: "smooth", block: "center" });
483
+ el.classList.add("ring-2", "ring-warning/40");
484
+ setTimeout(function () { el!.classList.remove("ring-2", "ring-warning/40"); }, 2000);
485
+ }
486
+ }}
487
+ className="flex items-start gap-2 w-full px-2.5 py-1.5 hover:bg-base-content/5 transition-colors text-left"
488
+ >
489
+ <Bookmark size={10} className="text-warning/60 mt-0.5 flex-shrink-0" />
490
+ <div className="min-w-0 flex-1">
491
+ <div className="text-[11px] text-base-content/60 truncate">{bm.messageText}</div>
492
+ <div className="text-[9px] text-base-content/30 font-mono">{bm.messageType}</div>
493
+ </div>
494
+ </button>
495
+ );
496
+ })}
497
+ </div>
498
+ )}
499
+ </div>
500
+ )}
423
501
  </>
424
502
  )}
425
503
  </div>
@@ -954,12 +1032,42 @@ export function ChatView() {
954
1032
  </div>
955
1033
  )}
956
1034
 
1035
+ {budgetExceeded && budgetStatus && (
1036
+ <div className="flex-shrink-0 border-t border-base-300 bg-warning/10 px-4 py-3 flex items-center gap-3">
1037
+ <AlertTriangle size={16} className="text-warning flex-shrink-0" />
1038
+ <div className="flex-1 min-w-0">
1039
+ <div className="text-[13px] text-base-content font-medium">
1040
+ Daily budget exceeded (${budgetStatus.dailySpend.toFixed(2)} / ${budgetStatus.dailyLimit.toFixed(2)})
1041
+ </div>
1042
+ <div className="text-[11px] text-base-content/50">Send anyway?</div>
1043
+ </div>
1044
+ <button
1045
+ onClick={sendBudgetOverride}
1046
+ className="px-3 py-1.5 rounded-lg bg-warning text-warning-content text-[12px] font-semibold hover:bg-warning/80 transition-colors"
1047
+ >
1048
+ Continue
1049
+ </button>
1050
+ <button
1051
+ onClick={dismissBudgetExceeded}
1052
+ className="px-3 py-1.5 rounded-lg border border-base-content/15 text-base-content/60 text-[12px] hover:bg-base-content/5 transition-colors"
1053
+ >
1054
+ Cancel
1055
+ </button>
1056
+ </div>
1057
+ )}
1058
+
957
1059
  <div className="flex-shrink-0 border-t border-base-300 bg-base-200 px-2 sm:px-4 pb-3 pt-2">
958
1060
  <ChatInput
959
1061
  sessionId={activeSessionId}
960
1062
  onSend={handleSend}
961
- disabled={!activeSessionId || !online || isBusy}
962
- disabledPlaceholder={isBusy ? "Session in use by another client..." : undefined}
1063
+ disabled={!activeSessionId || !online || isBusy || (budgetStatus !== null && budgetStatus.enforcement === "hard-block" && budgetStatus.dailySpend >= budgetStatus.dailyLimit)}
1064
+ disabledPlaceholder={
1065
+ isBusy
1066
+ ? "Session in use by another client..."
1067
+ : (budgetStatus !== null && budgetStatus.enforcement === "hard-block" && budgetStatus.dailySpend >= budgetStatus.dailyLimit)
1068
+ ? "Daily budget exceeded ($" + budgetStatus.dailySpend.toFixed(2) + " / $" + budgetStatus.dailyLimit.toFixed(2) + ")"
1069
+ : undefined
1070
+ }
963
1071
  failedInput={failedInput}
964
1072
  onFailedInputConsumed={clearFailedInput}
965
1073
  prefillText={prefillText}
@@ -1,10 +1,12 @@
1
- import { useState, useRef, useEffect, memo } from "react";
1
+ import { useState, useRef, useEffect, memo, useMemo } from "react";
2
2
  import Markdown from "react-markdown";
3
3
  import remarkGfm from "remark-gfm";
4
- import { Wrench, TriangleAlert, ChevronDown, ChevronRight, Check, X, Shield, Zap, Link, Copy, SquarePlus } from "lucide-react";
4
+ import { Wrench, TriangleAlert, ChevronDown, ChevronRight, Check, X, Shield, Zap, Link, Copy, SquarePlus, Bookmark, BookmarkCheck } from "lucide-react";
5
5
  import type { HistoryMessage, ChatPermissionResponseMessage } from "@lattice/shared";
6
+ import { useStore } from "@tanstack/react-store";
6
7
  import { useWebSocket } from "../../hooks/useWebSocket";
7
8
  import { getSessionStore, setPendingPrefill } from "../../stores/session";
9
+ import { getBookmarkStore, findBookmarkByUuid } from "../../stores/bookmarks";
8
10
  import { ToolResultRenderer } from "./ToolResultRenderer";
9
11
  import { formatToolSummary } from "./toolSummary";
10
12
  import { PromptQuestion } from "./PromptQuestion";
@@ -104,9 +106,17 @@ function stripMarkdown(text: string): string {
104
106
  .trim();
105
107
  }
106
108
 
107
- function MessageActions(props: { text: string; showNewSession?: boolean }) {
109
+ function MessageActions(props: { text: string; showNewSession?: boolean; messageUuid?: string; messageType?: "user" | "assistant" }) {
108
110
  var [copied, setCopied] = useState(false);
109
111
  var ws = useWebSocket();
112
+ var bookmarkState = useStore(getBookmarkStore(), function (s) { return s; });
113
+ var isBookmarked = useMemo(function () {
114
+ if (!props.messageUuid) return false;
115
+ for (var i = 0; i < bookmarkState.bookmarks.length; i++) {
116
+ if (bookmarkState.bookmarks[i].messageUuid === props.messageUuid) return true;
117
+ }
118
+ return false;
119
+ }, [props.messageUuid, bookmarkState.bookmarks]);
110
120
 
111
121
  function handleCopy(e: React.MouseEvent) {
112
122
  var content = e.shiftKey ? stripMarkdown(props.text) : props.text;
@@ -122,6 +132,26 @@ function MessageActions(props: { text: string; showNewSession?: boolean }) {
122
132
  ws.send({ type: "session:create", projectSlug: state.activeProjectSlug });
123
133
  }
124
134
 
135
+ function handleBookmarkToggle() {
136
+ var state = getSessionStore().state;
137
+ if (!state.activeSessionId || !state.activeProjectSlug || !props.messageUuid || !props.messageType) return;
138
+ if (isBookmarked) {
139
+ var bm = findBookmarkByUuid(props.messageUuid);
140
+ if (bm) {
141
+ ws.send({ type: "bookmark:remove", id: bm.id });
142
+ }
143
+ } else {
144
+ ws.send({
145
+ type: "bookmark:add",
146
+ sessionId: state.activeSessionId,
147
+ projectSlug: state.activeProjectSlug,
148
+ messageUuid: props.messageUuid,
149
+ messageText: props.text.slice(0, 100),
150
+ messageType: props.messageType,
151
+ });
152
+ }
153
+ }
154
+
125
155
  var btnClass = "opacity-0 group-hover/msg:opacity-100 transition-opacity duration-150 text-base-content/20 hover:text-base-content/50 cursor-pointer p-0.5 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-primary/30 focus-visible:outline-none rounded";
126
156
 
127
157
  return (
@@ -134,6 +164,11 @@ function MessageActions(props: { text: string; showNewSession?: boolean }) {
134
164
  <SquarePlus size={11} />
135
165
  </button>
136
166
  )}
167
+ {props.messageUuid && props.messageType && (
168
+ <button type="button" onClick={handleBookmarkToggle} className={btnClass + (isBookmarked ? " !opacity-100 !text-warning" : "")} title={isBookmarked ? "Remove bookmark" : "Bookmark message"}>
169
+ {isBookmarked ? <BookmarkCheck size={11} /> : <Bookmark size={11} />}
170
+ </button>
171
+ )}
137
172
  </>
138
173
  );
139
174
  }
@@ -179,7 +214,7 @@ function SkillMessage(props: { skillName: string; content: string; time: string
179
214
  <div className="chat-footer text-[10px] text-base-content/30 mt-0.5 flex items-center gap-1">
180
215
  {props.time}
181
216
  <MessageAnchor id={props.uuid} />
182
- <MessageActions text={"/" + props.skillName} showNewSession />
217
+ <MessageActions text={"/" + props.skillName} showNewSession messageUuid={props.uuid} messageType="user" />
183
218
  </div>
184
219
  )}
185
220
  </div>
@@ -205,7 +240,7 @@ function UserMessage(props: { message: HistoryMessage }) {
205
240
  <div className="chat-footer text-[10px] text-base-content/30 mt-0.5 flex items-center gap-1">
206
241
  {time}
207
242
  <MessageAnchor id={msg.uuid} />
208
- <MessageActions text={text} showNewSession />
243
+ <MessageActions text={text} showNewSession messageUuid={msg.uuid} messageType="user" />
209
244
  </div>
210
245
  )}
211
246
  </div>
@@ -256,7 +291,7 @@ function AssistantMessage(props: { message: HistoryMessage; responseCost?: numbe
256
291
  <span className="text-base-content/15">{formatTokenCount(msg.outputTokens)} out</span>
257
292
  )}
258
293
  <MessageAnchor id={msg.uuid} />
259
- <MessageActions text={msg.text || ""} showNewSession />
294
+ <MessageActions text={msg.text || ""} showNewSession messageUuid={msg.uuid} messageType="assistant" />
260
295
  </div>
261
296
  )}
262
297
  </div>
@@ -90,7 +90,7 @@ export function PromptQuestion(props: PromptQuestionProps) {
90
90
  )}
91
91
  <span className="text-[12px] text-base-content/50">{firstAnswer ? firstAnswer[0] : ""}</span>
92
92
  <span className="flex-1" />
93
- <span className="text-[12px] font-medium text-base-content/70">{firstAnswer ? firstAnswer[1] : ""}</span>
93
+ <span className="text-[12px] font-medium text-base-content/70">{firstAnswer ? String(firstAnswer[1]) : ""}</span>
94
94
  <ChevronDown
95
95
  size={12}
96
96
  className={"text-base-content/25 transition-transform duration-200 " + (expanded ? "rotate-180" : "")}
@@ -100,7 +100,7 @@ export function PromptQuestion(props: PromptQuestionProps) {
100
100
  {expanded && answeredQuestion && (
101
101
  <div className="px-4 pb-3 border-t border-base-content/5">
102
102
  <div className="flex flex-col gap-1 pt-2">
103
- {answeredQuestion.options.map(function (opt, oi) {
103
+ {answeredQuestion.options.map(function (opt: typeof answeredQuestion.options[number], oi: number) {
104
104
  var isChosen = firstAnswer && firstAnswer[1] === opt.label;
105
105
  return (
106
106
  <div
@@ -142,7 +142,7 @@ export function PromptQuestion(props: PromptQuestionProps) {
142
142
 
143
143
  return (
144
144
  <div className="px-5 py-2" aria-live="polite">
145
- {questions.map(function (q, qi) {
145
+ {questions.map(function (q: typeof questions[number], qi: number) {
146
146
  return (
147
147
  <div
148
148
  key={qi}
@@ -165,7 +165,7 @@ export function PromptQuestion(props: PromptQuestionProps) {
165
165
  aria-label={q.question}
166
166
  onKeyDown={function (e) { handleKeyDown(e, q.options.length, q, q.options); }}
167
167
  >
168
- {q.options.map(function (opt, oi) {
168
+ {q.options.map(function (opt: typeof q.options[number], oi: number) {
169
169
  var isSelected = selectedMulti.has(opt.label);
170
170
  var isFocused = focusIndex === oi;
171
171
  return (
@@ -19,7 +19,7 @@ export function TodoCard(props: TodoCardProps) {
19
19
  var todos = props.message.todos || [];
20
20
  if (todos.length === 0) return null;
21
21
 
22
- var completed = todos.filter(function (t) { return t.status === "completed"; }).length;
22
+ var completed = todos.filter(function (t: typeof todos[number]) { return t.status === "completed"; }).length;
23
23
  var total = todos.length;
24
24
 
25
25
  return (
@@ -32,7 +32,7 @@ export function TodoCard(props: TodoCardProps) {
32
32
  </div>
33
33
 
34
34
  <div className="px-3 py-2">
35
- {todos.map(function (todo) {
35
+ {todos.map(function (todo: typeof todos[number]) {
36
36
  return (
37
37
  <div
38
38
  key={todo.id}
@@ -3,6 +3,7 @@ import { useMesh } from "../../hooks/useMesh";
3
3
  import { useProjects } from "../../hooks/useProjects";
4
4
  import { useSidebar } from "../../hooks/useSidebar";
5
5
  import { useWebSocket } from "../../hooks/useWebSocket";
6
+ import { useTimeTick } from "../../hooks/useTimeTick";
6
7
  import { LatticeLogomark } from "../ui/LatticeLogomark";
7
8
  import { QuickStats } from "../analytics/QuickStats";
8
9
  import {
@@ -25,6 +26,7 @@ function relativeTime(ts: number): string {
25
26
  }
26
27
 
27
28
  export function DashboardView() {
29
+ useTimeTick();
28
30
  var { nodes } = useMesh();
29
31
  var { projects } = useProjects();
30
32
  var sidebar = useSidebar();
@@ -127,7 +127,7 @@ export function ProjectEnvironment({
127
127
  </div>
128
128
  <div className="flex gap-1.5 items-center">
129
129
  <div className="h-9 sm:h-7 px-3 bg-base-300/50 border border-base-content/10 rounded-xl flex items-center font-mono text-[12px] text-base-content/40 w-full">
130
- {v}
130
+ {String(v)}
131
131
  </div>
132
132
  </div>
133
133
  <span className="text-[10px] uppercase tracking-wider text-base-content/30 w-7 text-center hidden sm:block">
@@ -19,7 +19,7 @@ export function ProjectRules({
19
19
  var globalRules = settings.global.rules ?? [];
20
20
 
21
21
  var [rules, setRules] = useState<RuleEntry[]>(function () {
22
- return (settings.rules ?? []).map(function (r) {
22
+ return (settings.rules ?? []).map(function (r: { filename: string; content: string }) {
23
23
  return { filename: r.filename, content: r.content };
24
24
  });
25
25
  });
@@ -34,7 +34,7 @@ export function ProjectRules({
34
34
  if (save.saving) {
35
35
  save.confirmSave();
36
36
  } else {
37
- setRules((settings.rules ?? []).map(function (r) {
37
+ setRules((settings.rules ?? []).map(function (r: { filename: string; content: string }) {
38
38
  return { filename: r.filename, content: r.content };
39
39
  }));
40
40
  save.resetFromServer();
@@ -136,7 +136,7 @@ export function ProjectRules({
136
136
  )}
137
137
  {globalRules.length > 0 && (
138
138
  <div className="flex flex-col gap-1.5">
139
- {globalRules.map(function (rule, idx) {
139
+ {globalRules.map(function (rule: typeof globalRules[number], idx: number) {
140
140
  var isExpanded = expandedGlobal.has(idx);
141
141
  return (
142
142
  <div key={rule.filename + "-" + idx} className="border border-base-content/10 rounded-xl overflow-hidden">
@@ -46,7 +46,7 @@ export function ProjectSkills({ settings, projectSlug }: ProjectSkillsProps) {
46
46
  <div>
47
47
  <div className="text-[12px] font-semibold text-base-content/40 mb-2">Global Skills</div>
48
48
  <div className="space-y-2">
49
- {globalSkills.map(function (skill) {
49
+ {globalSkills.map(function (skill: typeof globalSkills[number]) {
50
50
  return (
51
51
  <SkillItem
52
52
  key={skill.path}
@@ -64,7 +64,7 @@ export function ProjectSkills({ settings, projectSlug }: ProjectSkillsProps) {
64
64
  <div>
65
65
  <div className="text-[12px] font-semibold text-base-content/40 mb-2">Project Skills</div>
66
66
  <div className="space-y-2">
67
- {projectSkills.map(function (skill) {
67
+ {projectSkills.map(function (skill: typeof projectSkills[number]) {
68
68
  return (
69
69
  <SkillItem
70
70
  key={skill.path}