@hienlh/ppm 0.10.4 → 0.11.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 (126) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/web/assets/ai-settings-section-D2vqiydT.js +1 -0
  3. package/dist/web/assets/{api-settings-CoKe_BdR.js → api-settings-2eTz4SgY.js} +1 -1
  4. package/dist/web/assets/architecture-PBZL5I3N-BRW4VwMk.js +1 -0
  5. package/dist/web/assets/chat-tab-CbguR_l0.js +10 -0
  6. package/dist/web/assets/code-editor-DbZP0Dnj.js +8 -0
  7. package/dist/web/assets/{conflict-editor-HvxI1A29.js → conflict-editor-BzrH1UpC.js} +3 -3
  8. package/dist/web/assets/{csv-preview-BizIVMyb.js → csv-preview-D37K2LRd.js} +1 -1
  9. package/dist/web/assets/{database-viewer-BgCXPc4e.js → database-viewer-CqMOv2Sg.js} +2 -2
  10. package/dist/web/assets/{diff-viewer-blzXAJHd.js → diff-viewer-B6a2oYYn.js} +1 -1
  11. package/dist/web/assets/{esm-K1XIK4vc.js → esm-B99v94EE.js} +1 -1
  12. package/dist/web/assets/{extension-store-3yZYn07W.js → extension-store-CkyOvGbF.js} +1 -1
  13. package/dist/web/assets/extension-webview-CZr_fvOm.js +3 -0
  14. package/dist/web/assets/gitGraph-HDMCJU4V-Bt68dqWT.js +1 -0
  15. package/dist/web/assets/index-C68PuiOm.js +26 -0
  16. package/dist/web/assets/index-iZHWllzQ.css +2 -0
  17. package/dist/web/assets/info-3K5VOQVL-ySD5z855.js +1 -0
  18. package/dist/web/assets/{keybindings-store-D2N-Tq4N.js → keybindings-store-CpP5_miA.js} +1 -1
  19. package/dist/web/assets/keybindings-store-qfYScgY0.js +1 -0
  20. package/dist/web/assets/{markdown-renderer-Hcj-59AX.js → markdown-renderer-BhNYbXCp.js} +3 -3
  21. package/dist/web/assets/packet-RMMSAZCW-CLxaXgIf.js +1 -0
  22. package/dist/web/assets/pie-UPGHQEXC-C9wPZfkn.js +1 -0
  23. package/dist/web/assets/port-forwarding-tab-Dw9MUu5a.js +1 -0
  24. package/dist/web/assets/{postgres-viewer-BEUI1N1X.js → postgres-viewer-YKyNjTLp.js} +3 -3
  25. package/dist/web/assets/{project-store-Ciq-cK1O.js → project-store-CczGNZyf.js} +1 -1
  26. package/dist/web/assets/radar-KQ55EAFF-DxEpzVN_.js +1 -0
  27. package/dist/web/assets/settings-store-CuYjM0FF.js +2 -0
  28. package/dist/web/assets/settings-tab-2tdZuQIn.js +1 -0
  29. package/dist/web/assets/{sql-query-editor-DZ9xskL8.js → sql-query-editor-CVEi0jLM.js} +1 -1
  30. package/dist/web/assets/{sqlite-viewer-sQs615K6.js → sqlite-viewer-Fx9qDD4-.js} +1 -1
  31. package/dist/web/assets/{tab-store-DZbiYk7y.js → tab-store-Jvy1eZGM.js} +1 -1
  32. package/dist/web/assets/terminal-tab-BxljmYb7.js +1 -0
  33. package/dist/web/assets/treemap-KZPCXAKY-yelcZZqO.js +1 -0
  34. package/dist/web/assets/{use-monaco-theme-OY18iXNi.js → use-monaco-theme-kjiAwvOp.js} +1 -1
  35. package/dist/web/assets/{vendor-mermaid-B2SLgECS.js → vendor-mermaid-CylkVm4U.js} +3 -3
  36. package/dist/web/index.html +13 -13
  37. package/dist/web/sw.js +1 -1
  38. package/docs/codebase-summary.md +29 -5
  39. package/docs/project-changelog.md +31 -1
  40. package/docs/system-architecture.md +106 -1
  41. package/package.json +1 -1
  42. package/packages/ext-git-graph/src/extension.ts +11 -4
  43. package/packages/ext-git-graph/src/webview-html.ts +25 -11
  44. package/src/cli/commands/jira-cmd.ts +92 -0
  45. package/src/cli/commands/jira-watcher-cmd.ts +149 -0
  46. package/src/index.ts +3 -0
  47. package/src/server/index.ts +19 -0
  48. package/src/server/routes/files.ts +15 -0
  49. package/src/server/routes/fs-browse.ts +40 -1
  50. package/src/server/routes/jira-config-routes.ts +74 -0
  51. package/src/server/routes/jira-watcher-routes.ts +316 -0
  52. package/src/server/routes/jira.ts +7 -0
  53. package/src/server/ws/chat.ts +21 -0
  54. package/src/services/db.service.ts +65 -1
  55. package/src/services/extension-host-worker.ts +3 -2
  56. package/src/services/extension.service.ts +4 -2
  57. package/src/services/file.service.ts +42 -0
  58. package/src/services/jira-api-client.ts +216 -0
  59. package/src/services/jira-config.service.ts +83 -0
  60. package/src/services/jira-debug-session.service.ts +240 -0
  61. package/src/services/jira-watcher-db.service.ts +195 -0
  62. package/src/services/jira-watcher.service.ts +159 -0
  63. package/src/services/notification.service.ts +6 -0
  64. package/src/services/supervisor-state.ts +13 -1
  65. package/src/services/supervisor.ts +4 -3
  66. package/src/types/jira.ts +128 -0
  67. package/src/web/app.tsx +15 -12
  68. package/src/web/components/chat/chat-tab.tsx +32 -1
  69. package/src/web/components/chat/message-input.tsx +56 -5
  70. package/src/web/components/explorer/file-tree.tsx +9 -0
  71. package/src/web/components/extensions/extension-webview.tsx +31 -13
  72. package/src/web/components/jira/jira-config-form.tsx +109 -0
  73. package/src/web/components/jira/jira-debug-prompt-dialog.tsx +58 -0
  74. package/src/web/components/jira/jira-filter-builder.tsx +197 -0
  75. package/src/web/components/jira/jira-panel.tsx +201 -0
  76. package/src/web/components/jira/jira-results-panel.tsx +184 -0
  77. package/src/web/components/jira/jira-settings-section.tsx +58 -0
  78. package/src/web/components/jira/jira-status-badge.tsx +18 -0
  79. package/src/web/components/jira/jira-ticket-card.tsx +144 -0
  80. package/src/web/components/jira/jira-ticket-detail.tsx +153 -0
  81. package/src/web/components/jira/jira-watcher-form.tsx +154 -0
  82. package/src/web/components/jira/jira-watcher-list.tsx +98 -0
  83. package/src/web/components/layout/mobile-drawer.tsx +18 -5
  84. package/src/web/components/layout/sidebar.tsx +20 -3
  85. package/src/web/components/settings/settings-tab.tsx +20 -3
  86. package/src/web/components/shared/markdown-code-block.tsx +5 -3
  87. package/src/web/components/ui/file-browser-picker.tsx +88 -1
  88. package/src/web/hooks/use-chat.ts +6 -0
  89. package/src/web/lib/report-bug.ts +3 -2
  90. package/src/web/lib/ws-client.ts +14 -6
  91. package/src/web/stores/jira-store.ts +198 -0
  92. package/src/web/stores/settings-store.ts +24 -5
  93. package/src/web/styles/globals.css +7 -0
  94. package/vite.config.ts +5 -66
  95. package/bun.lock +0 -2062
  96. package/bunfig.toml +0 -2
  97. package/dist/web/assets/ai-settings-section-LMO_cfIW.js +0 -1
  98. package/dist/web/assets/architecture-PBZL5I3N-CUZIB1Vq.js +0 -1
  99. package/dist/web/assets/chat-tab-By7krQ3s.js +0 -10
  100. package/dist/web/assets/code-editor-BoKL57Co.js +0 -8
  101. package/dist/web/assets/extension-webview-Dvk_61ON.js +0 -3
  102. package/dist/web/assets/gitGraph-HDMCJU4V-CtOMUphQ.js +0 -1
  103. package/dist/web/assets/index-DPnjO2FY.css +0 -2
  104. package/dist/web/assets/index-EgCQVN13.js +0 -26
  105. package/dist/web/assets/info-3K5VOQVL-BCrPCWGY.js +0 -1
  106. package/dist/web/assets/keybindings-store-C7No6mtl.js +0 -1
  107. package/dist/web/assets/packet-RMMSAZCW-D_OqB-zi.js +0 -1
  108. package/dist/web/assets/pie-UPGHQEXC-WUHpLNJz.js +0 -1
  109. package/dist/web/assets/port-forwarding-tab-CUgwDn_5.js +0 -1
  110. package/dist/web/assets/radar-KQ55EAFF-HQIIecVM.js +0 -1
  111. package/dist/web/assets/settings-store-B470PCWf.js +0 -2
  112. package/dist/web/assets/settings-tab-BGvgK51L.js +0 -1
  113. package/dist/web/assets/square-nsMa3iMk.js +0 -1
  114. package/dist/web/assets/terminal-tab-CUyHmiHH.js +0 -1
  115. package/dist/web/assets/treemap-KZPCXAKY-0wLgUUTz.js +0 -1
  116. /package/dist/web/assets/{api-client-o_6TmLGC.js → api-client-C3tXCh0r.js} +0 -0
  117. /package/dist/web/assets/{csv-parser--2WJNgS7.js → csv-parser-BAa56Nnn.js} +0 -0
  118. /package/dist/web/assets/{dist-im4ynINo.js → dist-On3hz9_g.js} +0 -0
  119. /package/dist/web/assets/{katex-CKoArbIw.js → katex-Bbu770d9.js} +0 -0
  120. /package/dist/web/assets/{lib-D_kRA9p6.js → lib-BqkcKGFq.js} +0 -0
  121. /package/dist/web/assets/{react-GqWghJ-L.js → react-BkWDCPD7.js} +0 -0
  122. /package/dist/web/assets/{sql-completion-provider-C3cq9j99.js → sql-completion-provider-D3acAhav.js} +0 -0
  123. /package/dist/web/assets/{table-Dq575bPF.js → table-DbSviOmw.js} +0 -0
  124. /package/dist/web/assets/{text-wrap-Cn6BNQfq.js → text-wrap-DzvCTq_i.js} +0 -0
  125. /package/dist/web/assets/{trash-2-CJYoLw7Q.js → trash-2-BgDIBl6f.js} +0 -0
  126. /package/dist/web/assets/{vendor-xterm-ejLe7-tK.js → vendor-xterm-B9BUAFKA.js} +0 -0
@@ -40,6 +40,12 @@ interface MessageInputProps {
40
40
  fileSelected?: FileNode | null;
41
41
  /** External files added via drag-drop on parent */
42
42
  externalFiles?: File[] | null;
43
+ /** External paths from file tree drag or disambiguation */
44
+ externalPaths?: string[] | null;
45
+ /** Callback when external paths have been consumed (inserted into textarea) */
46
+ onExternalPathsConsumed?: () => void;
47
+ /** Callback when OS-dropped files resolve to multiple matches (disambiguation needed) */
48
+ onDisambiguate?: (matches: FileNode[]) => void;
43
49
  /** Pre-fill input value (e.g. from command palette "Ask AI") */
44
50
  initialValue?: string;
45
51
  /** Auto-focus textarea on mount */
@@ -67,6 +73,9 @@ export const MessageInput = memo(function MessageInput({
67
73
  onFileItemsLoaded,
68
74
  fileSelected,
69
75
  externalFiles,
76
+ externalPaths,
77
+ onExternalPathsConsumed,
78
+ onDisambiguate,
70
79
  initialValue,
71
80
  autoFocus,
72
81
  permissionMode,
@@ -291,6 +300,17 @@ export const MessageInput = memo(function MessageInput({
291
300
  processFiles(externalFiles);
292
301
  }, [externalFiles]); // eslint-disable-line react-hooks/exhaustive-deps
293
302
 
303
+ // Handle external paths from file tree drag or disambiguation
304
+ useEffect(() => {
305
+ if (!externalPaths || externalPaths.length === 0) return;
306
+ const pathRefs = externalPaths.map((p) => `@${p}`).join(" ");
307
+ const cur = valueRef.current;
308
+ const sep = cur.length > 0 && !cur.endsWith(" ") ? " " : "";
309
+ writeTextareas(cur + sep + pathRefs + " ");
310
+ getVisibleTextarea()?.focus();
311
+ onExternalPathsConsumed?.();
312
+ }, [externalPaths]); // eslint-disable-line react-hooks/exhaustive-deps
313
+
294
314
  /** Upload a single file to the server, return server path */
295
315
  const uploadFile = useCallback(
296
316
  async (file: File): Promise<string | null> => {
@@ -318,12 +338,34 @@ export const MessageInput = memo(function MessageInput({
318
338
  [projectName],
319
339
  );
320
340
 
321
- /** Process dropped/pasted/selected files */
341
+ /** Process dropped/pasted/selected files — resolves paths via server when possible */
322
342
  const processFiles = useCallback(
323
- (files: File[]) => {
343
+ async (files: File[]) => {
324
344
  for (const file of files) {
345
+ // Step 1: Try server-side filename resolution for all files
346
+ if (projectName) {
347
+ try {
348
+ const data = await api.get<{ matches: FileNode[] }>(
349
+ `${projectUrl(projectName)}/files/resolve?name=${encodeURIComponent(file.name)}`,
350
+ );
351
+ if (data.matches.length === 1) {
352
+ const cur = valueRef.current;
353
+ const sep = cur.length > 0 && !cur.endsWith(" ") ? " " : "";
354
+ writeTextareas(cur + sep + `@${data.matches[0]!.path} `);
355
+ continue;
356
+ }
357
+ if (data.matches.length > 1) {
358
+ onDisambiguate?.(data.matches);
359
+ continue;
360
+ }
361
+ // 0 matches → fall through to existing behavior
362
+ } catch {
363
+ // Resolve failed → fall through
364
+ }
365
+ }
366
+
367
+ // Step 2: Fallback — upload supported files, insert name for unsupported
325
368
  if (!isSupportedFile(file)) {
326
- // Unsupported → insert file name as text
327
369
  const cur = valueRef.current;
328
370
  const sep = cur.length > 0 && !cur.endsWith(" ") ? " " : "";
329
371
  writeTextareas(cur + sep + file.name);
@@ -358,7 +400,7 @@ export const MessageInput = memo(function MessageInput({
358
400
  }
359
401
  (mobileTextareaRef.current ?? textareaRef.current)?.focus();
360
402
  },
361
- [uploadFile, writeTextareas],
403
+ [uploadFile, writeTextareas, projectName, onDisambiguate],
362
404
  );
363
405
 
364
406
  const removeAttachment = useCallback((id: string) => {
@@ -566,10 +608,19 @@ export const MessageInput = memo(function MessageInput({
566
608
  const handleDrop = useCallback(
567
609
  (e: DragEvent<HTMLTextAreaElement>) => {
568
610
  e.preventDefault();
611
+ // Check for internal file tree drag first
612
+ const ppmPath = e.dataTransfer.getData("application/x-ppm-path");
613
+ if (ppmPath) {
614
+ const cur = valueRef.current;
615
+ const sep = cur.length > 0 && !cur.endsWith(" ") ? " " : "";
616
+ writeTextareas(cur + sep + `@${ppmPath} `);
617
+ getVisibleTextarea()?.focus();
618
+ return;
619
+ }
569
620
  const files = Array.from(e.dataTransfer.files);
570
621
  if (files.length > 0) processFiles(files);
571
622
  },
572
- [processFiles],
623
+ [processFiles, writeTextareas, getVisibleTextarea],
573
624
  );
574
625
 
575
626
  const handleDragOver = useCallback((e: DragEvent<HTMLTextAreaElement>) => {
@@ -89,6 +89,13 @@ const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, on
89
89
  onFileOpen?.();
90
90
  }
91
91
 
92
+ function handleDragStart(e: React.DragEvent) {
93
+ const pathValue = isDir ? `${node.path}/` : node.path;
94
+ e.dataTransfer.setData("application/x-ppm-path", pathValue);
95
+ e.dataTransfer.setData("text/plain", node.name);
96
+ e.dataTransfer.effectAllowed = "copy";
97
+ }
98
+
92
99
  const Icon = isDir
93
100
  ? isExpanded
94
101
  ? FolderOpen
@@ -107,6 +114,8 @@ const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, on
107
114
  <ContextMenu>
108
115
  <ContextMenuTrigger asChild>
109
116
  <button
117
+ draggable
118
+ onDragStart={handleDragStart}
110
119
  onClick={handleClick}
111
120
  className={cn(
112
121
  "flex items-center w-full gap-1.5 px-2 py-1 rounded-sm text-sm",
@@ -1,6 +1,7 @@
1
1
  import { useRef, useEffect, useState, useCallback } from "react";
2
2
  import { useExtensionStore } from "@/stores/extension-store";
3
3
  import { useTabStore } from "@/stores/tab-store";
4
+ import { getAuthToken } from "@/lib/api-client";
4
5
  import { Loader2 } from "lucide-react";
5
6
 
6
7
  /** Inject acquireVsCodeApi() shim so extension webviews can postMessage to parent */
@@ -33,6 +34,8 @@ export function ExtensionWebview({ metadata }: ExtensionWebviewProps) {
33
34
  // Prefer currentProject (reflects URL/active project) over stale tab metadata
34
35
  const projectName = currentProject || (metadata?.projectName as string | undefined) || undefined;
35
36
  const [timedOut, setTimedOut] = useState(false);
37
+ // Track whether extensions are activated (contributions received from WS)
38
+ const extensionsReady = useExtensionStore((s) => s.contributions !== null);
36
39
 
37
40
  // Match panel: prefer panelId (exact), fallback to viewType match (reload recovery)
38
41
  const panel = useExtensionStore((s) => {
@@ -58,11 +61,11 @@ export function ExtensionWebview({ metadata }: ExtensionWebviewProps) {
58
61
  const prevProjectRef = useRef<string | null>(null);
59
62
 
60
63
  // On reload: resolve project path and dispatch command once.
61
- // No retry if it fails, user closes tab and reopens to retry.
64
+ // Wait for extensions to be activated (contributions received) before dispatching.
62
65
  // Skip if project-sync effect already dispatched for this project
63
66
  // (panel is briefly undefined during dispose→recreate transition).
64
67
  useEffect(() => {
65
- if (panel || !viewType) return;
68
+ if (panel || !viewType || !extensionsReady) return;
66
69
  // If we already dispatched for this project (via project-sync effect),
67
70
  // don't dispatch again — the panel is just temporarily missing.
68
71
  if (projectName && projectName === prevProjectRef.current) return;
@@ -74,7 +77,8 @@ export function ExtensionWebview({ metadata }: ExtensionWebviewProps) {
74
77
  let args: unknown[] = [];
75
78
  if (projectName) {
76
79
  try {
77
- const res = await fetch("/api/projects");
80
+ const token = getAuthToken();
81
+ const res = await fetch("/api/projects", token ? { headers: { Authorization: `Bearer ${token}` } } : {});
78
82
  const json = await res.json() as { ok: boolean; data?: { name: string; path: string }[] };
79
83
  const match = json.data?.find((p) => p.name === projectName);
80
84
  if (match) args = [match.path];
@@ -86,10 +90,9 @@ export function ExtensionWebview({ metadata }: ExtensionWebviewProps) {
86
90
  }));
87
91
  }
88
92
 
89
- // Short delay to let WS connect after page load
90
- const timer = setTimeout(dispatch, 500);
91
- return () => { cancelled = true; clearTimeout(timer); };
92
- }, [panel, viewType, projectName]);
93
+ dispatch();
94
+ return () => { cancelled = true; };
95
+ }, [panel, viewType, projectName, extensionsReady]);
93
96
 
94
97
  // When panel exists, ensure correct project is loaded.
95
98
  // On mount: dispatch command so extension can reload if project differs.
@@ -103,7 +106,8 @@ export function ExtensionWebview({ metadata }: ExtensionWebviewProps) {
103
106
  const command = viewType.includes(".") ? viewType : `${viewType}.view`;
104
107
  (async () => {
105
108
  try {
106
- const res = await fetch("/api/projects");
109
+ const token = getAuthToken();
110
+ const res = await fetch("/api/projects", token ? { headers: { Authorization: `Bearer ${token}` } } : {});
107
111
  const json = await res.json() as { ok: boolean; data?: { name: string; path: string }[] };
108
112
  const match = json.data?.find((p) => p.name === projectName);
109
113
  if (match) {
@@ -135,7 +139,8 @@ export function ExtensionWebview({ metadata }: ExtensionWebviewProps) {
135
139
  const command = viewType.includes(".") ? viewType : `${viewType}.view`;
136
140
  (async () => {
137
141
  try {
138
- const res = await fetch("/api/projects");
142
+ const token = getAuthToken();
143
+ const res = await fetch("/api/projects", token ? { headers: { Authorization: `Bearer ${token}` } } : {});
139
144
  const json = await res.json() as { ok: boolean; data?: { name: string; path: string }[] };
140
145
  const match = json.data?.find((p) => p.name === projectName);
141
146
  const args = match ? [match.path] : [];
@@ -161,12 +166,25 @@ export function ExtensionWebview({ metadata }: ExtensionWebviewProps) {
161
166
  };
162
167
  }, []);
163
168
 
164
- // Timeout: if panel doesn't appear within 5s, show error
169
+ // Auto-retry: if panel doesn't appear after extensions are ready,
170
+ // re-dispatch the command every 2s (up to 3 times) before showing error.
171
+ // This handles transient WS instability during initial page load where the
172
+ // first command dispatch may be lost due to connection cycling.
165
173
  useEffect(() => {
166
174
  if (panel) { setTimedOut(false); return; }
167
- const timer = setTimeout(() => setTimedOut(true), 5_000);
168
- return () => clearTimeout(timer);
169
- }, [panel]);
175
+ if (!extensionsReady || !viewType) return;
176
+ let retries = 0;
177
+ const id = setInterval(() => {
178
+ retries++;
179
+ if (retries > 3) {
180
+ clearInterval(id);
181
+ setTimedOut(true);
182
+ return;
183
+ }
184
+ handleRetry();
185
+ }, 2_000);
186
+ return () => clearInterval(id);
187
+ }, [panel, extensionsReady, viewType, handleRetry]);
170
188
 
171
189
  // Listen for postMessage from iframe → forward to extension via WS bridge
172
190
  useEffect(() => {
@@ -0,0 +1,109 @@
1
+ import { useState, useEffect, type FormEvent } from "react";
2
+ import { Button } from "@/components/ui/button";
3
+ import { Input } from "@/components/ui/input";
4
+ import { useJiraStore } from "@/stores/jira-store";
5
+ import { CheckCircle, AlertCircle, Loader2, Trash2 } from "lucide-react";
6
+
7
+ interface Props {
8
+ projectId: number;
9
+ existing?: { baseUrl: string; email: string; hasToken: boolean } | null;
10
+ }
11
+
12
+ export function JiraConfigForm({ projectId, existing }: Props) {
13
+ const { saveConfig, deleteConfig, testConnection } = useJiraStore();
14
+ const [baseUrl, setBaseUrl] = useState(existing?.baseUrl ?? "");
15
+ const [email, setEmail] = useState(existing?.email ?? "");
16
+ const [token, setToken] = useState("");
17
+
18
+ // Sync state when existing config loads or changes
19
+ useEffect(() => {
20
+ if (existing) {
21
+ setBaseUrl(existing.baseUrl);
22
+ setEmail(existing.email);
23
+ }
24
+ }, [existing?.baseUrl, existing?.email]);
25
+ const [saving, setSaving] = useState(false);
26
+ const [testing, setTesting] = useState(false);
27
+ const [testResult, setTestResult] = useState<"ok" | "fail" | null>(null);
28
+ const [testError, setTestError] = useState<string | null>(null);
29
+
30
+ const handleSave = async (e: FormEvent) => {
31
+ e.preventDefault();
32
+ if (!baseUrl || !email || (!token && !existing?.hasToken)) return;
33
+ setSaving(true);
34
+ try {
35
+ await saveConfig(projectId, { baseUrl, email, ...(token ? { token } : {}) });
36
+ setToken("");
37
+ } catch {}
38
+ setSaving(false);
39
+ };
40
+
41
+ const handleTest = async () => {
42
+ setTesting(true);
43
+ setTestResult(null);
44
+ setTestError(null);
45
+ try {
46
+ const ok = await testConnection(projectId);
47
+ setTestResult(ok ? "ok" : "fail");
48
+ } catch (e: any) {
49
+ setTestResult("fail");
50
+ setTestError(e?.message ?? "Connection failed");
51
+ }
52
+ setTesting(false);
53
+ };
54
+
55
+ return (
56
+ <form onSubmit={handleSave} className="space-y-3">
57
+ <div>
58
+ <label className="text-xs text-muted-foreground">Base URL</label>
59
+ <Input
60
+ value={baseUrl}
61
+ onChange={(e) => setBaseUrl(e.target.value)}
62
+ placeholder="https://mysite.atlassian.net"
63
+ className="h-9"
64
+ />
65
+ </div>
66
+ <div>
67
+ <label className="text-xs text-muted-foreground">Email</label>
68
+ <Input
69
+ value={email}
70
+ onChange={(e) => setEmail(e.target.value)}
71
+ placeholder="you@company.com"
72
+ className="h-9"
73
+ />
74
+ </div>
75
+ <div>
76
+ <label className="text-xs text-muted-foreground">
77
+ API Token {existing?.hasToken && <span className="text-green-500">(saved)</span>}
78
+ </label>
79
+ <Input
80
+ type="password"
81
+ value={token}
82
+ onChange={(e) => setToken(e.target.value)}
83
+ placeholder={existing?.hasToken ? "Enter new token to replace" : "Your Jira API token"}
84
+ className="h-9"
85
+ />
86
+ </div>
87
+ <div className="flex items-center gap-2 flex-wrap">
88
+ <Button type="submit" size="sm" disabled={saving} className="min-w-[44px] min-h-[44px]">
89
+ {saving ? <Loader2 className="size-4 animate-spin" /> : "Save"}
90
+ </Button>
91
+ {existing && (
92
+ <>
93
+ <Button type="button" size="sm" variant="outline" onClick={handleTest} disabled={testing} className="min-h-[44px]">
94
+ {testing ? <Loader2 className="size-4 animate-spin" /> : "Test Connection"}
95
+ </Button>
96
+ <Button type="button" size="sm" variant="destructive" onClick={() => deleteConfig(projectId)} className="min-h-[44px]">
97
+ <Trash2 className="size-4" />
98
+ </Button>
99
+ </>
100
+ )}
101
+ {testResult === "ok" && <CheckCircle className="size-4 text-green-500" />}
102
+ {testResult === "fail" && <AlertCircle className="size-4 text-red-500" />}
103
+ </div>
104
+ {testError && (
105
+ <p className="text-xs text-red-500 break-all">{testError}</p>
106
+ )}
107
+ </form>
108
+ );
109
+ }
@@ -0,0 +1,58 @@
1
+ import { useState, useEffect } from "react";
2
+ import { Button } from "@/components/ui/button";
3
+ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
4
+ import { useJiraStore } from "@/stores/jira-store";
5
+ import type { JiraWatchResult } from "../../../../src/types/jira";
6
+
7
+ interface Props {
8
+ result: JiraWatchResult | null;
9
+ onClose: () => void;
10
+ }
11
+
12
+ export function JiraDebugPromptDialog({ result, onClose }: Props) {
13
+ const { watchers, startDebug } = useJiraStore();
14
+ const [prompt, setPrompt] = useState("");
15
+
16
+ useEffect(() => {
17
+ if (!result) return;
18
+ const watcher = watchers.find((w) => w.id === result.watcherId);
19
+ const template = watcher?.promptTemplate
20
+ ?? `Debug Jira issue {issue_key}: {summary}`;
21
+ setPrompt(
22
+ template
23
+ .replace(/\{issue_key\}/g, result.issueKey)
24
+ .replace(/\{summary\}/g, result.issueSummary ?? ""),
25
+ );
26
+ }, [result, watchers]);
27
+
28
+ const handleStart = async () => {
29
+ if (!result) return;
30
+ try {
31
+ await startDebug(result.id, prompt);
32
+ } catch {}
33
+ onClose();
34
+ };
35
+
36
+ return (
37
+ <Dialog open={!!result} onOpenChange={(open) => { if (!open) onClose(); }}>
38
+ <DialogContent className="max-w-md">
39
+ <DialogHeader>
40
+ <DialogTitle>Start Debug: {result?.issueKey}</DialogTitle>
41
+ </DialogHeader>
42
+ <p className="text-sm text-muted-foreground">{result?.issueSummary}</p>
43
+ <div>
44
+ <label className="text-xs text-muted-foreground">Debug Prompt</label>
45
+ <textarea
46
+ value={prompt}
47
+ onChange={(e) => setPrompt(e.target.value)}
48
+ className="w-full h-24 rounded-md border border-input bg-background px-3 py-2 text-xs font-mono resize-none mt-1"
49
+ placeholder="Debug Jira issue {issue_key}: {summary}"
50
+ />
51
+ </div>
52
+ <Button size="sm" className="w-full min-h-[44px]" onClick={handleStart}>
53
+ Start Debug Session
54
+ </Button>
55
+ </DialogContent>
56
+ </Dialog>
57
+ );
58
+ }
@@ -0,0 +1,197 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { Button } from "@/components/ui/button";
3
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
4
+ import { X, Loader2 } from "lucide-react";
5
+ import { api } from "@/lib/api-client";
6
+
7
+ interface FilterState {
8
+ project: string[];
9
+ issueType: string[];
10
+ priority: string[];
11
+ status: string[];
12
+ assignee: string[];
13
+ }
14
+
15
+ function filtersToJql(filters: FilterState): string {
16
+ const clauses: string[] = [];
17
+ if (filters.project.length) clauses.push(`project IN (${filters.project.join(", ")})`);
18
+ if (filters.issueType.length) clauses.push(`issuetype IN (${filters.issueType.map((v) => `"${v}"`).join(", ")})`);
19
+ if (filters.priority.length) clauses.push(`priority IN (${filters.priority.map((v) => `"${v}"`).join(", ")})`);
20
+ if (filters.status.length) clauses.push(`status IN (${filters.status.map((v) => `"${v}"`).join(", ")})`);
21
+ if (filters.assignee.length) clauses.push(`assignee IN (${filters.assignee.map((v) => `"${v}"`).join(", ")})`);
22
+ return (clauses.join(" AND ") || "ORDER BY updated DESC") + (clauses.length ? " ORDER BY updated DESC" : "");
23
+ }
24
+
25
+ const EMPTY_FILTERS: FilterState = { project: [], issueType: [], priority: [], status: [], assignee: [] };
26
+
27
+ interface FieldOption { id?: string; key?: string; name: string }
28
+
29
+ interface Props {
30
+ value: string;
31
+ onChange: (jql: string) => void;
32
+ configId: number;
33
+ }
34
+
35
+ export function JiraFilterBuilder({ value, onChange, configId }: Props) {
36
+ const [mode, setMode] = useState<"builder" | "raw">(value ? "raw" : "builder");
37
+ const [filters, setFilters] = useState<FilterState>(EMPTY_FILTERS);
38
+ const [rawJql, setRawJql] = useState(value);
39
+
40
+ // Metadata options fetched from Jira API
41
+ const [projects, setProjects] = useState<FieldOption[]>([]);
42
+ const [issueTypes, setIssueTypes] = useState<FieldOption[]>([]);
43
+ const [priorities, setPriorities] = useState<FieldOption[]>([]);
44
+ const [statuses, setStatuses] = useState<FieldOption[]>([]);
45
+ const [assignees, setAssignees] = useState<FieldOption[]>([]);
46
+ const [loading, setLoading] = useState(false);
47
+
48
+ // Fetch metadata when configId changes
49
+ useEffect(() => {
50
+ if (!configId) return;
51
+ setLoading(true);
52
+ Promise.all([
53
+ api.get<FieldOption[]>(`/api/jira/metadata/${configId}/projects`).catch(() => []),
54
+ api.get<FieldOption[]>(`/api/jira/metadata/${configId}/issuetype`).catch(() => []),
55
+ api.get<FieldOption[]>(`/api/jira/metadata/${configId}/priority`).catch(() => []),
56
+ api.get<FieldOption[]>(`/api/jira/metadata/${configId}/status`).catch(() => []),
57
+ api.get<Array<{ accountId: string; displayName: string }>>(`/api/jira/metadata/${configId}/assignees`).catch(() => []),
58
+ ]).then(([p, it, pr, st, as_]) => {
59
+ setProjects(p);
60
+ setIssueTypes(it);
61
+ setPriorities(pr);
62
+ setStatuses(st);
63
+ setAssignees(as_.map((u) => ({ id: u.accountId, name: u.displayName })));
64
+ }).finally(() => setLoading(false));
65
+ }, [configId]);
66
+
67
+ // Sync builder → JQL
68
+ useEffect(() => {
69
+ if (mode === "builder") {
70
+ const jql = filtersToJql(filters);
71
+ onChange(jql);
72
+ }
73
+ }, [filters, mode]);
74
+
75
+ const handleRawChange = useCallback((val: string) => {
76
+ setRawJql(val);
77
+ onChange(val);
78
+ }, [onChange]);
79
+
80
+ const addValue = (field: keyof FilterState, val: string) => {
81
+ if (!val || filters[field].includes(val)) return;
82
+ setFilters((f) => ({ ...f, [field]: [...f[field], val] }));
83
+ };
84
+
85
+ const removeValue = (field: keyof FilterState, val: string) => {
86
+ setFilters((f) => ({ ...f, [field]: f[field].filter((v) => v !== val) }));
87
+ };
88
+
89
+ return (
90
+ <div className="space-y-2 min-w-0">
91
+ <div className="flex items-center gap-2">
92
+ <Button
93
+ type="button" size="sm" variant={mode === "builder" ? "default" : "outline"}
94
+ onClick={() => setMode("builder")} className="min-h-[44px] text-xs"
95
+ >Builder</Button>
96
+ <Button
97
+ type="button" size="sm" variant={mode === "raw" ? "default" : "outline"}
98
+ onClick={() => setMode("raw")} className="min-h-[44px] text-xs"
99
+ >Raw JQL</Button>
100
+ {loading && <Loader2 className="size-3.5 animate-spin text-muted-foreground" />}
101
+ </div>
102
+
103
+ {mode === "raw" ? (
104
+ <textarea
105
+ value={rawJql}
106
+ onChange={(e) => handleRawChange(e.target.value)}
107
+ className="w-full h-20 rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
108
+ placeholder='e.g. project = MYPROJ AND status = "In Progress"'
109
+ />
110
+ ) : (
111
+ <div className="space-y-2">
112
+ <FilterField
113
+ label="Project" field="project" filters={filters}
114
+ onAdd={addValue} onRemove={removeValue}
115
+ options={projects.map((p) => ({ value: p.key ?? p.name, label: `${p.key ?? p.name} — ${p.name}` }))}
116
+ placeholder="Select project..."
117
+ />
118
+ <FilterField
119
+ label="Issue Type" field="issueType" filters={filters}
120
+ onAdd={addValue} onRemove={removeValue}
121
+ options={issueTypes.map((t) => ({ value: t.name, label: t.name }))}
122
+ placeholder="Select issue type..."
123
+ />
124
+ <FilterField
125
+ label="Priority" field="priority" filters={filters}
126
+ onAdd={addValue} onRemove={removeValue}
127
+ options={priorities.map((p) => ({ value: p.name, label: p.name }))}
128
+ placeholder="Select priority..."
129
+ />
130
+ <FilterField
131
+ label="Status" field="status" filters={filters}
132
+ onAdd={addValue} onRemove={removeValue}
133
+ options={statuses.map((s) => ({ value: s.name, label: s.name }))}
134
+ placeholder="Select status..."
135
+ />
136
+ <FilterField
137
+ label="Assignee" field="assignee" filters={filters}
138
+ onAdd={addValue} onRemove={removeValue}
139
+ options={assignees.map((a) => ({ value: a.id ?? a.name, label: a.name }))}
140
+ placeholder="Select assignee..."
141
+ />
142
+ </div>
143
+ )}
144
+
145
+ {/* JQL preview */}
146
+ <div className="text-xs text-muted-foreground bg-muted/50 rounded px-2 py-1 font-mono break-all">
147
+ {mode === "builder" ? filtersToJql(filters) : rawJql || "(empty)"}
148
+ </div>
149
+ </div>
150
+ );
151
+ }
152
+
153
+ function FilterField({ label, field, filters, onAdd, onRemove, options, placeholder }: {
154
+ label: string; field: keyof FilterState; filters: FilterState;
155
+ onAdd: (f: keyof FilterState, v: string) => void;
156
+ onRemove: (f: keyof FilterState, v: string) => void;
157
+ options: Array<{ value: string; label: string }>;
158
+ placeholder: string;
159
+ }) {
160
+ // Filter out already-selected options
161
+ const available = options.filter((o) => !filters[field].includes(o.value));
162
+
163
+ return (
164
+ <div>
165
+ <label className="text-xs text-muted-foreground">{label}</label>
166
+ <div className="flex items-center gap-1 flex-wrap">
167
+ {filters[field].map((v) => {
168
+ const displayLabel = options.find((o) => o.value === v)?.label ?? v;
169
+ return (
170
+ <span key={v} className="inline-flex items-center gap-0.5 px-2 py-0.5 rounded-full bg-primary/10 text-xs">
171
+ {displayLabel}
172
+ <button type="button" onClick={() => onRemove(field, v)} className="hover:text-destructive">
173
+ <X className="size-3" />
174
+ </button>
175
+ </span>
176
+ );
177
+ })}
178
+ {available.length > 0 ? (
179
+ <Select onValueChange={(v) => onAdd(field, v)}>
180
+ <SelectTrigger className="h-7 w-auto min-w-[120px] text-xs">
181
+ <SelectValue placeholder={placeholder} />
182
+ </SelectTrigger>
183
+ <SelectContent>
184
+ {available.map((o) => (
185
+ <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
186
+ ))}
187
+ </SelectContent>
188
+ </Select>
189
+ ) : options.length > 0 ? (
190
+ <span className="text-xs text-muted-foreground italic">All selected</span>
191
+ ) : (
192
+ <span className="text-xs text-muted-foreground italic">Loading...</span>
193
+ )}
194
+ </div>
195
+ </div>
196
+ );
197
+ }