@hienlh/ppm 0.10.5 → 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 (122) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/web/assets/ai-settings-section-D2vqiydT.js +1 -0
  3. package/dist/web/assets/{api-settings-C__hxGX2.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-Bxq4QiW1.js → conflict-editor-BzrH1UpC.js} +1 -1
  8. package/dist/web/assets/{csv-preview-BizIVMyb.js → csv-preview-D37K2LRd.js} +1 -1
  9. package/dist/web/assets/{database-viewer-CvQc1PZH.js → database-viewer-CqMOv2Sg.js} +2 -2
  10. package/dist/web/assets/{diff-viewer-x7kjfVYW.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/{input-ClhO__YM.js → input-CHRMley8.js} +1 -1
  19. package/dist/web/assets/{keybindings-store-C9KsBH7z.js → keybindings-store-CpP5_miA.js} +1 -1
  20. package/dist/web/assets/keybindings-store-qfYScgY0.js +1 -0
  21. package/dist/web/assets/{markdown-renderer-CKmmrUuy.js → markdown-renderer-BhNYbXCp.js} +3 -3
  22. package/dist/web/assets/packet-RMMSAZCW-CLxaXgIf.js +1 -0
  23. package/dist/web/assets/pie-UPGHQEXC-C9wPZfkn.js +1 -0
  24. package/dist/web/assets/port-forwarding-tab-Dw9MUu5a.js +1 -0
  25. package/dist/web/assets/{postgres-viewer-YkljtDWX.js → postgres-viewer-YKyNjTLp.js} +3 -3
  26. package/dist/web/assets/{project-store-BYmQ0fDC.js → project-store-CczGNZyf.js} +1 -1
  27. package/dist/web/assets/radar-KQ55EAFF-DxEpzVN_.js +1 -0
  28. package/dist/web/assets/{scroll-area-DW7L4Gnc.js → scroll-area-DwWF9FpN.js} +1 -1
  29. package/dist/web/assets/settings-store-CuYjM0FF.js +2 -0
  30. package/dist/web/assets/settings-tab-2tdZuQIn.js +1 -0
  31. package/dist/web/assets/{sql-query-editor-CM_qEhaX.js → sql-query-editor-CVEi0jLM.js} +1 -1
  32. package/dist/web/assets/{sqlite-viewer-f6ZJHIzh.js → sqlite-viewer-Fx9qDD4-.js} +1 -1
  33. package/dist/web/assets/{tab-store-B3M9hjho.js → tab-store-Jvy1eZGM.js} +1 -1
  34. package/dist/web/assets/{terminal-tab-CVdfvDSK.js → terminal-tab-BxljmYb7.js} +1 -1
  35. package/dist/web/assets/treemap-KZPCXAKY-yelcZZqO.js +1 -0
  36. package/dist/web/assets/{use-monaco-theme-CvV5vy_F.js → use-monaco-theme-kjiAwvOp.js} +1 -1
  37. package/dist/web/assets/{vendor-mermaid-CwOSbfhN.js → vendor-mermaid-CylkVm4U.js} +3 -3
  38. package/dist/web/index.html +16 -16
  39. package/dist/web/sw.js +1 -1
  40. package/docs/codebase-summary.md +29 -5
  41. package/docs/project-changelog.md +31 -1
  42. package/docs/system-architecture.md +106 -1
  43. package/package.json +1 -1
  44. package/packages/ext-git-graph/src/webview-html.ts +8 -7
  45. package/src/cli/commands/jira-cmd.ts +92 -0
  46. package/src/cli/commands/jira-watcher-cmd.ts +149 -0
  47. package/src/index.ts +3 -0
  48. package/src/server/index.ts +19 -0
  49. package/src/server/routes/files.ts +15 -0
  50. package/src/server/routes/fs-browse.ts +40 -1
  51. package/src/server/routes/jira-config-routes.ts +74 -0
  52. package/src/server/routes/jira-watcher-routes.ts +316 -0
  53. package/src/server/routes/jira.ts +7 -0
  54. package/src/server/ws/chat.ts +21 -0
  55. package/src/services/db.service.ts +65 -1
  56. package/src/services/file.service.ts +42 -0
  57. package/src/services/jira-api-client.ts +216 -0
  58. package/src/services/jira-config.service.ts +83 -0
  59. package/src/services/jira-debug-session.service.ts +240 -0
  60. package/src/services/jira-watcher-db.service.ts +195 -0
  61. package/src/services/jira-watcher.service.ts +159 -0
  62. package/src/services/notification.service.ts +6 -0
  63. package/src/types/jira.ts +128 -0
  64. package/src/web/app.tsx +15 -12
  65. package/src/web/components/chat/chat-tab.tsx +32 -1
  66. package/src/web/components/chat/message-input.tsx +56 -5
  67. package/src/web/components/explorer/file-tree.tsx +9 -0
  68. package/src/web/components/extensions/extension-webview.tsx +24 -10
  69. package/src/web/components/jira/jira-config-form.tsx +109 -0
  70. package/src/web/components/jira/jira-debug-prompt-dialog.tsx +58 -0
  71. package/src/web/components/jira/jira-filter-builder.tsx +197 -0
  72. package/src/web/components/jira/jira-panel.tsx +201 -0
  73. package/src/web/components/jira/jira-results-panel.tsx +184 -0
  74. package/src/web/components/jira/jira-settings-section.tsx +58 -0
  75. package/src/web/components/jira/jira-status-badge.tsx +18 -0
  76. package/src/web/components/jira/jira-ticket-card.tsx +144 -0
  77. package/src/web/components/jira/jira-ticket-detail.tsx +153 -0
  78. package/src/web/components/jira/jira-watcher-form.tsx +154 -0
  79. package/src/web/components/jira/jira-watcher-list.tsx +98 -0
  80. package/src/web/components/layout/mobile-drawer.tsx +18 -5
  81. package/src/web/components/layout/sidebar.tsx +20 -3
  82. package/src/web/components/settings/settings-tab.tsx +20 -3
  83. package/src/web/components/shared/markdown-code-block.tsx +5 -3
  84. package/src/web/components/ui/file-browser-picker.tsx +88 -1
  85. package/src/web/hooks/use-chat.ts +6 -0
  86. package/src/web/lib/ws-client.ts +10 -3
  87. package/src/web/stores/jira-store.ts +198 -0
  88. package/src/web/stores/settings-store.ts +17 -2
  89. package/src/web/styles/globals.css +7 -0
  90. package/vite.config.ts +5 -66
  91. package/bun.lock +0 -2062
  92. package/bunfig.toml +0 -2
  93. package/dist/web/assets/ai-settings-section-D2rONDPd.js +0 -1
  94. package/dist/web/assets/architecture-PBZL5I3N-DmL1WyG-.js +0 -1
  95. package/dist/web/assets/chat-tab-Dki1pz84.js +0 -10
  96. package/dist/web/assets/code-editor-D3AAT8nI.js +0 -8
  97. package/dist/web/assets/extension-webview-BFd0USXC.js +0 -3
  98. package/dist/web/assets/gitGraph-HDMCJU4V-D8vKfkjC.js +0 -1
  99. package/dist/web/assets/index-DPnjO2FY.css +0 -2
  100. package/dist/web/assets/index-DuEUN2Eg.js +0 -26
  101. package/dist/web/assets/info-3K5VOQVL-VG29MIoT.js +0 -1
  102. package/dist/web/assets/keybindings-store-BkZjvU9J.js +0 -1
  103. package/dist/web/assets/packet-RMMSAZCW-Bl_WpvPc.js +0 -1
  104. package/dist/web/assets/pie-UPGHQEXC-BVpLpAIy.js +0 -1
  105. package/dist/web/assets/port-forwarding-tab-BUH9aImG.js +0 -1
  106. package/dist/web/assets/radar-KQ55EAFF-CJGco43I.js +0 -1
  107. package/dist/web/assets/settings-store-D9CflsKU.js +0 -2
  108. package/dist/web/assets/settings-tab-DfPjX9uY.js +0 -1
  109. package/dist/web/assets/square-nsMa3iMk.js +0 -1
  110. package/dist/web/assets/treemap-KZPCXAKY-BsOrObtE.js +0 -1
  111. /package/dist/web/assets/{api-client-Bn-Pi9k5.js → api-client-C3tXCh0r.js} +0 -0
  112. /package/dist/web/assets/{csv-parser--2WJNgS7.js → csv-parser-BAa56Nnn.js} +0 -0
  113. /package/dist/web/assets/{dist-im4ynINo.js → dist-On3hz9_g.js} +0 -0
  114. /package/dist/web/assets/{katex-CKoArbIw.js → katex-Bbu770d9.js} +0 -0
  115. /package/dist/web/assets/{lib-D_kRA9p6.js → lib-BqkcKGFq.js} +0 -0
  116. /package/dist/web/assets/{react-GqWghJ-L.js → react-BkWDCPD7.js} +0 -0
  117. /package/dist/web/assets/{sql-completion-provider-C3cq9j99.js → sql-completion-provider-D3acAhav.js} +0 -0
  118. /package/dist/web/assets/{table-Dq575bPF.js → table-DbSviOmw.js} +0 -0
  119. /package/dist/web/assets/{text-wrap-Cn6BNQfq.js → text-wrap-DzvCTq_i.js} +0 -0
  120. /package/dist/web/assets/{trash-2-CJYoLw7Q.js → trash-2-BgDIBl6f.js} +0 -0
  121. /package/dist/web/assets/{utils-CTg5uAYR.js → utils-ChWX7pZv.js} +0 -0
  122. /package/dist/web/assets/{vendor-xterm-ejLe7-tK.js → vendor-xterm-B9BUAFKA.js} +0 -0
@@ -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
+ }
@@ -0,0 +1,201 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { Button } from "@/components/ui/button";
3
+ import { ScrollArea } from "@/components/ui/scroll-area";
4
+ import { useProjectStore } from "@/stores/project-store";
5
+ import { useJiraStore } from "@/stores/jira-store";
6
+ import { useTabStore } from "@/stores/tab-store";
7
+ import { JiraTicketCard } from "./jira-ticket-card";
8
+ import { JiraWatcherList } from "./jira-watcher-list";
9
+ import { JiraConfigForm } from "./jira-config-form";
10
+ import { JiraDebugPromptDialog } from "./jira-debug-prompt-dialog";
11
+ import { ArrowLeft, Settings2, Plus, ListFilter, RefreshCw, Loader2 } from "lucide-react";
12
+ import { toast } from "sonner";
13
+ import type { JiraWatchResult } from "../../../../src/types/jira";
14
+
15
+ type SubView = "tickets" | "watchers" | "credentials";
16
+
17
+ export function JiraPanel() {
18
+ const activeProject = useProjectStore((s) => s.activeProject);
19
+ const openTab = useTabStore((s) => s.openTab);
20
+ const {
21
+ configs, watchers, results, loadConfigs, loadWatchers, loadResults,
22
+ loadProjectsWithIds, projectsWithIds, softDeleteResult, resumeDebug, cancelDebug,
23
+ markRead, unreadCount, loadUnreadCount,
24
+ } = useJiraStore();
25
+
26
+ const [subView, setSubView] = useState<SubView>("tickets");
27
+ const [loading, setLoading] = useState(false);
28
+ const [debugTarget, setDebugTarget] = useState<JiraWatchResult | null>(null);
29
+
30
+ // Resolve current project → config
31
+ const projectEntry = projectsWithIds.find((p) => p.name === activeProject?.name);
32
+ const config = configs.find((c) => c.projectId === projectEntry?.id);
33
+
34
+ // Load data on mount and when project changes
35
+ useEffect(() => {
36
+ loadConfigs();
37
+ loadProjectsWithIds();
38
+ }, []);
39
+
40
+ useEffect(() => {
41
+ if (config) loadWatchers(config.id);
42
+ }, [config?.id]);
43
+
44
+ const refresh = useCallback(async () => {
45
+ setLoading(true);
46
+ try { await loadResults(); } catch {}
47
+ setLoading(false);
48
+ }, [loadResults]);
49
+
50
+ useEffect(() => { refresh(); loadUnreadCount(); }, [config?.id]);
51
+
52
+ // WS events for live status updates
53
+ useEffect(() => {
54
+ const handler = (e: Event) => {
55
+ const detail = (e as CustomEvent).detail;
56
+ if (!detail) return;
57
+ refresh();
58
+ if (detail.status === "done") {
59
+ loadUnreadCount();
60
+ toast.success(`Debug complete: ${detail.issueKey}`, {
61
+ action: detail.sessionId ? {
62
+ label: "View",
63
+ onClick: () => openTab({
64
+ type: "chat",
65
+ title: `[Jira] ${detail.issueKey}`,
66
+ projectId: activeProject?.name ?? null,
67
+ metadata: { projectName: activeProject?.name, sessionId: detail.sessionId },
68
+ closable: true,
69
+ }),
70
+ } : undefined,
71
+ });
72
+ } else if (detail.status === "failed") {
73
+ toast.error(`Debug failed: ${detail.issueKey}`);
74
+ }
75
+ };
76
+ window.addEventListener("jira:status_change", handler);
77
+ return () => window.removeEventListener("jira:status_change", handler);
78
+ }, [refresh, loadUnreadCount]);
79
+
80
+ const openSession = useCallback((r: JiraWatchResult) => {
81
+ if (!r.sessionId) return;
82
+ if (r.status === "done" && !r.readAt) markRead(r.id);
83
+ openTab({
84
+ type: "chat",
85
+ title: `[Jira] ${r.issueKey}`,
86
+ projectId: activeProject?.name ?? null,
87
+ metadata: { projectName: activeProject?.name, sessionId: r.sessionId },
88
+ closable: true,
89
+ });
90
+ }, [openTab, markRead, activeProject]);
91
+
92
+ const handleRowClick = (r: JiraWatchResult) => {
93
+ if (r.sessionId) openSession(r);
94
+ };
95
+
96
+ // No project selected
97
+ if (!activeProject) {
98
+ return (
99
+ <div className="flex items-center justify-center h-32 p-4">
100
+ <p className="text-xs text-muted-foreground text-center">Select a project to use Jira</p>
101
+ </div>
102
+ );
103
+ }
104
+
105
+ // Sub-views: watchers / credentials
106
+ if (subView !== "tickets") {
107
+ const title = subView === "watchers" ? "Watchers" : "Jira Credentials";
108
+ return (
109
+ <div className="h-full flex flex-col">
110
+ <div className="shrink-0 px-2 py-2 flex items-center gap-1.5 border-b border-border/50">
111
+ <Button size="icon" variant="ghost" className="size-7" onClick={() => setSubView("tickets")}>
112
+ <ArrowLeft className="size-4" />
113
+ </Button>
114
+ <h2 className="text-sm font-semibold truncate">{title}</h2>
115
+ </div>
116
+ <ScrollArea className="flex-1 min-h-0">
117
+ <div className="p-3">
118
+ {subView === "watchers" ? (
119
+ config ? <JiraWatcherList configId={config.id} /> : <p className="text-xs text-muted-foreground text-center py-4">Configure Jira credentials first.</p>
120
+ ) : (
121
+ projectEntry ? <JiraConfigForm projectId={projectEntry.id} existing={config ? { baseUrl: config.baseUrl, email: config.email, hasToken: config.hasToken } : null} /> : <p className="text-xs text-muted-foreground text-center py-4">Project not found in database.</p>
122
+ )}
123
+ </div>
124
+ </ScrollArea>
125
+ </div>
126
+ );
127
+ }
128
+
129
+ // Default: ticket list
130
+ return (
131
+ <div className="h-full flex flex-col">
132
+ {/* Header */}
133
+ <div className="shrink-0 px-3 py-2 flex items-center gap-1.5 border-b border-border/50">
134
+ <h2 className="text-sm font-semibold flex-1 truncate">Jira</h2>
135
+ {unreadCount > 0 && (
136
+ <span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-primary text-primary-foreground text-[10px] font-bold">
137
+ {unreadCount}
138
+ </span>
139
+ )}
140
+ <Button size="icon" variant="ghost" className="size-7" onClick={refresh} disabled={loading} title="Refresh">
141
+ {loading ? <Loader2 className="size-3.5 animate-spin" /> : <RefreshCw className="size-3.5" />}
142
+ </Button>
143
+ <Button size="icon" variant="ghost" className="size-7" onClick={() => setSubView("watchers")} title="Watchers">
144
+ <ListFilter className="size-3.5" />
145
+ </Button>
146
+ <Button size="icon" variant="ghost" className="size-7" onClick={() => setSubView("credentials")} title="Credentials">
147
+ <Settings2 className="size-3.5" />
148
+ </Button>
149
+ </div>
150
+
151
+ {/* Ticket list */}
152
+ <ScrollArea className="flex-1 min-h-0">
153
+ <div className="p-1.5 space-y-1.5">
154
+ {!config ? (
155
+ <EmptyState
156
+ message="No Jira credentials configured"
157
+ action="Set up Jira"
158
+ onAction={() => setSubView("credentials")}
159
+ />
160
+ ) : results.length === 0 && watchers.length === 0 ? (
161
+ <EmptyState
162
+ message="No watchers yet"
163
+ action="Add Watcher"
164
+ onAction={() => setSubView("watchers")}
165
+ />
166
+ ) : results.length === 0 ? (
167
+ <EmptyState message="No tickets yet. Watchers will pick up new issues." />
168
+ ) : (
169
+ results.map((r) => (
170
+ <JiraTicketCard
171
+ key={r.id}
172
+ result={r}
173
+ onDebug={setDebugTarget}
174
+ onResume={(r) => resumeDebug(r.id)}
175
+ onCancel={(r) => cancelDebug(r.id)}
176
+ onOpenSession={openSession}
177
+ onDelete={softDeleteResult}
178
+ onClick={handleRowClick}
179
+ />
180
+ ))
181
+ )}
182
+ </div>
183
+ </ScrollArea>
184
+
185
+ <JiraDebugPromptDialog result={debugTarget} onClose={() => setDebugTarget(null)} />
186
+ </div>
187
+ );
188
+ }
189
+
190
+ function EmptyState({ message, action, onAction }: { message: string; action?: string; onAction?: () => void }) {
191
+ return (
192
+ <div className="flex flex-col items-center justify-center py-12 gap-3">
193
+ <p className="text-xs text-muted-foreground text-center">{message}</p>
194
+ {action && onAction && (
195
+ <Button size="sm" variant="outline" className="min-h-[44px]" onClick={onAction}>
196
+ <Plus className="size-4 mr-1.5" /> {action}
197
+ </Button>
198
+ )}
199
+ </div>
200
+ );
201
+ }
@@ -0,0 +1,184 @@
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 { useJiraStore } from "@/stores/jira-store";
5
+ import { JiraStatusBadge } from "./jira-status-badge";
6
+ import { JiraTicketDetail } from "./jira-ticket-detail";
7
+ import { JiraDebugPromptDialog } from "./jira-debug-prompt-dialog";
8
+ import { cn } from "@/lib/utils";
9
+ import { RefreshCw, Trash2, Loader2, Play } from "lucide-react";
10
+ import { toast } from "sonner";
11
+ import type { JiraWatchResult, JiraResultStatus } from "../../../../src/types/jira";
12
+
13
+ export function JiraResultsPanel() {
14
+ const {
15
+ results, watchers, configs, loadResults, loadConfigs,
16
+ softDeleteResult, markRead, unreadCount, loadUnreadCount,
17
+ } = useJiraStore();
18
+ const [filterWatcher, setFilterWatcher] = useState<string>("all");
19
+ const [filterStatus, setFilterStatus] = useState<string>("all");
20
+ const [loading, setLoading] = useState(false);
21
+ const [ticketOpen, setTicketOpen] = useState(false);
22
+ const [selectedIssue, setSelectedIssue] = useState<{ configId: number; issueKey: string } | null>(null);
23
+ const [debugTarget, setDebugTarget] = useState<JiraWatchResult | null>(null);
24
+
25
+ const refresh = useCallback(async () => {
26
+ setLoading(true);
27
+ try {
28
+ await loadResults(
29
+ filterWatcher !== "all" ? Number(filterWatcher) : undefined,
30
+ filterStatus !== "all" ? filterStatus : undefined,
31
+ );
32
+ } catch {}
33
+ setLoading(false);
34
+ }, [filterWatcher, filterStatus, loadResults]);
35
+
36
+ useEffect(() => { loadConfigs(); loadUnreadCount(); refresh(); }, []);
37
+ useEffect(() => { refresh(); }, [filterWatcher, filterStatus]);
38
+
39
+ // Listen for jira:status_change WS events dispatched as CustomEvents
40
+ useEffect(() => {
41
+ const handler = (e: Event) => {
42
+ const detail = (e as CustomEvent).detail;
43
+ if (!detail) return;
44
+ refresh();
45
+ if (detail.status === "done") {
46
+ loadUnreadCount();
47
+ toast.success(`Debug complete: ${detail.issueKey}`, {
48
+ action: detail.sessionId ? {
49
+ label: "View",
50
+ onClick: () => window.dispatchEvent(
51
+ new CustomEvent("ppm:open-session", { detail: { sessionId: detail.sessionId } }),
52
+ ),
53
+ } : undefined,
54
+ });
55
+ } else if (detail.status === "failed") {
56
+ toast.error(`Debug failed: ${detail.issueKey}`);
57
+ }
58
+ };
59
+ window.addEventListener("jira:status_change", handler);
60
+ return () => window.removeEventListener("jira:status_change", handler);
61
+ }, [refresh, loadUnreadCount]);
62
+
63
+ const handleRowClick = (r: JiraWatchResult) => {
64
+ // Mark as read if done + unread
65
+ if (r.status === "done" && !r.readAt) markRead(r.id);
66
+
67
+ if (r.status === "done" && r.sessionId) {
68
+ window.dispatchEvent(new CustomEvent("ppm:open-session", { detail: { sessionId: r.sessionId } }));
69
+ return;
70
+ }
71
+ const watcher = watchers.find((w) => w.id === r.watcherId);
72
+ const config = configs.find((c) => c.id === watcher?.jiraConfigId);
73
+ if (config) {
74
+ setSelectedIssue({ configId: config.id, issueKey: r.issueKey });
75
+ setTicketOpen(true);
76
+ }
77
+ };
78
+
79
+ const selectedConfig = selectedIssue
80
+ ? configs.find((c) => c.id === selectedIssue.configId)
81
+ : null;
82
+
83
+ return (
84
+ <div className="h-full flex flex-col">
85
+ {/* Filter bar */}
86
+ <div className="shrink-0 px-3 py-2 flex items-center gap-2 border-b">
87
+ <Select value={filterWatcher} onValueChange={setFilterWatcher}>
88
+ <SelectTrigger className="h-8 w-32 text-xs"><SelectValue placeholder="Watcher" /></SelectTrigger>
89
+ <SelectContent>
90
+ <SelectItem value="all">All watchers</SelectItem>
91
+ {watchers.map((w) => <SelectItem key={w.id} value={String(w.id)}>{w.name}</SelectItem>)}
92
+ </SelectContent>
93
+ </Select>
94
+ <Select value={filterStatus} onValueChange={setFilterStatus}>
95
+ <SelectTrigger className="h-8 w-28 text-xs"><SelectValue placeholder="Status" /></SelectTrigger>
96
+ <SelectContent>
97
+ <SelectItem value="all">All</SelectItem>
98
+ <SelectItem value="pending">Pending</SelectItem>
99
+ <SelectItem value="queued">Queued</SelectItem>
100
+ <SelectItem value="running">Running</SelectItem>
101
+ <SelectItem value="done">Done</SelectItem>
102
+ <SelectItem value="failed">Failed</SelectItem>
103
+ </SelectContent>
104
+ </Select>
105
+ {unreadCount > 0 && (
106
+ <span className="inline-flex items-center justify-center size-5 rounded-full bg-primary text-primary-foreground text-[10px] font-bold">
107
+ {unreadCount}
108
+ </span>
109
+ )}
110
+ <Button size="icon" variant="ghost" className="size-8 ml-auto" onClick={refresh} disabled={loading}>
111
+ {loading ? <Loader2 className="size-3.5 animate-spin" /> : <RefreshCw className="size-3.5" />}
112
+ </Button>
113
+ </div>
114
+
115
+ {/* Results list */}
116
+ <div className="flex-1 overflow-y-auto">
117
+ {results.length === 0 ? (
118
+ <p className="text-sm text-muted-foreground text-center py-8">
119
+ No results yet. Configure a watcher to start.
120
+ </p>
121
+ ) : (
122
+ results.map((r) => (
123
+ <div
124
+ key={r.id}
125
+ className={cn(
126
+ "flex items-center gap-2 px-3 py-2 border-b hover:bg-muted/50 cursor-pointer",
127
+ r.status === "done" && !r.readAt && "bg-primary/5 font-medium border-l-2 border-l-primary",
128
+ )}
129
+ onClick={() => handleRowClick(r)}
130
+ >
131
+ <JiraStatusBadge status={r.status as JiraResultStatus} />
132
+ <span className="text-xs font-mono font-medium shrink-0">{r.issueKey}</span>
133
+ <span className="text-xs text-muted-foreground truncate flex-1">
134
+ {r.issueSummary ?? ""}
135
+ </span>
136
+ {r.status === "queued" && <Loader2 className="size-3.5 text-muted-foreground" />}
137
+ {r.status === "running" && <Loader2 className="size-3.5 animate-spin text-primary" />}
138
+ {r.status === "pending" && (
139
+ <Button size="icon" variant="ghost" className="size-8 shrink-0"
140
+ onClick={(e) => { e.stopPropagation(); setDebugTarget(r); }}>
141
+ <Play className="size-3.5 text-primary" />
142
+ </Button>
143
+ )}
144
+ <span className="text-[10px] text-muted-foreground shrink-0">
145
+ {timeAgo(r.createdAt)}
146
+ </span>
147
+ <Button
148
+ size="icon" variant="ghost"
149
+ className="size-6 shrink-0 text-muted-foreground hover:text-destructive"
150
+ onClick={(e) => { e.stopPropagation(); softDeleteResult(r.id); }}
151
+ >
152
+ <Trash2 className="size-3" />
153
+ </Button>
154
+ </div>
155
+ ))
156
+ )}
157
+ </div>
158
+
159
+ {/* Debug prompt dialog */}
160
+ <JiraDebugPromptDialog result={debugTarget} onClose={() => setDebugTarget(null)} />
161
+
162
+ {/* Ticket detail dialog */}
163
+ {selectedIssue && (
164
+ <JiraTicketDetail
165
+ open={ticketOpen}
166
+ onOpenChange={setTicketOpen}
167
+ configId={selectedIssue.configId}
168
+ issueKey={selectedIssue.issueKey}
169
+ baseUrl={selectedConfig?.baseUrl}
170
+ />
171
+ )}
172
+ </div>
173
+ );
174
+ }
175
+
176
+ function timeAgo(dateStr: string): string {
177
+ const diff = Date.now() - new Date(dateStr).getTime();
178
+ const mins = Math.floor(diff / 60000);
179
+ if (mins < 1) return "now";
180
+ if (mins < 60) return `${mins}m`;
181
+ const hrs = Math.floor(mins / 60);
182
+ if (hrs < 24) return `${hrs}h`;
183
+ return `${Math.floor(hrs / 24)}d`;
184
+ }
@@ -0,0 +1,58 @@
1
+ import { useEffect } from "react";
2
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
3
+ import { Separator } from "@/components/ui/separator";
4
+ import { useJiraStore } from "@/stores/jira-store";
5
+ import { JiraConfigForm } from "./jira-config-form";
6
+ import { JiraWatcherList } from "./jira-watcher-list";
7
+ import { JiraResultsPanel } from "./jira-results-panel";
8
+
9
+ export function JiraSettingsSection() {
10
+ const {
11
+ configs, selectedProjectId, setSelectedProjectId,
12
+ loadConfigs, loadWatchers, projectsWithIds, loadProjectsWithIds,
13
+ } = useJiraStore();
14
+
15
+ useEffect(() => { loadConfigs(); loadProjectsWithIds(); }, []);
16
+
17
+ const selectedConfig = configs.find((c) => c.projectId === selectedProjectId);
18
+
19
+ useEffect(() => {
20
+ if (selectedConfig) loadWatchers(selectedConfig.id);
21
+ }, [selectedConfig?.id]);
22
+
23
+ return (
24
+ <div className="space-y-4">
25
+ <div>
26
+ <label className="text-xs text-muted-foreground">Project</label>
27
+ <Select
28
+ value={selectedProjectId ? String(selectedProjectId) : ""}
29
+ onValueChange={(v) => setSelectedProjectId(Number(v))}
30
+ >
31
+ <SelectTrigger className="h-9"><SelectValue placeholder="Select project..." /></SelectTrigger>
32
+ <SelectContent>
33
+ {projectsWithIds.map((p) => (
34
+ <SelectItem key={p.id} value={String(p.id)}>{p.name}</SelectItem>
35
+ ))}
36
+ </SelectContent>
37
+ </Select>
38
+ </div>
39
+
40
+ {selectedProjectId && (
41
+ <>
42
+ <JiraConfigForm
43
+ projectId={selectedProjectId}
44
+ existing={selectedConfig ? { baseUrl: selectedConfig.baseUrl, email: selectedConfig.email, hasToken: selectedConfig.hasToken } : null}
45
+ />
46
+ {selectedConfig && (
47
+ <>
48
+ <Separator />
49
+ <JiraWatcherList configId={selectedConfig.id} />
50
+ <Separator />
51
+ <JiraResultsPanel />
52
+ </>
53
+ )}
54
+ </>
55
+ )}
56
+ </div>
57
+ );
58
+ }
@@ -0,0 +1,18 @@
1
+ import { Badge } from "@/components/ui/badge";
2
+ import type { JiraResultStatus } from "../../../../src/types/jira";
3
+
4
+ const STATUS_STYLES: Record<JiraResultStatus, string> = {
5
+ pending: "bg-yellow-500/15 text-yellow-600 border-yellow-500/30",
6
+ queued: "bg-orange-500/15 text-orange-600 border-orange-500/30",
7
+ running: "bg-blue-500/15 text-blue-600 border-blue-500/30",
8
+ done: "bg-green-500/15 text-green-600 border-green-500/30",
9
+ failed: "bg-red-500/15 text-red-600 border-red-500/30",
10
+ };
11
+
12
+ export function JiraStatusBadge({ status }: { status: JiraResultStatus }) {
13
+ return (
14
+ <Badge variant="outline" className={`text-[10px] px-1.5 py-0 ${STATUS_STYLES[status] ?? ""}`}>
15
+ {status}
16
+ </Badge>
17
+ );
18
+ }