@cryptiklemur/lattice 1.14.2 → 1.16.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 (81) hide show
  1. package/.github/workflows/ci.yml +121 -0
  2. package/bun.lock +14 -1
  3. package/client/src/App.tsx +2 -0
  4. package/client/src/components/analytics/ChartCard.tsx +6 -10
  5. package/client/src/components/analytics/QuickStats.tsx +3 -3
  6. package/client/src/components/analytics/charts/NodeFleetOverview.tsx +1 -1
  7. package/client/src/components/chat/ChatView.tsx +119 -7
  8. package/client/src/components/chat/Message.tsx +41 -6
  9. package/client/src/components/chat/PromptQuestion.tsx +4 -4
  10. package/client/src/components/chat/TodoCard.tsx +2 -2
  11. package/client/src/components/chat/ToolResultRenderer.tsx +6 -2
  12. package/client/src/components/dashboard/DashboardView.tsx +2 -0
  13. package/client/src/components/mesh/PairingDialog.tsx +6 -17
  14. package/client/src/components/project-settings/ProjectEnvironment.tsx +1 -1
  15. package/client/src/components/project-settings/ProjectMemory.tsx +10 -19
  16. package/client/src/components/project-settings/ProjectRules.tsx +3 -3
  17. package/client/src/components/project-settings/ProjectSkills.tsx +2 -2
  18. package/client/src/components/settings/BudgetSettings.tsx +161 -0
  19. package/client/src/components/settings/Environment.tsx +1 -1
  20. package/client/src/components/settings/SettingsView.tsx +3 -0
  21. package/client/src/components/sidebar/AddProjectModal.tsx +7 -11
  22. package/client/src/components/sidebar/NodeSettingsModal.tsx +6 -11
  23. package/client/src/components/sidebar/ProjectRail.tsx +11 -1
  24. package/client/src/components/sidebar/SessionList.tsx +33 -12
  25. package/client/src/components/sidebar/SettingsSidebar.tsx +2 -1
  26. package/client/src/components/sidebar/Sidebar.tsx +152 -2
  27. package/client/src/components/sidebar/UserIsland.tsx +76 -37
  28. package/client/src/components/ui/IconPicker.tsx +9 -36
  29. package/client/src/components/ui/KeyboardShortcuts.tsx +129 -0
  30. package/client/src/components/ui/Toast.tsx +22 -2
  31. package/client/src/components/workspace/BookmarksView.tsx +156 -0
  32. package/client/src/components/workspace/TabBar.tsx +34 -5
  33. package/client/src/components/workspace/TaskEditModal.tsx +7 -2
  34. package/client/src/components/workspace/WorkspaceView.tsx +29 -6
  35. package/client/src/hooks/useBookmarks.ts +57 -0
  36. package/client/src/hooks/useFocusTrap.ts +72 -0
  37. package/client/src/hooks/useProjects.ts +1 -1
  38. package/client/src/hooks/useSession.ts +38 -1
  39. package/client/src/hooks/useTimeTick.ts +35 -0
  40. package/client/src/hooks/useVoiceRecorder.ts +17 -3
  41. package/client/src/hooks/useWorkspace.ts +10 -1
  42. package/client/src/router.tsx +6 -11
  43. package/client/src/stores/bookmarks.ts +45 -0
  44. package/client/src/stores/session.ts +24 -0
  45. package/client/src/stores/sidebar.ts +2 -2
  46. package/client/src/stores/workspace.ts +114 -3
  47. package/client/src/vite-env.d.ts +6 -0
  48. package/client/tsconfig.json +4 -0
  49. package/package.json +2 -1
  50. package/playwright.config.ts +19 -0
  51. package/server/package.json +2 -0
  52. package/server/src/analytics/engine.ts +43 -9
  53. package/server/src/daemon.ts +11 -7
  54. package/server/src/handlers/bookmarks.ts +50 -0
  55. package/server/src/handlers/chat.ts +64 -0
  56. package/server/src/handlers/fs.ts +1 -1
  57. package/server/src/handlers/memory.ts +1 -1
  58. package/server/src/handlers/mesh.ts +1 -1
  59. package/server/src/handlers/project-settings.ts +2 -2
  60. package/server/src/handlers/session.ts +12 -11
  61. package/server/src/handlers/settings.ts +5 -2
  62. package/server/src/handlers/skills.ts +1 -1
  63. package/server/src/logger.ts +12 -0
  64. package/server/src/mesh/connector.ts +7 -6
  65. package/server/src/project/bookmarks.ts +83 -0
  66. package/server/src/project/context-breakdown.ts +1 -1
  67. package/server/src/project/registry.ts +5 -5
  68. package/server/src/project/sdk-bridge.ts +77 -6
  69. package/server/src/project/session.ts +6 -5
  70. package/server/src/ws/router.ts +5 -4
  71. package/server/tsconfig.json +4 -0
  72. package/shared/src/messages.ts +53 -2
  73. package/shared/src/models.ts +17 -1
  74. package/shared/src/project-settings.ts +0 -1
  75. package/shared/tsconfig.json +4 -0
  76. package/tests/accessibility.spec.ts +77 -0
  77. package/tests/keyboard-shortcuts.spec.ts +74 -0
  78. package/tests/message-actions.spec.ts +112 -0
  79. package/tests/onboarding.spec.ts +72 -0
  80. package/tests/session-flow.spec.ts +117 -0
  81. package/tests/session-preview.spec.ts +83 -0
@@ -0,0 +1,121 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+ branches:
9
+ - main
10
+
11
+ jobs:
12
+ typecheck:
13
+ name: Typecheck
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - name: Checkout
17
+ uses: actions/checkout@v5
18
+
19
+ - name: Setup Bun
20
+ uses: oven-sh/setup-bun@v2
21
+ with:
22
+ bun-version: latest
23
+
24
+ - name: Setup Node.js
25
+ uses: actions/setup-node@v4
26
+ with:
27
+ node-version: 22
28
+
29
+ - name: Cache bun install
30
+ uses: actions/cache@v4
31
+ with:
32
+ path: ~/.bun/install/cache
33
+ key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
34
+ restore-keys: |
35
+ ${{ runner.os }}-bun-
36
+
37
+ - name: Install dependencies
38
+ run: bun install --frozen-lockfile
39
+
40
+ - name: Typecheck shared
41
+ run: bunx tsc --noEmit -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
48
+
49
+ build:
50
+ name: Build
51
+ runs-on: ubuntu-latest
52
+ steps:
53
+ - name: Checkout
54
+ uses: actions/checkout@v5
55
+
56
+ - name: Setup Bun
57
+ uses: oven-sh/setup-bun@v2
58
+ with:
59
+ bun-version: latest
60
+
61
+ - name: Setup Node.js
62
+ uses: actions/setup-node@v4
63
+ with:
64
+ node-version: 22
65
+
66
+ - name: Cache bun install
67
+ uses: actions/cache@v4
68
+ with:
69
+ path: ~/.bun/install/cache
70
+ key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
71
+ restore-keys: |
72
+ ${{ runner.os }}-bun-
73
+
74
+ - name: Install dependencies
75
+ run: bun install --frozen-lockfile
76
+
77
+ - name: Build client
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",
@@ -55,11 +56,13 @@
55
56
  "@anthropic-ai/claude-agent-sdk": "^0.2.79",
56
57
  "@lattice/shared": "workspace:*",
57
58
  "bonjour-service": "^1.3.0",
59
+ "debug": "^4.4.3",
58
60
  "js-tiktoken": "^1.0.21",
59
61
  "node-pty": "^1.1.0",
60
62
  "qrcode": "^1.5.4",
61
63
  },
62
64
  "devDependencies": {
65
+ "@types/debug": "^4.1.13",
63
66
  "@types/qrcode": "^1.5.6",
64
67
  "bun-types": "latest",
65
68
  },
@@ -350,6 +353,8 @@
350
353
 
351
354
  "@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
352
355
 
356
+ "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="],
357
+
353
358
  "@pnpm/config.env-replace": ["@pnpm/config.env-replace@1.1.0", "", {}, "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w=="],
354
359
 
355
360
  "@pnpm/network.ca-file": ["@pnpm/network.ca-file@1.0.2", "", { "dependencies": { "graceful-fs": "4.2.10" } }, "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA=="],
@@ -508,7 +513,7 @@
508
513
 
509
514
  "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
510
515
 
511
- "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
516
+ "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
512
517
 
513
518
  "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
514
519
 
@@ -1314,6 +1319,10 @@
1314
1319
 
1315
1320
  "pkg-conf": ["pkg-conf@2.1.0", "", { "dependencies": { "find-up": "^2.0.0", "load-json-file": "^4.0.0" } }, "sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g=="],
1316
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
+
1317
1326
  "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
1318
1327
 
1319
1328
  "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
@@ -1774,6 +1783,8 @@
1774
1783
 
1775
1784
  "make-asynchronous/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
1776
1785
 
1786
+ "micromark/@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
1787
+
1777
1788
  "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
1778
1789
 
1779
1790
  "npm/@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="],
@@ -2068,6 +2079,8 @@
2068
2079
 
2069
2080
  "pkg-conf/find-up": ["find-up@2.1.0", "", { "dependencies": { "locate-path": "^2.0.0" } }, "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ=="],
2070
2081
 
2082
+ "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
2083
+
2071
2084
  "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
2072
2085
 
2073
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=="],
@@ -4,6 +4,7 @@ import { WebSocketProvider } from "./providers/WebSocketProvider";
4
4
  import { ErrorBoundary } from "./components/ui/ErrorBoundary";
5
5
  import { Toast, useToastState } from "./components/ui/Toast";
6
6
  import { CommandPalette } from "./components/ui/CommandPalette";
7
+ import { KeyboardShortcuts } from "./components/ui/KeyboardShortcuts";
7
8
  import { UpdatePrompt } from "./components/ui/UpdatePrompt";
8
9
 
9
10
  function AppInner() {
@@ -13,6 +14,7 @@ function AppInner() {
13
14
  <>
14
15
  <RouterProvider router={router} />
15
16
  <CommandPalette />
17
+ <KeyboardShortcuts />
16
18
  <Toast items={items} onDismiss={dismiss} />
17
19
  <UpdatePrompt />
18
20
  </>
@@ -1,4 +1,5 @@
1
- import { useState, useEffect, useRef, createContext, useContext } from "react";
1
+ import { useState, useEffect, useRef, createContext, useContext, useCallback } from "react";
2
+ import { useFocusTrap } from "../../hooks/useFocusTrap";
2
3
  import { Maximize2, Minimize2 } from "lucide-react";
3
4
  import type { ReactNode } from "react";
4
5
 
@@ -31,6 +32,9 @@ export function ChartCard(props: ChartCardProps) {
31
32
  var cardRef = useRef<HTMLDivElement>(null);
32
33
  var [originRect, setOriginRect] = useState<DOMRect | null>(null);
33
34
  var [animating, setAnimating] = useState(false);
35
+ var fullscreenModalRef = useRef<HTMLDivElement>(null);
36
+ var closeFullscreenCb = useCallback(function () { closeFullscreen(); }, []);
37
+ useFocusTrap(fullscreenModalRef, closeFullscreenCb, isFullscreen);
34
38
 
35
39
  function openFullscreen() {
36
40
  if (cardRef.current) {
@@ -54,15 +58,6 @@ export function ChartCard(props: ChartCardProps) {
54
58
  }, 250);
55
59
  }
56
60
 
57
- useEffect(function () {
58
- if (!isFullscreen) return;
59
- function handleKeyDown(e: KeyboardEvent) {
60
- if (e.key === "Escape") closeFullscreen();
61
- }
62
- document.addEventListener("keydown", handleKeyDown);
63
- return function () { document.removeEventListener("keydown", handleKeyDown); };
64
- }, [isFullscreen]);
65
-
66
61
  useEffect(function () {
67
62
  if (isFullscreen) {
68
63
  document.body.style.overflow = "hidden";
@@ -134,6 +129,7 @@ export function ChartCard(props: ChartCardProps) {
134
129
  onClick={closeFullscreen}
135
130
  />
136
131
  <div
132
+ ref={fullscreenModalRef}
137
133
  className="fixed inset-0 z-[9999] flex items-center justify-center p-8"
138
134
  style={{ pointerEvents: "none" }}
139
135
  role="dialog"
@@ -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,13 +1,14 @@
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
+ import { useFocusTrap } from "../../hooks/useFocusTrap";
4
5
  import { useVirtualizer } from "@tanstack/react-virtual";
5
6
  import { useSession } from "../../hooks/useSession";
6
7
  import { useProjects } from "../../hooks/useProjects";
7
8
  import { useWebSocket } from "../../hooks/useWebSocket";
8
9
  import { setSessionTitle, setIsProcessing, setCurrentStatus, setWasInterrupted, setPendingPrefill } from "../../stores/session";
9
10
  import { openSettings, openProjectSettings } from "../../stores/sidebar";
10
- import { openTab } from "../../stores/workspace";
11
+ import { openTab, updateSessionTabTitle } from "../../stores/workspace";
11
12
  import { builtinCommands } from "../../commands";
12
13
  import { Message } from "./Message";
13
14
  import { ToolGroup } from "./ToolGroup";
@@ -18,15 +19,26 @@ import { StatusBar } from "./StatusBar";
18
19
  import { useSidebar } from "../../hooks/useSidebar";
19
20
  import { useOnline } from "../../hooks/useOnline";
20
21
  import { useSpinnerVerb } from "../../hooks/useSpinnerVerb";
22
+ import { useBookmarks } from "../../hooks/useBookmarks";
21
23
  import { formatSessionTitle } from "../../utils/formatSessionTitle";
22
24
 
23
- export function ChatView() {
24
- 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();
25
27
  var { activeProject } = useProjects();
26
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]);
27
35
  var online = useOnline();
28
36
  var ws = useWebSocket();
29
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);
30
42
  var scrollParentRef = useRef<HTMLDivElement>(null);
31
43
  var prevLengthRef = useRef<number>(0);
32
44
  var isLiveChatRef = useRef<boolean>(false);
@@ -38,6 +50,9 @@ export function ChatView() {
38
50
  var [showInfo, setShowInfo] = useState<boolean>(false);
39
51
  var [confirmStopExternal, setConfirmStopExternal] = useState<boolean>(false);
40
52
  var [prefillText, setPrefillText] = useState<string | null>(null);
53
+ var stopExternalModalRef = useRef<HTMLDivElement>(null);
54
+ var closeStopExternal = useCallback(function () { setConfirmStopExternal(false); }, []);
55
+ useFocusTrap(stopExternalModalRef, closeStopExternal, confirmStopExternal);
41
56
 
42
57
  useEffect(function () {
43
58
  if (pendingPrefill && !historyLoading) {
@@ -46,6 +61,24 @@ export function ChatView() {
46
61
  }
47
62
  }, [pendingPrefill, historyLoading]);
48
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
+
49
82
  var [copiedField, setCopiedField] = useState<string | null>(null);
50
83
  var [isRenaming, setIsRenaming] = useState<boolean>(false);
51
84
  var [renameValue, setRenameValue] = useState<string>("");
@@ -198,6 +231,7 @@ export function ChatView() {
198
231
  if (renameValue.trim() !== activeSessionTitle) {
199
232
  ws.send({ type: "session:rename", sessionId: activeSessionId, title: renameValue.trim() });
200
233
  setSessionTitle(renameValue.trim());
234
+ updateSessionTabTitle(activeSessionId, renameValue.trim());
201
235
  }
202
236
  }
203
237
  setIsRenaming(false);
@@ -305,6 +339,7 @@ export function ChatView() {
305
339
  if (args && activeSessionId) {
306
340
  ws.send({ type: "session:rename", sessionId: activeSessionId, title: args });
307
341
  setSessionTitle(args);
342
+ updateSessionTabTitle(activeSessionId, args);
308
343
  } else {
309
344
  handleRenameStart();
310
345
  }
@@ -416,6 +451,53 @@ export function ChatView() {
416
451
  <Pencil size={12} />
417
452
  </button>
418
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
+ )}
419
501
  </>
420
502
  )}
421
503
  </div>
@@ -885,7 +967,7 @@ export function ChatView() {
885
967
  )}
886
968
 
887
969
  {confirmStopExternal && (
888
- <div className="fixed inset-0 z-[9999] flex items-center justify-center" role="dialog" aria-modal="true" aria-label="End External Process" onKeyDown={function (e) { if (e.key === "Escape") setConfirmStopExternal(false); }}>
970
+ <div ref={stopExternalModalRef} className="fixed inset-0 z-[9999] flex items-center justify-center" role="dialog" aria-modal="true" aria-label="End External Process">
889
971
  <div className="absolute inset-0 bg-black/50" onClick={function () { setConfirmStopExternal(false); }} />
890
972
  <div className="relative bg-base-200 border border-base-content/15 rounded-2xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden">
891
973
  <div className="px-5 py-4 border-b border-base-content/15">
@@ -950,12 +1032,42 @@ export function ChatView() {
950
1032
  </div>
951
1033
  )}
952
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
+
953
1059
  <div className="flex-shrink-0 border-t border-base-300 bg-base-200 px-2 sm:px-4 pb-3 pt-2">
954
1060
  <ChatInput
955
1061
  sessionId={activeSessionId}
956
1062
  onSend={handleSend}
957
- disabled={!activeSessionId || !online || isBusy}
958
- 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
+ }
959
1071
  failedInput={failedInput}
960
1072
  onFailedInputConsumed={clearFailedInput}
961
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}
@@ -1,4 +1,5 @@
1
- import { useState, useEffect } from "react";
1
+ import { useState, useEffect, useRef, useCallback } from "react";
2
+ import { useFocusTrap } from "../../hooks/useFocusTrap";
2
3
  import { createPortal } from "react-dom";
3
4
  import Markdown from "react-markdown";
4
5
  import remarkGfm from "remark-gfm";
@@ -175,6 +176,9 @@ function ImageRenderer(props: { path: string }) {
175
176
  var [error, setError] = useState(false);
176
177
  var [modalOpen, setModalOpen] = useState(false);
177
178
  var imgSrc = "/api/file?path=" + encodeURIComponent(props.path);
179
+ var imageModalRef = useRef<HTMLDivElement>(null);
180
+ var closeImageModal = useCallback(function () { setModalOpen(false); }, []);
181
+ useFocusTrap(imageModalRef, closeImageModal, modalOpen);
178
182
 
179
183
  useEffect(function () {
180
184
  if (!modalOpen) return;
@@ -213,9 +217,9 @@ function ImageRenderer(props: { path: string }) {
213
217
  </div>
214
218
  {modalOpen && createPortal(
215
219
  <div
220
+ ref={imageModalRef}
216
221
  className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 sm:p-8 cursor-pointer overscroll-contain"
217
222
  onClick={function () { setModalOpen(false); }}
218
- onKeyDown={function (e) { if (e.key === "Escape") setModalOpen(false); }}
219
223
  onWheel={function (e) { e.stopPropagation(); }}
220
224
  onTouchMove={function (e) { e.stopPropagation(); }}
221
225
  role="dialog"