@hienlh/ppm 0.2.20 → 0.2.21

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 (82) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/CLAUDE.md +18 -1
  3. package/bun.lock +57 -59
  4. package/dist/ppm +0 -0
  5. package/dist/web/assets/chat-tab-C_U7EwM9.js +6 -0
  6. package/dist/web/assets/code-editor-DuarTBEe.js +1 -0
  7. package/dist/web/assets/diff-viewer-sBWBgb7U.js +4 -0
  8. package/dist/web/assets/git-graph-fOKEZiot.js +1 -0
  9. package/dist/web/assets/index-3zt5mBwZ.css +2 -0
  10. package/dist/web/assets/index-CaUQy3Zs.js +21 -0
  11. package/dist/web/assets/input-CTnwfHVN.js +41 -0
  12. package/dist/web/assets/settings-tab-C5aWMqIA.js +1 -0
  13. package/dist/web/assets/{terminal-tab-sGlqTp7k.js → terminal-tab-BEFAYT4S.js} +1 -1
  14. package/dist/web/assets/use-monaco-theme-BxaccPmI.js +11 -0
  15. package/dist/web/index.html +35 -8
  16. package/dist/web/sw.js +1 -1
  17. package/docs/codebase-summary.md +13 -8
  18. package/docs/project-roadmap.md +22 -4
  19. package/docs/system-architecture.md +59 -0
  20. package/package.json +6 -14
  21. package/src/providers/claude-agent-sdk.ts +2 -2
  22. package/src/providers/registry.ts +12 -11
  23. package/src/server/routes/projects.ts +43 -0
  24. package/src/server/routes/settings.ts +42 -8
  25. package/src/server/ws/chat.ts +2 -2
  26. package/src/services/config.service.ts +5 -1
  27. package/src/services/project.service.ts +1 -0
  28. package/src/types/config.ts +37 -0
  29. package/src/types/project.ts +1 -0
  30. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/css.worker.bundle.js +54268 -0
  31. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/editor.worker.bundle.js +14316 -0
  32. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/html.worker.bundle.js +30452 -0
  33. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/json.worker.bundle.js +22095 -0
  34. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/ts.worker.bundle.js +225957 -0
  35. package/src/web/app.tsx +43 -5
  36. package/src/web/components/chat/chat-history-panel.tsx +106 -0
  37. package/src/web/components/chat/chat-tab.tsx +27 -19
  38. package/src/web/components/editor/code-editor.tsx +78 -197
  39. package/src/web/components/editor/diff-viewer.tsx +59 -176
  40. package/src/web/components/layout/add-project-form.tsx +151 -0
  41. package/src/web/components/layout/command-palette.tsx +3 -1
  42. package/src/web/components/layout/editor-panel.tsx +6 -4
  43. package/src/web/components/layout/mobile-drawer.tsx +48 -180
  44. package/src/web/components/layout/mobile-nav.tsx +89 -6
  45. package/src/web/components/layout/panel-layout.tsx +16 -10
  46. package/src/web/components/layout/project-bar.tsx +329 -0
  47. package/src/web/components/layout/project-bottom-sheet.tsx +345 -0
  48. package/src/web/components/layout/sidebar.tsx +56 -142
  49. package/src/web/components/layout/tab-bar.tsx +1 -6
  50. package/src/web/components/layout/tab-content.tsx +0 -10
  51. package/src/web/components/ui/dialog.tsx +1 -1
  52. package/src/web/lib/project-avatar.ts +45 -0
  53. package/src/web/lib/project-palette.ts +18 -0
  54. package/src/web/lib/use-monaco-theme.ts +29 -0
  55. package/src/web/stores/panel-store.ts +96 -9
  56. package/src/web/stores/project-store.ts +87 -3
  57. package/src/web/stores/settings-store.ts +31 -4
  58. package/src/web/stores/tab-store.ts +0 -2
  59. package/vite.config.ts +6 -2
  60. package/dist/web/assets/arrow-up-from-line-DjfWTP75.js +0 -1
  61. package/dist/web/assets/button-CQ5h5gxS.js +0 -41
  62. package/dist/web/assets/chat-tab-Cfw__7vJ.js +0 -6
  63. package/dist/web/assets/code-editor-D8Pz69sx.js +0 -2
  64. package/dist/web/assets/dialog-BL9i7XEo.js +0 -5
  65. package/dist/web/assets/diff-viewer-CWS5n7ur.js +0 -4
  66. package/dist/web/assets/dist-0XHv8Vwc.js +0 -1
  67. package/dist/web/assets/dist-Ca3N8Xbh.js +0 -46
  68. package/dist/web/assets/git-graph-DwA62J8-.js +0 -1
  69. package/dist/web/assets/git-status-panel-DaB-zzSF.js +0 -1
  70. package/dist/web/assets/index-BYIXPY6U.css +0 -2
  71. package/dist/web/assets/index-DbTCLiox.js +0 -17
  72. package/dist/web/assets/project-list-Z4lhtp6P.js +0 -1
  73. package/dist/web/assets/refresh-cw-S6I91MHO.js +0 -1
  74. package/dist/web/assets/settings-tab-BW6MGcir.js +0 -1
  75. package/dist/web/assets/trash-2-CGlFXde_.js +0 -1
  76. package/dist/web/assets/x-C0Rw5Giw.js +0 -1
  77. /package/dist/web/assets/{api-client-DzH9zCD7.js → api-client-BCjah751.js} +0 -0
  78. /package/dist/web/assets/{columns-2-DsiY76NQ.js → columns-2-DFQ3yid7.js} +0 -0
  79. /package/dist/web/assets/{copy-D_Q54D-v.js → copy-B-kLwqzg.js} +0 -0
  80. /package/dist/web/assets/{external-link-C6Y-D528.js → external-link-Dim3NH6h.js} +0 -0
  81. /package/dist/web/assets/{marked.esm-Cv8mjgnt.js → marked.esm-DhBtkBa8.js} +0 -0
  82. /package/dist/web/assets/{utils-D6me7KDg.js → utils-B-_GCz7E.js} +0 -0
@@ -1,48 +1,23 @@
1
- import { useState, useMemo, useCallback } from "react";
2
- import { FolderOpen, ChevronDown, Check, Plus, Search, Bug, PanelLeftClose, PanelLeftOpen } from "lucide-react";
3
- import { useProjectStore, sortByRecent } from "@/stores/project-store";
4
- import { useTabStore } from "@/stores/tab-store";
1
+ import { PanelLeftClose, PanelLeftOpen, FolderOpen, GitBranch, MessageSquare } from "lucide-react";
2
+ import { useProjectStore } from "@/stores/project-store";
3
+ import { useSettingsStore, type SidebarActiveTab } from "@/stores/settings-store";
5
4
  import { FileTree } from "@/components/explorer/file-tree";
6
- import {
7
- DropdownMenu,
8
- DropdownMenuContent,
9
- DropdownMenuSeparator,
10
- DropdownMenuTrigger,
11
- } from "@/components/ui/dropdown-menu";
12
- import { useSettingsStore } from "@/stores/settings-store";
5
+ import { GitStatusPanel } from "@/components/git/git-status-panel";
6
+ import { ChatHistoryPanel } from "@/components/chat/chat-history-panel";
13
7
  import { cn } from "@/lib/utils";
14
- import { openBugReport } from "@/lib/report-bug";
15
8
 
16
- /** Max projects shown before needing to search (desktop) */
17
- const MAX_VISIBLE = 8;
9
+ const TABS: { id: SidebarActiveTab; label: string; icon: React.ElementType }[] = [
10
+ { id: "explorer", label: "Explorer", icon: FolderOpen },
11
+ { id: "git", label: "Git", icon: GitBranch },
12
+ { id: "history", label: "History", icon: MessageSquare },
13
+ ];
18
14
 
19
15
  export function Sidebar() {
20
- const { projects, activeProject, setActiveProject, loading } =
21
- useProjectStore();
22
- const openTab = useTabStore((s) => s.openTab);
23
- const deviceName = useSettingsStore((s) => s.deviceName);
24
- const version = useSettingsStore((s) => s.version);
16
+ const { activeProject } = useProjectStore();
25
17
  const sidebarCollapsed = useSettingsStore((s) => s.sidebarCollapsed);
26
18
  const toggleSidebar = useSettingsStore((s) => s.toggleSidebar);
27
- const [query, setQuery] = useState("");
28
-
29
- const sorted = useMemo(() => sortByRecent(projects), [projects]);
30
-
31
- const filtered = useMemo(() => {
32
- if (!query.trim()) return sorted.slice(0, MAX_VISIBLE);
33
- const q = query.toLowerCase();
34
- return sorted.filter(
35
- (p) => p.name.toLowerCase().includes(q) || p.path.toLowerCase().includes(q),
36
- );
37
- }, [sorted, query]);
38
-
39
- const showSearch = projects.length > MAX_VISIBLE || query.length > 0;
40
-
41
- function handleAddProject() {
42
- openTab({ type: "projects", title: "Projects", projectId: null, closable: true });
43
- }
44
-
45
- const handleReportBug = useCallback(() => openBugReport(version), [version]);
19
+ const sidebarActiveTab = useSettingsStore((s) => s.sidebarActiveTab);
20
+ const setSidebarActiveTab = useSettingsStore((s) => s.setSidebarActiveTab);
46
21
 
47
22
  if (sidebarCollapsed) {
48
23
  return (
@@ -60,116 +35,55 @@ export function Sidebar() {
60
35
 
61
36
  return (
62
37
  <aside className="hidden md:flex flex-col w-[280px] min-w-[280px] bg-background border-r border-border overflow-hidden">
63
- {/* Logo + project dropdown same height as tab bar */}
64
- <div className="flex items-center gap-2 px-3 h-[41px] border-b border-border shrink-0">
65
- <span className="text-sm font-bold text-primary tracking-tight shrink-0">PPM</span>
66
- {deviceName && (
67
- <span className="text-[10px] text-text-subtle bg-surface-elevated px-1.5 py-0.5 rounded-full truncate max-w-[100px]" title={deviceName}>
68
- {deviceName}
69
- </span>
70
- )}
71
-
72
- <DropdownMenu onOpenChange={() => setQuery("")}>
73
- <DropdownMenuTrigger asChild>
74
- <button className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-surface-elevated transition-colors min-w-0 flex-1">
75
- <FolderOpen className="size-3.5 text-text-subtle shrink-0" />
76
- <span className="text-sm truncate flex-1 text-left">
77
- {activeProject?.name ?? "Select Project"}
78
- </span>
79
- <ChevronDown className="size-3 text-text-subtle shrink-0" />
80
- </button>
81
- </DropdownMenuTrigger>
82
- <DropdownMenuContent align="start" className="w-[360px] p-0">
83
- {/* Search — only when many projects */}
84
- {showSearch && (
85
- <div className="flex items-center gap-2 px-2.5 py-2 border-b border-border">
86
- <Search className="size-3.5 text-text-subtle shrink-0" />
87
- <input
88
- type="text"
89
- value={query}
90
- onChange={(e) => setQuery(e.target.value)}
91
- placeholder="Search projects..."
92
- className="flex-1 bg-transparent text-sm outline-none placeholder:text-text-subtle text-text-primary"
93
- autoFocus
94
- />
95
- </div>
96
- )}
97
-
98
- {/* Project list */}
99
- <div className="max-h-64 overflow-y-auto py-1">
100
- {loading && (
101
- <p className="px-3 py-1.5 text-xs text-text-secondary">Loading...</p>
102
- )}
103
- {!loading && filtered.length === 0 && (
104
- <p className="px-3 py-2 text-xs text-text-subtle text-center">
105
- {query ? "No matches" : "No projects"}
106
- </p>
107
- )}
108
- {filtered.map((project) => (
109
- <button
110
- key={project.name}
111
- onClick={() => setActiveProject(project)}
112
- className={cn(
113
- "w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left transition-colors hover:bg-surface-elevated",
114
- activeProject?.name === project.name && "bg-accent/10",
115
- )}
116
- >
117
- <FolderOpen className="size-3.5 shrink-0 text-text-subtle" />
118
- <span className="truncate font-semibold text-text-primary">{project.name}</span>
119
- <span className="truncate text-xs text-text-subtle ml-auto">{project.path}</span>
120
- {activeProject?.name === project.name && (
121
- <Check className="size-3.5 text-primary shrink-0" />
122
- )}
123
- </button>
124
- ))}
125
- </div>
126
-
127
- <DropdownMenuSeparator className="my-0" />
38
+ {/* Tab bar (replaces old header) */}
39
+ <div className="flex items-center h-[41px] border-b border-border shrink-0">
40
+ {TABS.map((tab) => {
41
+ const Icon = tab.icon;
42
+ const isActive = sidebarActiveTab === tab.id;
43
+ return (
128
44
  <button
129
- onClick={handleAddProject}
130
- className="w-full flex items-center gap-2 px-3 py-2 text-sm text-text-secondary hover:bg-surface-elevated transition-colors"
45
+ key={tab.id}
46
+ onClick={() => setSidebarActiveTab(tab.id)}
47
+ className={cn(
48
+ "flex-1 flex items-center justify-center gap-1.5 h-full text-xs transition-colors border-b-2 -mb-px",
49
+ isActive
50
+ ? "border-primary text-primary font-medium"
51
+ : "border-transparent text-text-secondary hover:text-foreground",
52
+ )}
131
53
  >
132
- <Plus className="size-3.5 shrink-0" />
133
- <span>Add Project...</span>
54
+ <Icon className="size-3.5" />
55
+ <span>{tab.label}</span>
134
56
  </button>
135
- </DropdownMenuContent>
136
- </DropdownMenu>
57
+ );
58
+ })}
59
+ <button
60
+ onClick={toggleSidebar}
61
+ title="Collapse sidebar (⌘B)"
62
+ className="flex items-center justify-center w-8 h-full text-text-subtle hover:text-text-secondary transition-colors shrink-0"
63
+ >
64
+ <PanelLeftClose className="size-3.5" />
65
+ </button>
137
66
  </div>
138
67
 
139
- {/* File tree */}
140
- {activeProject ? (
141
- <div className="flex-1 overflow-y-auto">
142
- <FileTree />
143
- </div>
144
- ) : (
145
- <div className="flex-1 flex items-center justify-center p-4">
146
- <p className="text-xs text-text-subtle text-center">
147
- Select a project to browse files
148
- </p>
149
- </div>
150
- )}
151
-
152
- {/* Version footer + Report Bug + Collapse */}
153
- <div className="flex items-center justify-between px-3 py-1.5 border-t border-border shrink-0">
154
- {version && <span className="text-[10px] text-text-subtle">v{version}</span>}
155
- <div className="flex items-center gap-2 ml-auto">
156
- <button
157
- onClick={handleReportBug}
158
- title="Report a bug"
159
- className="flex items-center gap-1 text-[10px] text-text-subtle hover:text-text-secondary transition-colors"
160
- >
161
- <Bug className="size-3" />
162
- <span>Report Bug</span>
163
- </button>
164
- <button
165
- onClick={toggleSidebar}
166
- title="Collapse sidebar (⌘B)"
167
- className="text-text-subtle hover:text-text-secondary transition-colors"
168
- >
169
- <PanelLeftClose className="size-3.5" />
170
- </button>
171
- </div>
68
+ {/* Tab content */}
69
+ <div className="flex-1 overflow-y-auto min-h-0">
70
+ {sidebarActiveTab === "explorer" && (
71
+ activeProject ? (
72
+ <FileTree />
73
+ ) : (
74
+ <div className="flex items-center justify-center h-24 p-4">
75
+ <p className="text-xs text-text-subtle text-center">Select a project to browse files</p>
76
+ </div>
77
+ )
78
+ )}
79
+ {sidebarActiveTab === "git" && (
80
+ <GitStatusPanel metadata={{ projectName: activeProject?.name }} />
81
+ )}
82
+ {sidebarActiveTab === "history" && (
83
+ <ChatHistoryPanel projectName={activeProject?.name} />
84
+ )}
172
85
  </div>
86
+
173
87
  </aside>
174
88
  );
175
89
  }
@@ -2,11 +2,9 @@ import { useEffect, useRef } from "react";
2
2
  import {
3
3
  X,
4
4
  Plus,
5
- FolderOpen,
6
5
  Terminal,
7
6
  MessageSquare,
8
7
  GitBranch,
9
- GitCommitHorizontal,
10
8
  FileDiff,
11
9
  Settings,
12
10
  FileCode,
@@ -25,12 +23,10 @@ import { useTabDrag } from "@/hooks/use-tab-drag";
25
23
  import { DraggableTab } from "./draggable-tab";
26
24
 
27
25
  const TAB_ICONS: Record<TabType, React.ElementType> = {
28
- projects: FolderOpen,
29
26
  terminal: Terminal,
30
27
  chat: MessageSquare,
31
28
  editor: FileCode,
32
29
  "git-graph": GitBranch,
33
- "git-status": GitCommitHorizontal,
34
30
  "git-diff": FileDiff,
35
31
  settings: Settings,
36
32
  };
@@ -39,7 +35,6 @@ const NEW_TAB_OPTIONS: { type: TabType; label: string }[] = [
39
35
  { type: "terminal", label: "Terminal" },
40
36
  { type: "chat", label: "AI Chat" },
41
37
  { type: "git-graph", label: "Git Graph" },
42
- { type: "git-status", label: "Git Status" },
43
38
  { type: "settings", label: "Settings" },
44
39
  ];
45
40
 
@@ -71,7 +66,7 @@ export function TabBar({ panelId }: TabBarProps) {
71
66
  }, [tabs.length, activeTabId]);
72
67
 
73
68
  function handleNewTab(type: TabType) {
74
- const needsProject = type === "git-graph" || type === "git-status" || type === "git-diff" || type === "terminal" || type === "chat";
69
+ const needsProject = type === "git-graph" || type === "git-diff" || type === "terminal" || type === "chat";
75
70
  const metadata = needsProject ? { projectName: activeProject?.name } : undefined;
76
71
 
77
72
  usePanelStore.getState().openTab(
@@ -3,11 +3,6 @@ import { useTabStore, type TabType } from "@/stores/tab-store";
3
3
  import { Loader2 } from "lucide-react";
4
4
 
5
5
  const TAB_COMPONENTS: Record<TabType, React.LazyExoticComponent<React.ComponentType<{ metadata?: Record<string, unknown>; tabId?: string }>>> = {
6
- projects: lazy(() =>
7
- import("@/components/projects/project-list").then((m) => ({
8
- default: m.ProjectList,
9
- })),
10
- ),
11
6
  terminal: lazy(() =>
12
7
  import("@/components/terminal/terminal-tab").then((m) => ({
13
8
  default: m.TerminalTab,
@@ -28,11 +23,6 @@ const TAB_COMPONENTS: Record<TabType, React.LazyExoticComponent<React.ComponentT
28
23
  default: m.GitGraph,
29
24
  })),
30
25
  ),
31
- "git-status": lazy(() =>
32
- import("@/components/git/git-status-panel").then((m) => ({
33
- default: m.GitStatusPanel,
34
- })),
35
- ),
36
26
  "git-diff": lazy(() =>
37
27
  import("@/components/editor/diff-viewer").then((m) => ({
38
28
  default: m.DiffViewer,
@@ -59,7 +59,7 @@ function DialogContent({
59
59
  <DialogPrimitive.Content
60
60
  data-slot="dialog-content"
61
61
  className={cn(
62
- "fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
62
+ "fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background text-foreground p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
63
63
  className
64
64
  )}
65
65
  {...props}
@@ -0,0 +1,45 @@
1
+ /** Compute display initials for a project, resolving collisions. */
2
+ export function getProjectInitials(name: string, allNames: string[]): string {
3
+ // Split by common separators, take first char of each word, uppercase
4
+ const words = name.split(/[-_.\s]+/).filter(Boolean);
5
+ const firstChar = (words[0]?.[0] ?? name[0] ?? "?").toUpperCase();
6
+ const twoChars = words.length > 1
7
+ ? (firstChar + (words[1]![0] ?? "").toUpperCase())
8
+ : firstChar;
9
+
10
+ // Check if 1-char is unique among all projects
11
+ const others = allNames.filter((n) => n !== name);
12
+ const othersFirstChars = others.map((n) => {
13
+ const w = n.split(/[-_.\s]+/).filter(Boolean);
14
+ return (w[0]?.[0] ?? n[0] ?? "").toUpperCase();
15
+ });
16
+
17
+ if (!othersFirstChars.includes(firstChar)) {
18
+ return firstChar;
19
+ }
20
+
21
+ // Try 2-char initials
22
+ const othersTwoChars = others.map((n) => {
23
+ const w = n.split(/[-_.\s]+/).filter(Boolean);
24
+ const f = (w[0]?.[0] ?? n[0] ?? "").toUpperCase();
25
+ const s = w.length > 1 ? (w[1]![0] ?? "").toUpperCase() : f;
26
+ return w.length > 1 ? f + s : f;
27
+ });
28
+
29
+ if (!othersTwoChars.includes(twoChars)) {
30
+ return twoChars;
31
+ }
32
+
33
+ // Fall back to 1-based index
34
+ const idx = allNames.indexOf(name);
35
+ return String(idx >= 0 ? idx + 1 : "?");
36
+ }
37
+
38
+ export interface ProjectAvatar {
39
+ initials: string;
40
+ color: string;
41
+ }
42
+
43
+ export function getProjectAvatar(name: string, allNames: string[], color: string): ProjectAvatar {
44
+ return { initials: getProjectInitials(name, allNames), color };
45
+ }
@@ -0,0 +1,18 @@
1
+ export const PROJECT_PALETTE = [
2
+ 'linear-gradient(135deg, #667eea, #764ba2)',
3
+ 'linear-gradient(135deg, #f5576c, #f093fb)',
4
+ 'linear-gradient(135deg, #4facfe, #00c6ff)',
5
+ 'linear-gradient(135deg, #43e97b, #38f9d7)',
6
+ 'linear-gradient(135deg, #fa709a, #fee140)',
7
+ 'linear-gradient(135deg, #a18cd1, #6a3de8)',
8
+ 'linear-gradient(135deg, #fd7043, #ff8a65)',
9
+ 'linear-gradient(135deg, #26c6da, #0097a7)',
10
+ 'linear-gradient(135deg, #ab47bc, #7b1fa2)',
11
+ 'linear-gradient(135deg, #ef5350, #b71c1c)',
12
+ 'linear-gradient(135deg, #1976d2, #42a5f5)',
13
+ 'linear-gradient(135deg, #2e7d32, #66bb6a)',
14
+ ] as const;
15
+
16
+ export function resolveProjectColor(color: string | undefined, index: number): string {
17
+ return color ?? PROJECT_PALETTE[index % PROJECT_PALETTE.length]!;
18
+ }
@@ -0,0 +1,29 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useSettingsStore } from "@/stores/settings-store";
3
+
4
+ /** Resolves the current app theme to a Monaco editor theme name. */
5
+ export function useMonacoTheme(): string {
6
+ const theme = useSettingsStore((s) => s.theme);
7
+
8
+ const resolve = () => {
9
+ if (theme === "dark") return "vs-dark";
10
+ if (theme === "light") return "light";
11
+ // system
12
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "vs-dark" : "light";
13
+ };
14
+
15
+ const [monacoTheme, setMonacoTheme] = useState(resolve);
16
+
17
+ useEffect(() => {
18
+ setMonacoTheme(resolve());
19
+
20
+ if (theme === "system") {
21
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
22
+ const handler = () => setMonacoTheme(mq.matches ? "vs-dark" : "light");
23
+ mq.addEventListener("change", handler);
24
+ return () => mq.removeEventListener("change", handler);
25
+ }
26
+ }, [theme]);
27
+
28
+ return monacoTheme;
29
+ }
@@ -16,7 +16,10 @@ import {
16
16
  } from "./panel-utils";
17
17
 
18
18
  /** Tab types that can only have 1 instance per project */
19
- const SINGLETON_TYPES = new Set<TabType>(["git-status", "git-graph", "settings", "projects"]);
19
+ const SINGLETON_TYPES = new Set<TabType>(["git-graph", "settings"]);
20
+
21
+ /** Tab types removed in a prior version — filter them out when loading persisted state */
22
+ const OBSOLETE_TAB_TYPES = new Set(["projects", "git-status"]);
20
23
 
21
24
  function generateTabId(): string {
22
25
  return `tab-${randomId()}`;
@@ -38,6 +41,10 @@ export interface PanelStore {
38
41
  focusedPanelId: string;
39
42
  currentProject: string | null;
40
43
 
44
+ /** Keep-alive: per-project grid snapshots (for hidden workspaces) */
45
+ projectGrids: Record<string, string[][]>;
46
+ projectFocused: Record<string, string>;
47
+
41
48
  // Project lifecycle
42
49
  switchProject: (projectName: string) => void;
43
50
 
@@ -70,9 +77,16 @@ function defaultLayout(): { panels: Record<string, Panel>; grid: string[][]; foc
70
77
  // Store
71
78
  // ---------------------------------------------------------------------------
72
79
  export const usePanelStore = create<PanelStore>()((set, get) => {
80
+ /** Save only the active project's panels to localStorage */
73
81
  function persist() {
74
82
  const { currentProject, panels, grid, focusedPanelId } = get();
75
- if (currentProject) savePanelLayout(currentProject, { panels, grid, focusedPanelId });
83
+ if (!currentProject) return;
84
+ const panelIds = new Set(grid.flat());
85
+ const projectPanels: Record<string, Panel> = {};
86
+ for (const [id, p] of Object.entries(panels)) {
87
+ if (panelIds.has(id)) projectPanels[id] = p;
88
+ }
89
+ savePanelLayout(currentProject, { panels: projectPanels, grid, focusedPanelId });
76
90
  }
77
91
 
78
92
  function findPanel(tabId: string): Panel | undefined {
@@ -86,25 +100,98 @@ export const usePanelStore = create<PanelStore>()((set, get) => {
86
100
  return {
87
101
  ...defaultLayout(),
88
102
  currentProject: null,
103
+ projectGrids: {},
104
+ projectFocused: {},
89
105
 
90
106
  switchProject: (projectName) => {
91
- const { currentProject } = get();
92
- if (currentProject) persist();
107
+ const { currentProject, panels, grid, focusedPanelId, projectGrids, projectFocused } = get();
108
+
109
+ // No-op if same project
110
+ if (currentProject === projectName) return;
111
+
112
+ // Snapshot current project's state
113
+ const newProjectGrids = { ...projectGrids };
114
+ const newProjectFocused = { ...projectFocused };
115
+
116
+ if (currentProject) {
117
+ newProjectGrids[currentProject] = grid;
118
+ newProjectFocused[currentProject] = focusedPanelId;
119
+ // Persist to localStorage
120
+ const panelIds = new Set(grid.flat());
121
+ const currentPanels: Record<string, Panel> = {};
122
+ for (const [id, p] of Object.entries(panels)) {
123
+ if (panelIds.has(id)) currentPanels[id] = p;
124
+ }
125
+ savePanelLayout(currentProject, { panels: currentPanels, grid, focusedPanelId });
126
+ }
93
127
 
128
+ // Already in memory → restore from snapshot (no localStorage read)
129
+ if (newProjectGrids[projectName]) {
130
+ const restoredGrid = newProjectGrids[projectName]!;
131
+ const restoredFocused = newProjectFocused[projectName] ?? restoredGrid[0]?.[0] ?? "";
132
+ set({
133
+ currentProject: projectName,
134
+ grid: restoredGrid,
135
+ focusedPanelId: restoredFocused,
136
+ projectGrids: newProjectGrids,
137
+ projectFocused: newProjectFocused,
138
+ });
139
+ return;
140
+ }
141
+
142
+ // Load from localStorage
94
143
  const loaded = loadPanelLayout(projectName);
95
144
  if (loaded && Object.keys(loaded.panels).length > 0) {
96
- set({ currentProject: projectName, ...loaded });
145
+ // Migrate: remove obsolete tab types
146
+ const migratedPanels: typeof loaded.panels = {};
147
+ for (const [pid, panel] of Object.entries(loaded.panels)) {
148
+ const filteredTabs = panel.tabs.filter((t) => !OBSOLETE_TAB_TYPES.has(t.type));
149
+ const filteredHistory = panel.tabHistory.filter(
150
+ (id) => filteredTabs.some((t) => t.id === id),
151
+ );
152
+ const activeTabId = panel.activeTabId && filteredTabs.some((t) => t.id === panel.activeTabId)
153
+ ? panel.activeTabId
154
+ : (filteredHistory[filteredHistory.length - 1] ?? filteredTabs[0]?.id ?? null);
155
+ migratedPanels[pid] = { ...panel, tabs: filteredTabs, tabHistory: filteredHistory, activeTabId };
156
+ }
157
+
158
+ // Merge into flat panels map (keep-alive: old panels stay)
159
+ const mergedPanels = { ...panels, ...migratedPanels };
160
+ newProjectGrids[projectName] = loaded.grid;
161
+ newProjectFocused[projectName] = loaded.focusedPanelId;
162
+ set({
163
+ currentProject: projectName,
164
+ panels: mergedPanels,
165
+ grid: loaded.grid,
166
+ focusedPanelId: loaded.focusedPanelId,
167
+ projectGrids: newProjectGrids,
168
+ projectFocused: newProjectFocused,
169
+ });
97
170
  } else {
171
+ // Create default layout for new project
98
172
  const p = createPanel();
99
173
  const defaultTab: Tab = {
100
- id: generateTabId(), type: "projects", title: "Projects", projectId: null, closable: true,
174
+ id: generateTabId(), type: "chat", title: "AI Chat", projectId: projectName, closable: true,
175
+ metadata: { projectName },
101
176
  };
102
177
  p.tabs = [defaultTab];
103
178
  p.activeTabId = defaultTab.id;
104
179
  p.tabHistory = [defaultTab.id];
105
- const layout = { panels: { [p.id]: p }, grid: [[p.id]], focusedPanelId: p.id };
106
- savePanelLayout(projectName, layout);
107
- set({ currentProject: projectName, ...layout });
180
+ const newGrid = [[p.id]];
181
+
182
+ // Merge into flat panels map
183
+ const mergedPanels = { ...panels, [p.id]: p };
184
+ newProjectGrids[projectName] = newGrid;
185
+ newProjectFocused[projectName] = p.id;
186
+ savePanelLayout(projectName, { panels: { [p.id]: p }, grid: newGrid, focusedPanelId: p.id });
187
+ set({
188
+ currentProject: projectName,
189
+ panels: mergedPanels,
190
+ grid: newGrid,
191
+ focusedPanelId: p.id,
192
+ projectGrids: newProjectGrids,
193
+ projectFocused: newProjectFocused,
194
+ });
108
195
  }
109
196
  },
110
197