@hienlh/ppm 0.2.21 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +53 -3
  2. package/dist/web/assets/chat-tab-mOQXOUVI.js +6 -0
  3. package/dist/web/assets/code-editor-CRgH4vbS.js +1 -0
  4. package/dist/web/assets/diff-viewer-D3qUDVXh.js +4 -0
  5. package/dist/web/assets/git-graph-D1SOZKP7.js +1 -0
  6. package/dist/web/assets/index-C_yeSRZ0.css +2 -0
  7. package/dist/web/assets/index-CgNJBFj4.js +21 -0
  8. package/dist/web/assets/input-AESbQWjx.js +41 -0
  9. package/dist/web/assets/markdown-renderer-BwjbbSR0.js +59 -0
  10. package/dist/web/assets/settings-store-DWYkr_a3.js +1 -0
  11. package/dist/web/assets/settings-tab-C-UYksUh.js +1 -0
  12. package/dist/web/assets/tab-store-B1wzyDLQ.js +1 -0
  13. package/dist/web/assets/{terminal-tab-BEFAYT4S.js → terminal-tab-BeFf07MH.js} +1 -1
  14. package/dist/web/assets/use-monaco-theme-Bb9W0CI2.js +11 -0
  15. package/dist/web/index.html +7 -5
  16. package/dist/web/sw.js +1 -1
  17. package/package.json +1 -1
  18. package/src/providers/claude-agent-sdk.ts +83 -10
  19. package/src/server/index.ts +81 -1
  20. package/src/server/ws/chat.ts +10 -0
  21. package/src/types/api.ts +3 -3
  22. package/src/types/chat.ts +3 -3
  23. package/src/web/app.tsx +11 -3
  24. package/src/web/components/chat/chat-history-bar.tsx +231 -0
  25. package/src/web/components/chat/chat-tab.tsx +19 -66
  26. package/src/web/components/chat/message-list.tsx +4 -114
  27. package/src/web/components/chat/tool-cards.tsx +54 -14
  28. package/src/web/components/editor/code-editor.tsx +26 -39
  29. package/src/web/components/editor/diff-viewer.tsx +0 -21
  30. package/src/web/components/layout/command-palette.tsx +145 -15
  31. package/src/web/components/layout/draggable-tab.tsx +2 -0
  32. package/src/web/components/layout/editor-panel.tsx +44 -5
  33. package/src/web/components/layout/sidebar.tsx +53 -7
  34. package/src/web/components/layout/tab-bar.tsx +30 -48
  35. package/src/web/components/settings/ai-settings-section.tsx +28 -19
  36. package/src/web/components/settings/settings-tab.tsx +24 -21
  37. package/src/web/components/shared/markdown-renderer.tsx +223 -0
  38. package/src/web/components/ui/scroll-area.tsx +2 -2
  39. package/src/web/hooks/use-chat.ts +78 -83
  40. package/src/web/hooks/use-global-keybindings.ts +30 -2
  41. package/src/web/stores/panel-store.ts +2 -9
  42. package/src/web/stores/settings-store.ts +12 -2
  43. package/src/web/styles/globals.css +14 -4
  44. package/dist/web/assets/chat-tab-C_U7EwM9.js +0 -6
  45. package/dist/web/assets/code-editor-DuarTBEe.js +0 -1
  46. package/dist/web/assets/columns-2-DFQ3yid7.js +0 -1
  47. package/dist/web/assets/diff-viewer-sBWBgb7U.js +0 -4
  48. package/dist/web/assets/git-graph-fOKEZiot.js +0 -1
  49. package/dist/web/assets/index-3zt5mBwZ.css +0 -2
  50. package/dist/web/assets/index-CaUQy3Zs.js +0 -21
  51. package/dist/web/assets/input-CTnwfHVN.js +0 -41
  52. package/dist/web/assets/marked.esm-DhBtkBa8.js +0 -59
  53. package/dist/web/assets/settings-tab-C5aWMqIA.js +0 -1
  54. package/dist/web/assets/use-monaco-theme-BxaccPmI.js +0 -11
  55. /package/dist/web/assets/{api-client-BCjah751.js → api-client-BsHoRDAn.js} +0 -0
  56. /package/dist/web/assets/{copy-B-kLwqzg.js → copy-BNk4Z75P.js} +0 -0
  57. /package/dist/web/assets/{external-link-Dim3NH6h.js → external-link-CrtbmtJ6.js} +0 -0
  58. /package/dist/web/assets/{utils-B-_GCz7E.js → utils-bntUtdc7.js} +0 -0
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useRef, useMemo } from "react";
1
+ import { useState, useEffect, useRef, useMemo, useCallback } from "react";
2
2
  import {
3
3
  Terminal,
4
4
  MessageSquare,
@@ -7,11 +7,14 @@ import {
7
7
  Settings,
8
8
  Search,
9
9
  FileCode,
10
+ FolderOpen,
11
+ Loader2,
10
12
  } from "lucide-react";
11
13
  import { useTabStore, type TabType } from "@/stores/tab-store";
12
14
  import { useProjectStore } from "@/stores/project-store";
13
15
  import { useSettingsStore } from "@/stores/settings-store";
14
16
  import { useFileStore, type FileNode } from "@/stores/file-store";
17
+ import { api } from "@/lib/api-client";
15
18
 
16
19
  interface CommandItem {
17
20
  id: string;
@@ -20,26 +23,45 @@ interface CommandItem {
20
23
  icon: React.ElementType;
21
24
  action: () => void;
22
25
  keywords?: string;
23
- group: "action" | "file";
26
+ group: "action" | "file" | "fs";
24
27
  }
25
28
 
26
29
  /** Recursively flatten file tree into file-only list */
27
- function flattenFiles(nodes: FileNode[], prefix = ""): { name: string; path: string }[] {
30
+ function flattenFiles(nodes: FileNode[]): { name: string; path: string }[] {
28
31
  const result: { name: string; path: string }[] = [];
29
32
  for (const node of nodes) {
30
33
  if (node.type === "file") {
31
34
  result.push({ name: node.name, path: node.path });
32
35
  }
33
36
  if (node.children) {
34
- result.push(...flattenFiles(node.children, node.path));
37
+ result.push(...flattenFiles(node.children));
35
38
  }
36
39
  }
37
40
  return result;
38
41
  }
39
42
 
40
- export function CommandPalette({ open, onClose }: { open: boolean; onClose: () => void }) {
43
+ /** Check if query looks like an absolute path (Unix: /, ~/ | Windows: C:\, ~\) */
44
+ function isPathQuery(q: string): boolean {
45
+ return q.startsWith("/") || q.startsWith("~/") || q.startsWith("~\\") || /^[A-Za-z]:[/\\]/.test(q);
46
+ }
47
+
48
+ /** Extract the directory portion of a path for API call */
49
+ function extractDir(q: string): string {
50
+ // Normalize to forward slash for splitting
51
+ const normalized = q.replace(/\\/g, "/");
52
+ if (normalized.endsWith("/")) return q;
53
+ const lastSlash = Math.max(normalized.lastIndexOf("/"), q.lastIndexOf("\\"));
54
+ return lastSlash > 0 ? q.slice(0, lastSlash + 1) : q;
55
+ }
56
+
57
+ // Cache: dir path → file list
58
+ const fsCache = new Map<string, string[]>();
59
+
60
+ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boolean; onClose: () => void; initialQuery?: string }) {
41
61
  const [query, setQuery] = useState("");
42
62
  const [selectedIdx, setSelectedIdx] = useState(0);
63
+ const [fsFiles, setFsFiles] = useState<string[]>([]);
64
+ const [fsLoading, setFsLoading] = useState(false);
43
65
  const inputRef = useRef<HTMLInputElement>(null);
44
66
  const listRef = useRef<HTMLDivElement>(null);
45
67
 
@@ -47,6 +69,36 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () =
47
69
  const activeProject = useProjectStore((s) => s.activeProject);
48
70
  const fileTree = useFileStore((s) => s.tree);
49
71
  const setSidebarActiveTab = useSettingsStore((s) => s.setSidebarActiveTab);
72
+ const sidebarCollapsed = useSettingsStore((s) => s.sidebarCollapsed);
73
+ const toggleSidebar = useSettingsStore((s) => s.toggleSidebar);
74
+
75
+ // Fetch filesystem files when path query changes directory
76
+ const fetchFsFiles = useCallback(async (dir: string) => {
77
+ if (fsCache.has(dir)) {
78
+ setFsFiles(fsCache.get(dir)!);
79
+ return;
80
+ }
81
+ setFsLoading(true);
82
+ try {
83
+ const files = await api.get<string[]>(`/api/fs/list?dir=${encodeURIComponent(dir)}`);
84
+ fsCache.set(dir, files);
85
+ setFsFiles(files);
86
+ } catch {
87
+ setFsFiles([]);
88
+ } finally {
89
+ setFsLoading(false);
90
+ }
91
+ }, []);
92
+
93
+ // When query changes and looks like a path, fetch files
94
+ useEffect(() => {
95
+ if (!isPathQuery(query)) {
96
+ setFsFiles([]);
97
+ return;
98
+ }
99
+ const dir = extractDir(query);
100
+ fetchFsFiles(dir);
101
+ }, [query, fetchFsFiles]);
50
102
 
51
103
  // Action commands
52
104
  const actionCommands = useMemo<CommandItem[]>(() => {
@@ -63,11 +115,20 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () =
63
115
  { id: "chat", label: "New AI Chat", icon: MessageSquare, action: openNewTab("chat", "AI Chat"), keywords: "ai assistant claude", group: "action" },
64
116
  { id: "git-graph", label: "Git Graph", icon: GitBranch, action: openNewTab("git-graph", "Git Graph"), keywords: "branch history log", group: "action" },
65
117
  { id: "git-status", label: "Git Status", icon: GitCommitHorizontal, action: () => { setSidebarActiveTab("git"); onClose(); }, keywords: "changes diff staged", group: "action" },
66
- { id: "settings", label: "Settings", icon: Settings, action: openNewTab("settings", "Settings"), keywords: "config preferences", group: "action" },
118
+ {
119
+ id: "settings", label: "Settings", icon: Settings,
120
+ action: () => {
121
+ if (sidebarCollapsed) toggleSidebar();
122
+ setSidebarActiveTab("settings");
123
+ onClose();
124
+ },
125
+ keywords: "config preferences theme",
126
+ group: "action",
127
+ },
67
128
  ];
68
- }, [activeProject, openTab, onClose]);
129
+ }, [activeProject, openTab, onClose, setSidebarActiveTab, sidebarCollapsed, toggleSidebar]);
69
130
 
70
- // File commands — derived from file store tree
131
+ // File commands — derived from file store tree (project files)
71
132
  const fileCommands = useMemo<CommandItem[]>(() => {
72
133
  const projectId = activeProject?.name ?? null;
73
134
  const meta = activeProject ? { projectName: activeProject.name } : undefined;
@@ -93,12 +154,56 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () =
93
154
  }));
94
155
  }, [fileTree, activeProject, openTab, onClose]);
95
156
 
96
- const allCommands = useMemo(() => [...actionCommands, ...fileCommands], [actionCommands, fileCommands]);
157
+ // Filesystem commands from cached API results
158
+ const fsCommands = useMemo<CommandItem[]>(() => {
159
+ const projectId = activeProject?.name ?? null;
160
+ const meta = activeProject ? { projectName: activeProject.name } : undefined;
161
+
162
+ return fsFiles.map((fp) => {
163
+ const name = fp.split("/").pop() ?? fp;
164
+ return {
165
+ id: `fs:${fp}`,
166
+ label: name,
167
+ hint: fp,
168
+ icon: FolderOpen,
169
+ group: "fs" as const,
170
+ keywords: fp,
171
+ action: () => {
172
+ openTab({
173
+ type: "editor",
174
+ title: name,
175
+ projectId,
176
+ metadata: { ...meta, filePath: fp },
177
+ closable: true,
178
+ });
179
+ onClose();
180
+ },
181
+ };
182
+ });
183
+ }, [fsFiles, activeProject, openTab, onClose]);
184
+
185
+ const allCommands = useMemo(
186
+ () => [...actionCommands, ...fileCommands],
187
+ [actionCommands, fileCommands],
188
+ );
97
189
 
98
190
  const filtered = useMemo(() => {
99
- if (!query.trim()) return actionCommands; // show only actions when empty
191
+ // Path mode search filesystem results using filename portion only
192
+ if (isPathQuery(query)) {
193
+ // Extract the part after the last / as the filename filter
194
+ const lastSlash = query.lastIndexOf("/");
195
+ const fileFilter = lastSlash >= 0 ? query.slice(lastSlash + 1).toLowerCase() : "";
196
+ if (!fileFilter) return fsCommands.slice(0, 50); // show all if query ends with /
197
+ return fsCommands.filter((c) => {
198
+ const name = c.label.toLowerCase();
199
+ const path = (c.keywords ?? "").toLowerCase();
200
+ return name.includes(fileFilter) || path.includes(fileFilter);
201
+ }).slice(0, 50);
202
+ }
203
+
204
+ // Normal mode
205
+ if (!query.trim()) return actionCommands;
100
206
  const q = query.toLowerCase();
101
- // Fuzzy-ish: every character of query must appear in order
102
207
  const matchesFuzzy = (text: string) => {
103
208
  let ti = 0;
104
209
  for (let qi = 0; qi < q.length; qi++) {
@@ -111,13 +216,14 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () =
111
216
  return allCommands.filter(
112
217
  (c) => matchesFuzzy(c.label.toLowerCase()) || (c.keywords && matchesFuzzy(c.keywords.toLowerCase())),
113
218
  );
114
- }, [allCommands, actionCommands, query]);
219
+ }, [allCommands, actionCommands, fsCommands, query]);
115
220
 
116
221
  // Reset state when opening
117
222
  useEffect(() => {
118
223
  if (open) {
119
- setQuery("");
224
+ setQuery(initialQuery);
120
225
  setSelectedIdx(0);
226
+ setFsFiles([]);
121
227
  requestAnimationFrame(() => inputRef.current?.focus());
122
228
  }
123
229
  }, [open]);
@@ -158,6 +264,8 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () =
158
264
 
159
265
  if (!open) return null;
160
266
 
267
+ const pathMode = isPathQuery(query);
268
+
161
269
  return (
162
270
  <div className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]" onClick={onClose}>
163
271
  <div className="fixed inset-0 bg-black/50" />
@@ -174,18 +282,28 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () =
174
282
  type="text"
175
283
  value={query}
176
284
  onChange={(e) => setQuery(e.target.value)}
177
- placeholder="Search actions & files..."
285
+ placeholder="Search actions & files... (type / or ~/ for filesystem)"
178
286
  className="flex-1 bg-transparent text-sm text-text-primary outline-none placeholder:text-text-subtle"
179
287
  />
288
+ {fsLoading && <Loader2 className="size-3.5 animate-spin text-text-subtle shrink-0" />}
180
289
  <kbd className="hidden sm:inline-flex items-center rounded border border-border bg-surface px-1.5 py-0.5 text-[10px] text-text-subtle font-mono">
181
290
  ESC
182
291
  </kbd>
183
292
  </div>
184
293
 
294
+ {/* Path mode hint */}
295
+ {pathMode && !fsLoading && fsFiles.length === 0 && query.length < 4 && (
296
+ <div className="px-3 py-2 text-xs text-text-subtle border-b border-border/50">
297
+ Type a directory path to browse files (e.g. ~/Projects/)
298
+ </div>
299
+ )}
300
+
185
301
  {/* Results */}
186
302
  <div ref={listRef} className="max-h-72 overflow-y-auto py-1">
187
303
  {filtered.length === 0 ? (
188
- <p className="px-3 py-4 text-sm text-text-subtle text-center">No results</p>
304
+ <p className="px-3 py-4 text-sm text-text-subtle text-center">
305
+ {fsLoading ? "Searching..." : "No results"}
306
+ </p>
189
307
  ) : (
190
308
  filtered.map((cmd, i) => {
191
309
  const Icon = cmd.icon;
@@ -211,6 +329,18 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () =
211
329
  })
212
330
  )}
213
331
  </div>
332
+
333
+ {/* Shortcut hint */}
334
+ <div className="flex items-center justify-center gap-1.5 border-t border-border px-3 py-1.5">
335
+ <span className="text-[10px] text-text-subtle">Press</span>
336
+ <kbd className="inline-flex items-center rounded border border-border bg-surface px-1 py-0.5 text-[10px] text-text-subtle font-mono">
337
+ Shift
338
+ </kbd>
339
+ <kbd className="inline-flex items-center rounded border border-border bg-surface px-1 py-0.5 text-[10px] text-text-subtle font-mono">
340
+ Shift
341
+ </kbd>
342
+ <span className="text-[10px] text-text-subtle">to open this palette</span>
343
+ </div>
214
344
  </div>
215
345
  </div>
216
346
  );
@@ -26,8 +26,10 @@ export function DraggableTab({
26
26
  )}
27
27
  <button
28
28
  ref={tabRef}
29
+ data-tab-item
29
30
  draggable
30
31
  onClick={onSelect}
32
+ onAuxClick={(e) => { if (e.button === 1 && tab.closable) { e.preventDefault(); onClose(); } }}
31
33
  onDragStart={onDragStart}
32
34
  onDragOver={onDragOver}
33
35
  onDragEnd={onDragEnd}
@@ -1,11 +1,18 @@
1
1
  import { Suspense, lazy } from "react";
2
- import { Loader2 } from "lucide-react";
2
+ import { Loader2, Terminal, MessageSquare, GitBranch } from "lucide-react";
3
3
  import { usePanelStore } from "@/stores/panel-store";
4
+ import { useProjectStore } from "@/stores/project-store";
4
5
  import type { TabType } from "@/stores/tab-store";
5
6
  import { TabBar } from "./tab-bar";
6
7
  import { SplitDropOverlay } from "./split-drop-overlay";
7
8
  import { cn } from "@/lib/utils";
8
9
 
10
+ const QUICK_OPEN_TABS: { type: TabType; label: string; icon: React.ElementType }[] = [
11
+ { type: "terminal", label: "Terminal", icon: Terminal },
12
+ { type: "chat", label: "AI Chat", icon: MessageSquare },
13
+ { type: "git-graph", label: "Git Graph", icon: GitBranch },
14
+ ];
15
+
9
16
  const TAB_COMPONENTS: Record<TabType, React.LazyExoticComponent<React.ComponentType<{ metadata?: Record<string, unknown>; tabId?: string }>>> = {
10
17
  terminal: lazy(() => import("@/components/terminal/terminal-tab").then((m) => ({ default: m.TerminalTab }))),
11
18
  chat: lazy(() => import("@/components/chat/chat-tab").then((m) => ({ default: m.ChatTab }))),
@@ -37,15 +44,13 @@ export function EditorPanel({ panelId, projectName }: EditorPanelProps) {
37
44
  panelCount > 1 && "border border-transparent",
38
45
  panelCount > 1 && isFocused && "border-primary/30",
39
46
  )}
40
- onClick={() => usePanelStore.getState().setFocusedPanel(panelId)}
47
+ onMouseDown={() => { if (usePanelStore.getState().focusedPanelId !== panelId) usePanelStore.getState().setFocusedPanel(panelId); }}
41
48
  >
42
49
  <TabBar panelId={panelId} />
43
50
 
44
51
  <div className="flex-1 overflow-hidden relative">
45
52
  {panel.tabs.length === 0 ? (
46
- <div className="flex items-center justify-center h-full text-text-secondary text-sm">
47
- Drop a tab here
48
- </div>
53
+ <EmptyPanel panelId={panelId} />
49
54
  ) : (
50
55
  panel.tabs.map((tab) => {
51
56
  const Component = TAB_COMPONENTS[tab.type];
@@ -64,3 +69,37 @@ export function EditorPanel({ panelId, projectName }: EditorPanelProps) {
64
69
  </div>
65
70
  );
66
71
  }
72
+
73
+ function EmptyPanel({ panelId }: { panelId: string }) {
74
+ const activeProject = useProjectStore((s) => s.activeProject);
75
+
76
+ function openTab(type: TabType) {
77
+ const needsProject = type !== "settings";
78
+ const metadata = needsProject && activeProject ? { projectName: activeProject.name } : undefined;
79
+ usePanelStore.getState().openTab(
80
+ { type, title: QUICK_OPEN_TABS.find((t) => t.type === type)?.label ?? type, metadata, projectId: activeProject?.name ?? null, closable: true },
81
+ panelId,
82
+ );
83
+ }
84
+
85
+ return (
86
+ <div className="flex flex-col items-center justify-center h-full gap-4 text-text-secondary">
87
+ <p className="text-sm">Open a tab to get started</p>
88
+ <div className="flex flex-col md:flex-row flex-wrap justify-center gap-2">
89
+ {QUICK_OPEN_TABS.map((opt) => {
90
+ const Icon = opt.icon;
91
+ return (
92
+ <button
93
+ key={opt.type}
94
+ onClick={() => openTab(opt.type)}
95
+ className="flex items-center gap-2 px-4 py-2 rounded-md border border-border bg-surface hover:bg-surface-elevated text-sm text-foreground transition-colors"
96
+ >
97
+ <Icon className="size-4" />
98
+ {opt.label}
99
+ </button>
100
+ );
101
+ })}
102
+ </div>
103
+ </div>
104
+ );
105
+ }
@@ -1,21 +1,62 @@
1
- import { PanelLeftClose, PanelLeftOpen, FolderOpen, GitBranch, MessageSquare } from "lucide-react";
1
+ import { useCallback, useRef } from "react";
2
+ import { PanelLeftClose, PanelLeftOpen, FolderOpen, GitBranch, Settings } from "lucide-react";
2
3
  import { useProjectStore } from "@/stores/project-store";
3
4
  import { useSettingsStore, type SidebarActiveTab } from "@/stores/settings-store";
4
5
  import { FileTree } from "@/components/explorer/file-tree";
5
6
  import { GitStatusPanel } from "@/components/git/git-status-panel";
6
- import { ChatHistoryPanel } from "@/components/chat/chat-history-panel";
7
+ import { SettingsTab } from "@/components/settings/settings-tab";
7
8
  import { cn } from "@/lib/utils";
8
9
 
9
10
  const TABS: { id: SidebarActiveTab; label: string; icon: React.ElementType }[] = [
10
11
  { id: "explorer", label: "Explorer", icon: FolderOpen },
11
12
  { id: "git", label: "Git", icon: GitBranch },
12
- { id: "history", label: "History", icon: MessageSquare },
13
+ { id: "settings", label: "Settings", icon: Settings },
13
14
  ];
14
15
 
16
+ function ResizeHandle({ onResize }: { onResize: (width: number) => void }) {
17
+ const dragging = useRef(false);
18
+
19
+ const handlePointerDown = useCallback((e: React.PointerEvent) => {
20
+ e.preventDefault();
21
+ dragging.current = true;
22
+ const target = e.currentTarget as HTMLElement;
23
+ target.setPointerCapture(e.pointerId);
24
+ document.body.style.cursor = "col-resize";
25
+ document.body.style.userSelect = "none";
26
+ }, []);
27
+
28
+ const handlePointerMove = useCallback((e: React.PointerEvent) => {
29
+ if (!dragging.current) return;
30
+ // Sidebar starts after the project bar (48px wide)
31
+ const projectBarWidth = 48;
32
+ const newWidth = e.clientX - projectBarWidth;
33
+ onResize(newWidth);
34
+ }, [onResize]);
35
+
36
+ const handlePointerUp = useCallback((e: React.PointerEvent) => {
37
+ dragging.current = false;
38
+ const target = e.currentTarget as HTMLElement;
39
+ target.releasePointerCapture(e.pointerId);
40
+ document.body.style.cursor = "";
41
+ document.body.style.userSelect = "";
42
+ }, []);
43
+
44
+ return (
45
+ <div
46
+ className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/30 active:bg-primary/50 transition-colors z-10"
47
+ onPointerDown={handlePointerDown}
48
+ onPointerMove={handlePointerMove}
49
+ onPointerUp={handlePointerUp}
50
+ />
51
+ );
52
+ }
53
+
15
54
  export function Sidebar() {
16
55
  const { activeProject } = useProjectStore();
17
56
  const sidebarCollapsed = useSettingsStore((s) => s.sidebarCollapsed);
57
+ const sidebarWidth = useSettingsStore((s) => s.sidebarWidth);
18
58
  const toggleSidebar = useSettingsStore((s) => s.toggleSidebar);
59
+ const setSidebarWidth = useSettingsStore((s) => s.setSidebarWidth);
19
60
  const sidebarActiveTab = useSettingsStore((s) => s.sidebarActiveTab);
20
61
  const setSidebarActiveTab = useSettingsStore((s) => s.setSidebarActiveTab);
21
62
 
@@ -34,8 +75,11 @@ export function Sidebar() {
34
75
  }
35
76
 
36
77
  return (
37
- <aside className="hidden md:flex flex-col w-[280px] min-w-[280px] bg-background border-r border-border overflow-hidden">
38
- {/* Tab bar (replaces old header) */}
78
+ <aside
79
+ className="hidden md:flex flex-col bg-background border-r border-border overflow-hidden relative"
80
+ style={{ width: sidebarWidth, minWidth: 200, maxWidth: 600 }}
81
+ >
82
+ {/* Tab bar */}
39
83
  <div className="flex items-center h-[41px] border-b border-border shrink-0">
40
84
  {TABS.map((tab) => {
41
85
  const Icon = tab.icon;
@@ -79,11 +123,13 @@ export function Sidebar() {
79
123
  {sidebarActiveTab === "git" && (
80
124
  <GitStatusPanel metadata={{ projectName: activeProject?.name }} />
81
125
  )}
82
- {sidebarActiveTab === "history" && (
83
- <ChatHistoryPanel projectName={activeProject?.name} />
126
+ {sidebarActiveTab === "settings" && (
127
+ <SettingsTab />
84
128
  )}
85
129
  </div>
86
130
 
131
+ {/* Resize handle */}
132
+ <ResizeHandle onResize={setSidebarWidth} />
87
133
  </aside>
88
134
  );
89
135
  }
@@ -1,6 +1,5 @@
1
1
  import { useEffect, useRef } from "react";
2
2
  import {
3
- X,
4
3
  Plus,
5
4
  Terminal,
6
5
  MessageSquare,
@@ -9,17 +8,11 @@ import {
9
8
  Settings,
10
9
  FileCode,
11
10
  } from "lucide-react";
12
- import {
13
- DropdownMenu,
14
- DropdownMenuContent,
15
- DropdownMenuItem,
16
- DropdownMenuTrigger,
17
- } from "@/components/ui/dropdown-menu";
18
- import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
19
11
  import { useTabStore, type TabType } from "@/stores/tab-store";
20
12
  import { usePanelStore } from "@/stores/panel-store";
21
13
  import { useProjectStore } from "@/stores/project-store";
22
14
  import { useTabDrag } from "@/hooks/use-tab-drag";
15
+ import { openCommandPalette } from "@/hooks/use-global-keybindings";
23
16
  import { DraggableTab } from "./draggable-tab";
24
17
 
25
18
  const TAB_ICONS: Record<TabType, React.ElementType> = {
@@ -31,13 +24,6 @@ const TAB_ICONS: Record<TabType, React.ElementType> = {
31
24
  settings: Settings,
32
25
  };
33
26
 
34
- const NEW_TAB_OPTIONS: { type: TabType; label: string }[] = [
35
- { type: "terminal", label: "Terminal" },
36
- { type: "chat", label: "AI Chat" },
37
- { type: "git-graph", label: "Git Graph" },
38
- { type: "settings", label: "Settings" },
39
- ];
40
-
41
27
  interface TabBarProps {
42
28
  panelId?: string;
43
29
  }
@@ -45,6 +31,7 @@ interface TabBarProps {
45
31
  export function TabBar({ panelId }: TabBarProps) {
46
32
  const activeProject = useProjectStore((s) => s.activeProject);
47
33
  const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
34
+ const scrollRef = useRef<HTMLDivElement>(null);
48
35
  const prevTabCount = useRef(0);
49
36
 
50
37
  // Read tabs from panel-store if panelId given, else from tab-store (focused)
@@ -65,20 +52,20 @@ export function TabBar({ panelId }: TabBarProps) {
65
52
  prevTabCount.current = tabs.length;
66
53
  }, [tabs.length, activeTabId]);
67
54
 
68
- function handleNewTab(type: TabType) {
69
- const needsProject = type === "git-graph" || type === "git-diff" || type === "terminal" || type === "chat";
70
- const metadata = needsProject ? { projectName: activeProject?.name } : undefined;
55
+ /** Double-click on empty bar area → open command palette */
56
+ function handleBarDoubleClick(e: React.MouseEvent) {
57
+ // Only trigger if clicking directly on the bar or scroll container (not on a tab)
58
+ const target = e.target as HTMLElement;
59
+ if (target.closest("[data-tab-item]")) return;
60
+ openCommandPalette();
61
+ }
71
62
 
72
- usePanelStore.getState().openTab(
73
- {
74
- type,
75
- title: NEW_TAB_OPTIONS.find((o) => o.type === type)?.label ?? type,
76
- metadata,
77
- projectId: activeProject?.name ?? null,
78
- closable: true,
79
- },
80
- effectivePanelId,
81
- );
63
+ /** Right-click on empty bar area → open command palette */
64
+ function handleBarContextMenu(e: React.MouseEvent) {
65
+ const target = e.target as HTMLElement;
66
+ if (target.closest("[data-tab-item]")) return;
67
+ e.preventDefault();
68
+ openCommandPalette();
82
69
  }
83
70
 
84
71
  return (
@@ -86,8 +73,14 @@ export function TabBar({ panelId }: TabBarProps) {
86
73
  className="hidden md:flex items-center h-[41px] border-b border-border bg-background"
87
74
  onDragOver={handleDragOverBar}
88
75
  onDrop={handleDrop}
76
+ onDoubleClick={handleBarDoubleClick}
77
+ onContextMenu={handleBarContextMenu}
89
78
  >
90
- <ScrollArea className="flex-1">
79
+ {/* Scrollable tabs + sticky + button */}
80
+ <div
81
+ ref={scrollRef}
82
+ className="flex-1 overflow-x-auto overflow-y-hidden min-w-0 scrollbar-none"
83
+ >
91
84
  <div className="flex items-center gap-0.5 px-2 py-1">
92
85
  {tabs.map((tab, i) => (
93
86
  <DraggableTab
@@ -111,28 +104,17 @@ export function TabBar({ panelId }: TabBarProps) {
111
104
  {dropIndex !== null && dropIndex >= tabs.length && (
112
105
  <div className="w-0.5 h-6 bg-primary rounded-full" />
113
106
  )}
114
- </div>
115
- <ScrollBar orientation="horizontal" />
116
- </ScrollArea>
117
107
 
118
- <DropdownMenu>
119
- <DropdownMenuTrigger asChild>
120
- <button className="flex items-center justify-center size-8 mx-1 rounded-md text-text-secondary hover:text-foreground hover:bg-surface-elevated transition-colors">
108
+ {/* + button — inside flow, sticky when overflowing */}
109
+ <button
110
+ onClick={openCommandPalette}
111
+ title="Open command palette (Shift+Shift)"
112
+ className="flex items-center justify-center size-7 shrink-0 sticky right-1 rounded-md text-text-secondary hover:text-foreground hover:bg-surface-elevated transition-colors bg-background"
113
+ >
121
114
  <Plus className="size-4" />
122
115
  </button>
123
- </DropdownMenuTrigger>
124
- <DropdownMenuContent align="end">
125
- {NEW_TAB_OPTIONS.map((opt) => {
126
- const Icon = TAB_ICONS[opt.type];
127
- return (
128
- <DropdownMenuItem key={opt.type} onClick={() => handleNewTab(opt.type)}>
129
- <Icon className="size-4 mr-2" />
130
- {opt.label}
131
- </DropdownMenuItem>
132
- );
133
- })}
134
- </DropdownMenuContent>
135
- </DropdownMenu>
116
+ </div>
117
+ </div>
136
118
  </div>
137
119
  );
138
120
  }