@cryptiklemur/lattice 1.17.1 → 1.18.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.
@@ -0,0 +1,35 @@
1
+ # Code Style and Conventions
2
+
3
+ ## JavaScript/TypeScript
4
+ - Use `const`/`let` (NOT `var`)
5
+ - Use named function declarations, not arrow functions — except for anonymous callbacks where arrows are fine
6
+ - ESM throughout (server + client)
7
+ - Follow .editorconfig: 2-space indent, LF line endings, UTF-8
8
+
9
+ ## Icons
10
+ - Use `lucide-react` for ALL icons
11
+ - Import individually: `import { Settings, Moon } from "lucide-react"`
12
+ - No emojis for icons or in UI
13
+
14
+ ## Comments
15
+ - No section separator comments (// ========)
16
+ - No organizational header comments
17
+ - Keep code clean without visual separators
18
+
19
+ ## Design System
20
+ - See `.impeccable.md` for comprehensive design guidelines
21
+ - Dark-first with 23 base16 themes via OKLCH CSS variables
22
+ - Tailwind + daisyUI component framework
23
+ - Typography: JetBrains Mono (headings/code), IBM Plex Sans (body)
24
+ - Three surface tiers: Chrome (base-200), Stage (base-100 + dot-grid), Elevated (base-300 + shadow)
25
+ - Never hardcode colors — always use CSS variables/theme tokens
26
+ - WCAG AA contrast, prefers-reduced-motion, keyboard-first
27
+
28
+ ## Types
29
+ - `HistoryMessage` uses a discriminated union type (`HistoryMessageType`)
30
+ - Shared types in `shared/src/models.ts`
31
+ - Message protocol in `shared/src/messages.ts`
32
+ - All `var` in existing code is legacy — new code should use `const`/`let`
33
+
34
+ ## Pre-existing Errors
35
+ - DO NOT EVER LEAVE PRE-EXISTING ERRORS. FIX THEM.
@@ -0,0 +1,51 @@
1
+ # Lattice — Project Overview
2
+
3
+ **Purpose**: Multi-machine agentic dashboard for Claude Code. Web UI for monitoring sessions, managing projects, tracking costs, and orchestrating across mesh-networked nodes.
4
+
5
+ **Status**: Alpha (pre-1.0), work directly on main branch.
6
+
7
+ ## Tech Stack
8
+
9
+ - **Runtime**: Bun (server), Node (CI), Vite (client bundler)
10
+ - **Server**: Bun WebSocket server with typed message protocol
11
+ - **Client**: React 19 + Vite 8 + Tailwind CSS + daisyUI
12
+ - **Shared**: TypeScript types and constants (no build step, imported directly)
13
+ - **State**: Tanstack Store (client), Tanstack Router (routing)
14
+ - **Sessions**: Claude Agent SDK (`@anthropic-ai/claude-agent-sdk`)
15
+ - **Charts**: Recharts
16
+ - **Testing**: Playwright (chromium only)
17
+ - **CI**: GitHub Actions (typecheck + build + Playwright)
18
+ - **Release**: semantic-release on push to main
19
+
20
+ ## Monorepo Structure
21
+
22
+ ```
23
+ lattice/
24
+ ├── shared/ — TypeScript types, message definitions, constants
25
+ ├── server/ — Bun WebSocket server, handlers, analytics, mesh networking
26
+ ├── client/ — React + Vite dashboard, UI components, 23 themes
27
+ ├── themes/ — Base16 theme JSON files (dark + light)
28
+ ├── tests/ — Playwright test files
29
+ └── docs/ — Screenshots, audit docs
30
+ ```
31
+
32
+ ## Architecture
33
+
34
+ - Server communicates with clients via WebSocket using typed messages defined in `shared/`
35
+ - Sessions managed through Claude Agent SDK
36
+ - 23 base16 themes with OKLCH color space
37
+ - PWA with service worker caching (Workbox)
38
+ - Mesh networking for multi-machine orchestration
39
+ - Structured logging with `debug` package (lattice:* namespaces)
40
+
41
+ ## Key Features
42
+
43
+ - Real-time chat with tool approval, context tracking, cost per message
44
+ - Session tabs with split-pane support
45
+ - Message bookmarks (per-session + global)
46
+ - Analytics dashboard (15+ chart types)
47
+ - Daily cost budget with configurable enforcement
48
+ - Keyboard shortcuts overlay (press ?)
49
+ - Session hover previews, auto-titling, date range search
50
+ - MCP server management, skill marketplace
51
+ - Memory management UI
@@ -0,0 +1,32 @@
1
+ # Suggested Commands
2
+
3
+ ## Development
4
+ - `bun run dev` — Start server (--watch) + Vite dev server, hot reloads both
5
+ - `bun run build` — Build all workspaces (only client produces output)
6
+ - `bun run typecheck` — Run tsc across all workspaces
7
+
8
+ ## Testing
9
+ - `bunx playwright test` — Run all Playwright tests (server must be running on :7654)
10
+ - `bunx playwright test tests/session-flow.spec.ts` — Run single test file
11
+
12
+ ## Type Checking (per-workspace, as CI does it)
13
+ - `bunx tsc -p shared/tsconfig.json` — Build shared types (must run first)
14
+ - `bunx tsc --noEmit -p server/tsconfig.json` — Typecheck server
15
+ - `bunx tsc --noEmit -p client/tsconfig.json` — Typecheck client
16
+
17
+ ## Client Build
18
+ - `cd client && npx vite build` — Build client for production
19
+
20
+ ## Server
21
+ - `bun run server/src/index.ts daemon` — Run server daemon directly
22
+ - `bun run server/src/index.ts daemon --port 8080` — Custom port
23
+ - Default port: 7654, binds to 0.0.0.0
24
+
25
+ ## Environment
26
+ - `ANTHROPIC_API_KEY` — Optional, uses `claude setup-token` if not set
27
+ - `DEBUG=lattice:*` — Enable structured debug logging
28
+
29
+ ## Git
30
+ - Commit messages: Angular Commit Convention (feat/fix/refactor/etc)
31
+ - Releases: automated via semantic-release on push to main
32
+ - Never add AI attribution to commits
@@ -0,0 +1,15 @@
1
+ # Task Completion Checklist
2
+
3
+ When a coding task is completed, verify the following:
4
+
5
+ 1. **Type check** — Run `bunx tsc --noEmit -p client/tsconfig.json` and `bunx tsc --noEmit -p server/tsconfig.json` (build shared first with `bunx tsc -p shared/tsconfig.json`)
6
+ 2. **Build** — Run `cd client && npx vite build` to verify client builds
7
+ 3. **Server build** — Run `bun build server/src/index.ts --target=bun --outdir=/tmp/check` to verify server bundles
8
+ 4. **No pre-existing errors** — Fix any errors you encounter, don't leave them
9
+ 5. **Code style** — const/let (not var), named functions (not arrows except callbacks), lucide-react icons only
10
+ 6. **Git** — Only commit when explicitly asked. Angular Commit Convention. No AI attribution.
11
+
12
+ ## Important Notes
13
+ - The PWA service worker caches old bundles. After rebuilding client, users need to clear SW cache in browser to see changes.
14
+ - Server serves from `client/dist/` (built output), NOT from Vite dev server. Client changes require `npx vite build` to appear on port 7654.
15
+ - Vite dev server runs on :5173 separately but can't connect WebSocket without the Bun server on :7654.
@@ -136,3 +136,17 @@ symbol_info_budget:
136
136
  # list of regex patterns which, when matched, mark a memory entry as read‑only.
137
137
  # Extends the list from the global configuration, merging the two lists.
138
138
  read_only_memory_patterns: []
139
+
140
+ # list of regex patterns for memories to completely ignore.
141
+ # Matching memories will not appear in list_memories or activate_project output
142
+ # and cannot be accessed via read_memory or write_memory.
143
+ # To access ignored memory files, use the read_file tool on the raw file path.
144
+ # Extends the list from the global configuration, merging the two lists.
145
+ # Example: ["_archive/.*", "_episodes/.*"]
146
+ ignored_memory_patterns: []
147
+
148
+ # advanced configuration option allowing to configure language server-specific options.
149
+ # Maps the language key to the options.
150
+ # Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
151
+ # No documentation on options means no options are available.
152
+ ls_specific_settings: {}
@@ -12,6 +12,7 @@ import {
12
12
  } from "lucide-react";
13
13
  import type { ServerMessage, SessionSummary, LatticeConfig } from "@lattice/shared";
14
14
  import { formatSessionTitle } from "../../utils/formatSessionTitle";
15
+ import { openSessionTab } from "../../stores/workspace";
15
16
 
16
17
  function relativeTime(ts: number): string {
17
18
  var diff = Date.now() - ts;
@@ -157,7 +158,7 @@ export function DashboardView() {
157
158
  return (
158
159
  <button
159
160
  key={s.id}
160
- onClick={function () { sidebar.navigateToSession(s.projectSlug, s.id); }}
161
+ onClick={function () { openSessionTab(s.id, s.projectSlug, s.title); sidebar.navigateToSession(s.projectSlug, s.id); }}
161
162
  className="flex items-center gap-3 px-3 py-2 rounded-xl border border-base-content/15 bg-base-200 hover:border-base-content/30 transition-colors duration-[120ms] cursor-pointer text-left focus-visible:ring-2 focus-visible:ring-primary"
162
163
  >
163
164
  <MessageSquare size={12} className="text-base-content/30 flex-shrink-0" />
@@ -9,6 +9,7 @@ import {
9
9
  } from "lucide-react";
10
10
  import type { ProjectSettingsSection } from "../../stores/sidebar";
11
11
  import type { SessionSummary, ServerMessage } from "@lattice/shared";
12
+ import { openSessionTab } from "../../stores/workspace";
12
13
 
13
14
  function StatCard({ label, value, icon }: { label: string; value: string | number; icon: React.ReactNode }) {
14
15
  return (
@@ -74,9 +75,10 @@ export function ProjectDashboardView() {
74
75
  sidebar.openProjectSettings(section);
75
76
  }
76
77
 
77
- function goToSession(sessionId: string) {
78
+ function goToSession(s: SessionSummary) {
78
79
  if (activeProject) {
79
- sidebar.setActiveSessionId(sessionId);
80
+ openSessionTab(s.id, activeProject.slug, s.title);
81
+ sidebar.setActiveSessionId(s.id);
80
82
  }
81
83
  }
82
84
 
@@ -130,7 +132,7 @@ export function ProjectDashboardView() {
130
132
  return (
131
133
  <button
132
134
  key={s.id}
133
- onClick={function () { goToSession(s.id); }}
135
+ onClick={function () { goToSession(s); }}
134
136
  className="flex items-center gap-3 px-3 py-2 rounded-xl border border-base-content/15 bg-base-300 hover:border-base-content/30 transition-colors duration-[120ms] cursor-pointer text-left focus-visible:ring-2 focus-visible:ring-primary"
135
137
  >
136
138
  <MessageSquare size={12} className="text-base-content/30 flex-shrink-0" />
@@ -10,7 +10,7 @@ import { useSidebar } from "../../hooks/useSidebar";
10
10
  import { useSession } from "../../hooks/useSession";
11
11
  import { clearSession } from "../../stores/session";
12
12
  import { useOnline } from "../../hooks/useOnline";
13
- import { openTab, openSessionTab } from "../../stores/workspace";
13
+ import { openTab, openSessionTab, getWorkspaceStore } from "../../stores/workspace";
14
14
  import { getSidebarStore, goToAnalytics } from "../../stores/sidebar";
15
15
  import { ProjectRail } from "./ProjectRail";
16
16
  import { SessionList } from "./SessionList";
@@ -214,7 +214,13 @@ export function Sidebar({ onSessionSelect }: { onSessionSelect?: () => void }) {
214
214
  if (!sidebar.activeProjectSlug || !sidebar.activeSessionId) return;
215
215
  if (!activeProject) return;
216
216
  initialActivatedRef.current = true;
217
- openSessionTab(sidebar.activeSessionId, sidebar.activeProjectSlug, "Session");
217
+ const wsState = getWorkspaceStore().state;
218
+ const alreadyHasTab = wsState.tabs.some(function (t) {
219
+ return t.type === "chat" && t.sessionId === sidebar.activeSessionId;
220
+ });
221
+ if (!alreadyHasTab) {
222
+ openSessionTab(sidebar.activeSessionId, sidebar.activeProjectSlug, "Session");
223
+ }
218
224
  session.activateSession(sidebar.activeProjectSlug, sidebar.activeSessionId);
219
225
  }, [sidebar.activeProjectSlug, sidebar.activeSessionId, activeProject]);
220
226
 
@@ -0,0 +1,217 @@
1
+ import type { Tab, Pane, TabType, WorkspaceState } from "../stores/workspace";
2
+
3
+ const TAB_TYPES: Set<string> = new Set(["chat", "files", "terminal", "notes", "tasks", "bookmarks", "analytics"]);
4
+
5
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6
+
7
+ export function shortSessionId(uuid: string): string {
8
+ return uuid.slice(0, 8);
9
+ }
10
+
11
+ export function encodeWorkspaceUrl(
12
+ state: WorkspaceState,
13
+ tabs: Tab[],
14
+ primaryProjectSlug: string | null,
15
+ ): string {
16
+ if (tabs.length === 0) return "";
17
+ if (tabs.length === 1 && tabs[0].id === "chat" && !tabs[0].sessionId) return "";
18
+
19
+ const panes = state.panes;
20
+ const activePaneId = state.activePaneId;
21
+
22
+ function encodeTab(tab: Tab, isActive: boolean): string {
23
+ let token = tab.type;
24
+ if (tab.type === "chat" && tab.sessionId) {
25
+ const shortId = shortSessionId(tab.sessionId);
26
+ if (tab.projectSlug && tab.projectSlug !== primaryProjectSlug) {
27
+ token += ":" + shortId + "." + tab.projectSlug;
28
+ } else {
29
+ token += ":" + shortId;
30
+ }
31
+ }
32
+ if (isActive) {
33
+ token += "*";
34
+ }
35
+ return token;
36
+ }
37
+
38
+ const paneTokens: string[] = [];
39
+ for (let pi = 0; pi < panes.length; pi++) {
40
+ const pane = panes[pi];
41
+ const tabTokens: string[] = [];
42
+ for (let ti = 0; ti < pane.tabIds.length; ti++) {
43
+ const tabId = pane.tabIds[ti];
44
+ const tab = tabs.find(function (t) { return t.id === tabId; });
45
+ if (!tab) continue;
46
+ const isActive = tabId === pane.activeTabId;
47
+ tabTokens.push(encodeTab(tab, isActive));
48
+ }
49
+ paneTokens.push(tabTokens.join(","));
50
+ }
51
+
52
+ let result = paneTokens.join("|");
53
+
54
+ if (panes.length > 1 && state.splitDirection) {
55
+ const dirChar = state.splitDirection === "horizontal" ? "h" : "v";
56
+ let activePaneIndex = 0;
57
+ for (let i = 0; i < panes.length; i++) {
58
+ if (panes[i].id === activePaneId) {
59
+ activePaneIndex = i;
60
+ break;
61
+ }
62
+ }
63
+ result += "|" + dirChar + activePaneIndex;
64
+ }
65
+
66
+ return result;
67
+ }
68
+
69
+ export interface DecodedWorkspace {
70
+ tabs: Tab[];
71
+ panes: Pane[];
72
+ activePaneId: string;
73
+ splitDirection: "horizontal" | "vertical" | null;
74
+ }
75
+
76
+ export function decodeWorkspaceUrl(
77
+ tParam: string,
78
+ primaryProjectSlug: string | null,
79
+ resolveFullId: (shortId: string, projectSlug: string) => string | null,
80
+ ): DecodedWorkspace {
81
+ const segments = tParam.split("|");
82
+ const tabs: Tab[] = [];
83
+ const panes: Pane[] = [];
84
+ let splitDirection: "horizontal" | "vertical" | null = null;
85
+ let activePaneIndex = 0;
86
+
87
+ const labels: Record<TabType, string> = {
88
+ chat: "Chat",
89
+ files: "Files",
90
+ terminal: "Terminal",
91
+ notes: "Notes",
92
+ tasks: "Tasks",
93
+ bookmarks: "Bookmarks",
94
+ analytics: "Analytics",
95
+ };
96
+
97
+ let splitMeta: string | null = null;
98
+ const paneSegments: string[] = [];
99
+
100
+ for (let i = 0; i < segments.length; i++) {
101
+ const seg = segments[i];
102
+ if (seg.length <= 3 && /^[hv]\d$/.test(seg)) {
103
+ splitMeta = seg;
104
+ } else {
105
+ paneSegments.push(seg);
106
+ }
107
+ }
108
+
109
+ if (splitMeta) {
110
+ splitDirection = splitMeta[0] === "h" ? "horizontal" : "vertical";
111
+ activePaneIndex = parseInt(splitMeta[1], 10);
112
+ }
113
+
114
+ for (let pi = 0; pi < paneSegments.length; pi++) {
115
+ const paneId = "pane-" + (pi + 1);
116
+ const tabTokens = paneSegments[pi].split(",").filter(function (s) { return s.length > 0; });
117
+ const paneTabIds: string[] = [];
118
+ let paneActiveTabId = "";
119
+
120
+ for (let ti = 0; ti < tabTokens.length; ti++) {
121
+ let token = tabTokens[ti];
122
+ const isActive = token.endsWith("*");
123
+ if (isActive) {
124
+ token = token.slice(0, -1);
125
+ }
126
+
127
+ const colonIdx = token.indexOf(":");
128
+ let tabType: TabType;
129
+ let sessionShortId: string | null = null;
130
+ let projectSlug: string | null = null;
131
+
132
+ if (colonIdx !== -1) {
133
+ tabType = token.slice(0, colonIdx) as TabType;
134
+ const rest = token.slice(colonIdx + 1);
135
+ const dotIdx = rest.indexOf(".");
136
+ if (dotIdx !== -1) {
137
+ sessionShortId = rest.slice(0, dotIdx);
138
+ projectSlug = rest.slice(dotIdx + 1);
139
+ } else {
140
+ sessionShortId = rest;
141
+ projectSlug = primaryProjectSlug;
142
+ }
143
+ } else {
144
+ tabType = token as TabType;
145
+ }
146
+
147
+ if (!TAB_TYPES.has(tabType)) continue;
148
+
149
+ let tab: Tab;
150
+ if (tabType === "chat" && sessionShortId && projectSlug) {
151
+ const fullId = resolveFullId(sessionShortId, projectSlug);
152
+ const resolvedId = fullId || sessionShortId;
153
+ const tabId = "chat-" + resolvedId;
154
+ tab = {
155
+ id: tabId,
156
+ type: "chat",
157
+ label: "Session",
158
+ closeable: true,
159
+ sessionId: resolvedId,
160
+ projectSlug: projectSlug,
161
+ };
162
+ } else if (tabType === "chat") {
163
+ tab = { id: "chat", type: "chat", label: "Chat", closeable: false };
164
+ } else {
165
+ tab = {
166
+ id: tabType,
167
+ type: tabType,
168
+ label: labels[tabType],
169
+ closeable: true,
170
+ };
171
+ }
172
+
173
+ const existingIdx = tabs.findIndex(function (t) { return t.id === tab.id; });
174
+ if (existingIdx === -1) {
175
+ tabs.push(tab);
176
+ }
177
+ paneTabIds.push(tab.id);
178
+
179
+ if (isActive) {
180
+ paneActiveTabId = tab.id;
181
+ }
182
+ }
183
+
184
+ if (paneTabIds.length > 0) {
185
+ if (!paneActiveTabId) {
186
+ paneActiveTabId = paneTabIds[0];
187
+ }
188
+ panes.push({ id: paneId, tabIds: paneTabIds, activeTabId: paneActiveTabId });
189
+ }
190
+ }
191
+
192
+ if (panes.length === 0) {
193
+ const defaultTab: Tab = { id: "chat", type: "chat", label: "Chat", closeable: false };
194
+ tabs.push(defaultTab);
195
+ panes.push({ id: "pane-1", tabIds: ["chat"], activeTabId: "chat" });
196
+ }
197
+
198
+ const resolvedActivePaneIndex = Math.min(activePaneIndex, panes.length - 1);
199
+ const activePaneId = panes[resolvedActivePaneIndex].id;
200
+
201
+ if (panes.length < 2) {
202
+ splitDirection = null;
203
+ }
204
+
205
+ return { tabs, panes, activePaneId, splitDirection };
206
+ }
207
+
208
+ export function isLegacySessionUrl(pathname: string): { projectSlug: string; sessionId: string } | null {
209
+ const parts = pathname.split("/").filter(function (p) { return p.length > 0; });
210
+ if (parts.length < 2) return null;
211
+ if (parts[0] === "settings") return null;
212
+ if (parts[1] === "settings") return null;
213
+ if (UUID_PATTERN.test(parts[1])) {
214
+ return { projectSlug: parts[0], sessionId: parts[1] };
215
+ }
216
+ return null;
217
+ }
@@ -532,12 +532,6 @@ var projectRoute = createRoute({
532
532
  component: IndexPage,
533
533
  });
534
534
 
535
- var sessionRoute = createRoute({
536
- getParentRoute: function () { return rootRoute; },
537
- path: "/$projectSlug/$sessionId",
538
- component: IndexPage,
539
- });
540
-
541
535
  var settingsRoute = createRoute({
542
536
  getParentRoute: function () { return rootRoute; },
543
537
  path: "/settings/$section",
@@ -562,7 +556,7 @@ var projectSettingsIndexRoute = createRoute({
562
556
  component: IndexPage,
563
557
  });
564
558
 
565
- var routeTree = rootRoute.addChildren([indexRoute, settingsIndexRoute, settingsRoute, projectSettingsIndexRoute, projectSettingsRoute, projectRoute, sessionRoute]);
559
+ var routeTree = rootRoute.addChildren([indexRoute, settingsIndexRoute, settingsRoute, projectSettingsIndexRoute, projectSettingsRoute, projectRoute]);
566
560
 
567
561
  export var router = createRouter({ routeTree });
568
562
 
@@ -1,5 +1,8 @@
1
1
  import { Store } from "@tanstack/react-store";
2
2
  import type { ProjectSettingsSection } from "@lattice/shared";
3
+ import { encodeWorkspaceUrl, decodeWorkspaceUrl, isLegacySessionUrl, shortSessionId } from "../lib/workspace-url";
4
+ import type { DecodedWorkspace } from "../lib/workspace-url";
5
+ import { getWorkspaceStore, restoreWorkspace, setUrlSyncCallback } from "./workspace";
3
6
 
4
7
  export type { ProjectSettingsSection };
5
8
 
@@ -32,33 +35,82 @@ export interface SidebarState {
32
35
  confirmRemoveSlug: string | null;
33
36
  }
34
37
 
35
- var SETTINGS_SECTIONS: SettingsSection[] = ["appearance", "claude", "environment", "mcp", "skills", "nodes", "editor", "rules", "memory", "notifications", "budget"];
38
+ const SETTINGS_SECTIONS: SettingsSection[] = ["appearance", "claude", "environment", "mcp", "skills", "nodes", "editor", "rules", "memory", "notifications", "budget"];
39
+
40
+ interface ParsedUrl {
41
+ projectSlug: string | null;
42
+ sessionId: string | null;
43
+ settingsSection: SettingsSection | null;
44
+ projectSettingsSection: ProjectSettingsSection | null;
45
+ tParam: string | null;
46
+ decodedWorkspace: DecodedWorkspace | null;
47
+ }
48
+
49
+ function resolveFullIdFromNothing(_shortId: string, _projectSlug: string): string | null {
50
+ return null;
51
+ }
52
+
53
+ function parseInitialUrl(): ParsedUrl {
54
+ const path = window.location.pathname;
55
+ const parts = path.split("/").filter(function (p) { return p.length > 0; });
56
+ const searchParams = new URLSearchParams(window.location.search);
57
+ const tParam = searchParams.get("t");
36
58
 
37
- function parseInitialUrl(): { projectSlug: string | null; sessionId: string | null; settingsSection: SettingsSection | null; projectSettingsSection: ProjectSettingsSection | null } {
38
- var path = window.location.pathname;
39
- var parts = path.split("/").filter(function (p) { return p.length > 0; });
40
59
  if (parts[0] === "settings") {
41
- var section = parts[1] as SettingsSection;
60
+ const section = parts[1] as SettingsSection;
42
61
  if (section && SETTINGS_SECTIONS.indexOf(section) !== -1) {
43
- return { projectSlug: null, sessionId: null, settingsSection: section, projectSettingsSection: null };
62
+ return { projectSlug: null, sessionId: null, settingsSection: section, projectSettingsSection: null, tParam: null, decodedWorkspace: null };
44
63
  }
45
- return { projectSlug: null, sessionId: null, settingsSection: "appearance", projectSettingsSection: null };
64
+ return { projectSlug: null, sessionId: null, settingsSection: "appearance", projectSettingsSection: null, tParam: null, decodedWorkspace: null };
46
65
  }
66
+
47
67
  if (parts.length >= 2 && parts[1] === "settings") {
48
- return { projectSlug: parts[0], sessionId: null, settingsSection: null, projectSettingsSection: (parts[2] || "general") as ProjectSettingsSection };
68
+ return { projectSlug: parts[0], sessionId: null, settingsSection: null, projectSettingsSection: (parts[2] || "general") as ProjectSettingsSection, tParam: null, decodedWorkspace: null };
49
69
  }
50
- if (parts.length >= 2) {
51
- return { projectSlug: parts[0], sessionId: parts[1], settingsSection: null, projectSettingsSection: null };
70
+
71
+ const legacy = isLegacySessionUrl(path);
72
+ if (legacy && !tParam) {
73
+ const shortId = shortSessionId(legacy.sessionId);
74
+ const newUrl = "/" + legacy.projectSlug + "?t=chat:" + shortId + "*";
75
+ window.history.replaceState(null, "", newUrl);
76
+ const decoded = decodeWorkspaceUrl("chat:" + shortId + "*", legacy.projectSlug, resolveFullIdFromNothing);
77
+ return {
78
+ projectSlug: legacy.projectSlug,
79
+ sessionId: legacy.sessionId,
80
+ settingsSection: null,
81
+ projectSettingsSection: null,
82
+ tParam: "chat:" + shortId + "*",
83
+ decodedWorkspace: decoded,
84
+ };
52
85
  }
53
- if (parts.length === 1) {
54
- return { projectSlug: parts[0], sessionId: null, settingsSection: null, projectSettingsSection: null };
86
+
87
+ if (parts.length >= 1 && parts[0] !== "settings") {
88
+ const projectSlug = parts[0];
89
+ if (tParam) {
90
+ const decoded = decodeWorkspaceUrl(tParam, projectSlug, resolveFullIdFromNothing);
91
+ let sessionId: string | null = null;
92
+ const activePane = decoded.panes.find(function (p) { return p.id === decoded.activePaneId; });
93
+ if (activePane) {
94
+ const activeTab = decoded.tabs.find(function (t) { return t.id === activePane.activeTabId; });
95
+ if (activeTab && activeTab.type === "chat" && activeTab.sessionId) {
96
+ sessionId = activeTab.sessionId;
97
+ }
98
+ }
99
+ return { projectSlug, sessionId, settingsSection: null, projectSettingsSection: null, tParam, decodedWorkspace: decoded };
100
+ }
101
+ return { projectSlug, sessionId: null, settingsSection: null, projectSettingsSection: null, tParam: null, decodedWorkspace: null };
55
102
  }
56
- return { projectSlug: null, sessionId: null, settingsSection: null, projectSettingsSection: null };
103
+
104
+ return { projectSlug: null, sessionId: null, settingsSection: null, projectSettingsSection: null, tParam: null, decodedWorkspace: null };
57
105
  }
58
106
 
59
- var initialUrl = parseInitialUrl();
107
+ const initialUrl = parseInitialUrl();
60
108
 
61
- var sidebarStore = new Store<SidebarState>({
109
+ if (initialUrl.decodedWorkspace) {
110
+ restoreWorkspace(initialUrl.decodedWorkspace);
111
+ }
112
+
113
+ const sidebarStore = new Store<SidebarState>({
62
114
  activeProjectSlug: initialUrl.settingsSection ? null : initialUrl.projectSlug,
63
115
  activeSessionId: initialUrl.settingsSection ? null : initialUrl.sessionId,
64
116
  sidebarMode: (initialUrl.settingsSection || initialUrl.projectSettingsSection) ? "settings" : "project",
@@ -67,7 +119,7 @@ var sidebarStore = new Store<SidebarState>({
67
119
  : initialUrl.projectSettingsSection
68
120
  ? { type: "project-settings", section: initialUrl.projectSettingsSection }
69
121
  : initialUrl.projectSlug
70
- ? (initialUrl.sessionId ? { type: "chat" } : { type: "project-dashboard" })
122
+ ? (initialUrl.sessionId || initialUrl.tParam ? { type: "chat" } : { type: "project-dashboard" })
71
123
  : { type: "dashboard" },
72
124
  previousView: null,
73
125
  userMenuOpen: false,
@@ -78,19 +130,67 @@ var sidebarStore = new Store<SidebarState>({
78
130
  confirmRemoveSlug: null,
79
131
  });
80
132
 
81
- function pushUrl(projectSlug: string | null, sessionId: string | null): void {
82
- var path = "/";
133
+ let lastEncodedUrl = "";
134
+
135
+ function pushUrl(projectSlug: string | null, replace: boolean = false): void {
136
+ const wsState = getWorkspaceStore().state;
137
+ const encoded = encodeWorkspaceUrl(wsState, wsState.tabs, projectSlug);
138
+ let path = "/";
83
139
  if (projectSlug) {
84
140
  path = "/" + projectSlug;
85
- if (sessionId) {
86
- path = path + "/" + sessionId;
141
+ if (encoded) {
142
+ path += "?t=" + encoded;
87
143
  }
88
144
  }
89
- if (window.location.pathname !== path) {
90
- window.history.pushState(null, "", path);
145
+ const hash = window.location.hash;
146
+ const fullUrl = path + hash;
147
+ if (window.location.pathname + window.location.search !== path) {
148
+ if (replace) {
149
+ window.history.replaceState(null, "", fullUrl);
150
+ } else {
151
+ window.history.pushState(null, "", fullUrl);
152
+ }
91
153
  }
154
+ lastEncodedUrl = encoded;
92
155
  }
93
156
 
157
+ export function syncUrlFromWorkspace(): void {
158
+ const state = sidebarStore.state;
159
+ if (state.sidebarMode === "settings") return;
160
+ if (!state.activeProjectSlug) return;
161
+
162
+ const wsState = getWorkspaceStore().state;
163
+ const encoded = encodeWorkspaceUrl(wsState, wsState.tabs, state.activeProjectSlug);
164
+
165
+ if (encoded === lastEncodedUrl) return;
166
+
167
+ const isActiveTabChangeOnly = detectActiveTabChangeOnly(lastEncodedUrl, encoded);
168
+ lastEncodedUrl = encoded;
169
+
170
+ let path = "/" + state.activeProjectSlug;
171
+ if (encoded) {
172
+ path += "?t=" + encoded;
173
+ }
174
+ const hash = window.location.hash;
175
+ const fullUrl = path + hash;
176
+
177
+ if (window.location.pathname + window.location.search !== path) {
178
+ if (isActiveTabChangeOnly) {
179
+ window.history.replaceState(null, "", fullUrl);
180
+ } else {
181
+ window.history.pushState(null, "", fullUrl);
182
+ }
183
+ }
184
+ }
185
+
186
+ function detectActiveTabChangeOnly(oldEncoded: string, newEncoded: string): boolean {
187
+ const oldStripped = oldEncoded.replace(/\*/g, "");
188
+ const newStripped = newEncoded.replace(/\*/g, "");
189
+ return oldStripped === newStripped;
190
+ }
191
+
192
+ setUrlSyncCallback(syncUrlFromWorkspace);
193
+
94
194
  export function getSidebarStore(): Store<SidebarState> {
95
195
  return sidebarStore;
96
196
  }
@@ -107,11 +207,17 @@ export function setActiveProjectSlug(slug: string | null): void {
107
207
  projectDropdownOpen: false,
108
208
  };
109
209
  });
110
- pushUrl(slug, null);
210
+ let path = "/";
211
+ if (slug) {
212
+ path = "/" + slug;
213
+ }
214
+ if (window.location.pathname + window.location.search !== path) {
215
+ window.history.pushState(null, "", path);
216
+ }
217
+ lastEncodedUrl = "";
111
218
  }
112
219
 
113
220
  export function setActiveSessionId(sessionId: string | null): void {
114
- var state = sidebarStore.state;
115
221
  sidebarStore.setState(function (s) {
116
222
  return {
117
223
  ...s,
@@ -119,7 +225,6 @@ export function setActiveSessionId(sessionId: string | null): void {
119
225
  activeView: sessionId ? { type: "chat" } : s.activeView,
120
226
  };
121
227
  });
122
- pushUrl(state.activeProjectSlug, sessionId);
123
228
  }
124
229
 
125
230
  export function navigateToSession(projectSlug: string, sessionId: string): void {
@@ -134,7 +239,6 @@ export function navigateToSession(projectSlug: string, sessionId: string): void
134
239
  projectDropdownOpen: false,
135
240
  };
136
241
  });
137
- pushUrl(projectSlug, sessionId);
138
242
  }
139
243
 
140
244
  export function openSettings(section: SettingsSection): void {
@@ -148,7 +252,7 @@ export function openSettings(section: SettingsSection): void {
148
252
  projectDropdownOpen: false,
149
253
  };
150
254
  });
151
- var path = "/settings/" + section;
255
+ const path = "/settings/" + section;
152
256
  if (window.location.pathname !== path) {
153
257
  window.history.pushState(null, "", path);
154
258
  }
@@ -161,7 +265,7 @@ export function setSettingsSection(section: SettingsSection): void {
161
265
  activeView: { type: "settings", section: section },
162
266
  };
163
267
  });
164
- var path = "/settings/" + section;
268
+ const path = "/settings/" + section;
165
269
  if (window.location.pathname !== path) {
166
270
  window.history.replaceState(null, "", path);
167
271
  }
@@ -178,8 +282,8 @@ export function openProjectSettings(section: ProjectSettingsSection): void {
178
282
  projectDropdownOpen: false,
179
283
  };
180
284
  });
181
- var state = sidebarStore.state;
182
- var path = "/" + state.activeProjectSlug + "/settings/" + section;
285
+ const state = sidebarStore.state;
286
+ const path = "/" + state.activeProjectSlug + "/settings/" + section;
183
287
  if (window.location.pathname !== path) {
184
288
  window.history.pushState(null, "", path);
185
289
  }
@@ -192,16 +296,16 @@ export function setProjectSettingsSection(section: ProjectSettingsSection): void
192
296
  activeView: { type: "project-settings", section: section },
193
297
  };
194
298
  });
195
- var state = sidebarStore.state;
196
- var path = "/" + state.activeProjectSlug + "/settings/" + section;
299
+ const state = sidebarStore.state;
300
+ const path = "/" + state.activeProjectSlug + "/settings/" + section;
197
301
  if (window.location.pathname !== path) {
198
302
  window.history.replaceState(null, "", path);
199
303
  }
200
304
  }
201
305
 
202
306
  export function exitSettings(): void {
203
- var state = sidebarStore.state;
204
- var restored = state.previousView ?? { type: "chat" } as ActiveView;
307
+ const state = sidebarStore.state;
308
+ const restored = state.previousView ?? { type: "chat" } as ActiveView;
205
309
  sidebarStore.setState(function (s) {
206
310
  return {
207
311
  ...s,
@@ -210,11 +314,7 @@ export function exitSettings(): void {
210
314
  previousView: null,
211
315
  };
212
316
  });
213
- if (state.activeView.type === "project-settings") {
214
- pushUrl(state.activeProjectSlug, null);
215
- } else {
216
- pushUrl(state.activeProjectSlug, state.activeSessionId);
217
- }
317
+ pushUrl(state.activeProjectSlug);
218
318
  }
219
319
 
220
320
  export function goToProjectDashboard(): void {
@@ -228,8 +328,15 @@ export function goToProjectDashboard(): void {
228
328
  projectDropdownOpen: false,
229
329
  };
230
330
  });
231
- var state = sidebarStore.state;
232
- pushUrl(state.activeProjectSlug, null);
331
+ const state = sidebarStore.state;
332
+ let path = "/";
333
+ if (state.activeProjectSlug) {
334
+ path = "/" + state.activeProjectSlug;
335
+ }
336
+ if (window.location.pathname + window.location.search !== path) {
337
+ window.history.pushState(null, "", path);
338
+ }
339
+ lastEncodedUrl = "";
233
340
  }
234
341
 
235
342
  export function goToDashboard(): void {
@@ -244,54 +351,96 @@ export function goToDashboard(): void {
244
351
  projectDropdownOpen: false,
245
352
  };
246
353
  });
247
- pushUrl(null, null);
354
+ if (window.location.pathname + window.location.search !== "/") {
355
+ window.history.pushState(null, "", "/");
356
+ }
357
+ lastEncodedUrl = "";
248
358
  }
249
359
 
250
360
  export function goToAnalytics(): void {
251
361
  sidebarStore.setState(function (state) {
252
362
  return { ...state, activeView: { type: "analytics" }, sidebarMode: "project" };
253
363
  });
254
- pushUrl(sidebarStore.state.activeProjectSlug, null);
364
+ pushUrl(sidebarStore.state.activeProjectSlug);
255
365
  }
256
366
 
257
367
  export function handlePopState(): void {
258
- var url = parseInitialUrl();
259
- if (url.settingsSection) {
368
+ const path = window.location.pathname;
369
+ const parts = path.split("/").filter(function (p) { return p.length > 0; });
370
+ const searchParams = new URLSearchParams(window.location.search);
371
+ const tParam = searchParams.get("t");
372
+
373
+ if (parts[0] === "settings") {
374
+ const section = (parts[1] || "appearance") as SettingsSection;
260
375
  sidebarStore.setState(function (state) {
261
376
  return {
262
377
  ...state,
263
378
  sidebarMode: "settings",
264
- activeView: { type: "settings", section: url.settingsSection! },
379
+ activeView: { type: "settings", section: section },
265
380
  userMenuOpen: false,
266
381
  projectDropdownOpen: false,
267
382
  };
268
383
  });
269
- } else if (url.projectSettingsSection) {
384
+ return;
385
+ }
386
+
387
+ if (parts.length >= 2 && parts[1] === "settings") {
388
+ const projectSettingsSection = (parts[2] || "general") as ProjectSettingsSection;
270
389
  sidebarStore.setState(function (state) {
271
390
  return {
272
391
  ...state,
273
- activeProjectSlug: url.projectSlug,
392
+ activeProjectSlug: parts[0],
274
393
  sidebarMode: "settings",
275
- activeView: { type: "project-settings", section: url.projectSettingsSection! },
394
+ activeView: { type: "project-settings", section: projectSettingsSection },
276
395
  userMenuOpen: false,
277
396
  projectDropdownOpen: false,
278
397
  };
279
398
  });
280
- } else {
399
+ return;
400
+ }
401
+
402
+ const projectSlug = parts.length > 0 ? parts[0] : null;
403
+
404
+ if (tParam && projectSlug) {
405
+ const decoded = decodeWorkspaceUrl(tParam, projectSlug, resolveFullIdFromNothing);
406
+ restoreWorkspace(decoded);
407
+ lastEncodedUrl = tParam;
408
+
409
+ let sessionId: string | null = null;
410
+ const activePane = decoded.panes.find(function (p) { return p.id === decoded.activePaneId; });
411
+ if (activePane) {
412
+ const activeTab = decoded.tabs.find(function (t) { return t.id === activePane.activeTabId; });
413
+ if (activeTab && activeTab.type === "chat" && activeTab.sessionId) {
414
+ sessionId = activeTab.sessionId;
415
+ }
416
+ }
417
+
281
418
  sidebarStore.setState(function (state) {
282
419
  return {
283
420
  ...state,
284
- activeProjectSlug: url.projectSlug,
285
- activeSessionId: url.sessionId,
421
+ activeProjectSlug: projectSlug,
422
+ activeSessionId: sessionId,
286
423
  sidebarMode: "project",
287
- activeView: url.projectSlug
288
- ? (url.sessionId ? { type: "chat" } : { type: "project-dashboard" })
289
- : { type: "dashboard" },
424
+ activeView: sessionId || decoded.tabs.length > 0 ? { type: "chat" } : { type: "project-dashboard" },
290
425
  userMenuOpen: false,
291
426
  projectDropdownOpen: false,
292
427
  };
293
428
  });
429
+ return;
294
430
  }
431
+
432
+ sidebarStore.setState(function (state) {
433
+ return {
434
+ ...state,
435
+ activeProjectSlug: projectSlug,
436
+ activeSessionId: null,
437
+ sidebarMode: "project",
438
+ activeView: projectSlug ? { type: "project-dashboard" } : { type: "dashboard" },
439
+ userMenuOpen: false,
440
+ projectDropdownOpen: false,
441
+ };
442
+ });
443
+ lastEncodedUrl = "";
295
444
  }
296
445
 
297
446
  export function toggleUserMenu(): void {
@@ -1,4 +1,5 @@
1
1
  import { Store } from "@tanstack/react-store";
2
+ import type { DecodedWorkspace } from "../lib/workspace-url";
2
3
 
3
4
  export type TabType = "chat" | "files" | "terminal" | "notes" | "tasks" | "bookmarks" | "analytics";
4
5
 
@@ -364,3 +365,34 @@ export function getActiveSessionTab(): Tab | null {
364
365
  if (!activeTab || activeTab.type !== "chat") return null;
365
366
  return activeTab;
366
367
  }
368
+
369
+ let urlSyncCallback: (() => void) | null = null;
370
+ let urlSyncSuppressed = false;
371
+
372
+ export function setUrlSyncCallback(cb: () => void): void {
373
+ urlSyncCallback = cb;
374
+ }
375
+
376
+ function notifyUrlSync(): void {
377
+ if (!urlSyncSuppressed && urlSyncCallback) {
378
+ urlSyncCallback();
379
+ }
380
+ }
381
+
382
+ export function restoreWorkspace(data: DecodedWorkspace): void {
383
+ urlSyncSuppressed = true;
384
+ workspaceStore.setState(function (state) {
385
+ return {
386
+ ...state,
387
+ tabs: data.tabs,
388
+ panes: data.panes,
389
+ activePaneId: data.activePaneId,
390
+ splitDirection: data.splitDirection,
391
+ };
392
+ });
393
+ urlSyncSuppressed = false;
394
+ }
395
+
396
+ workspaceStore.subscribe(function () {
397
+ notifyUrlSync();
398
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cryptiklemur/lattice",
3
- "version": "1.17.1",
3
+ "version": "1.18.0",
4
4
  "description": "Multi-machine agentic dashboard for Claude Code. Monitor sessions, manage MCP servers and skills, orchestrate across mesh-networked nodes.",
5
5
  "license": "MIT",
6
6
  "author": "Aaron Scherer <me@aaronscherer.me>",