@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
@@ -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
+ }
@@ -0,0 +1,144 @@
1
+ import { Play, RotateCcw, Square, ExternalLink, Trash2, Loader2, MoreHorizontal } from "lucide-react";
2
+ import {
3
+ DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
4
+ } from "@/components/ui/dropdown-menu";
5
+ import { cn } from "@/lib/utils";
6
+ import type { JiraWatchResult, JiraResultStatus } from "../../../../src/types/jira";
7
+
8
+ /** Status dot color */
9
+ const DOT_COLORS: Record<JiraResultStatus, string> = {
10
+ pending: "bg-yellow-500",
11
+ queued: "bg-orange-500",
12
+ running: "bg-blue-500 animate-pulse",
13
+ done: "bg-green-500",
14
+ failed: "bg-red-500",
15
+ };
16
+
17
+ /** Relative time helper */
18
+ function timeAgo(dateStr: string): string {
19
+ const diff = Date.now() - new Date(dateStr).getTime();
20
+ const mins = Math.floor(diff / 60000);
21
+ if (mins < 1) return "now";
22
+ if (mins < 60) return `${mins}m`;
23
+ const hrs = Math.floor(mins / 60);
24
+ if (hrs < 24) return `${hrs}h`;
25
+ const days = Math.floor(hrs / 24);
26
+ return `${days}d`;
27
+ }
28
+
29
+ interface Props {
30
+ result: JiraWatchResult;
31
+ onDebug: (r: JiraWatchResult) => void;
32
+ onResume: (r: JiraWatchResult) => void;
33
+ onCancel: (r: JiraWatchResult) => void;
34
+ onOpenSession: (r: JiraWatchResult) => void;
35
+ onDelete: (id: number) => void;
36
+ onClick: (r: JiraWatchResult) => void;
37
+ }
38
+
39
+ export function JiraTicketCard({ result, onDebug, onResume, onCancel, onOpenSession, onDelete, onClick }: Props) {
40
+ const r = result;
41
+ const isUnread = r.status === "done" && !r.readAt;
42
+ const hasSession = !!r.sessionId;
43
+ const canResume = r.status === "failed" && hasSession;
44
+ const canDebug = r.status === "pending" || (r.status === "failed" && !hasSession);
45
+ const canCancel = r.status === "queued" || r.status === "running";
46
+ const status = r.status as JiraResultStatus;
47
+
48
+ return (
49
+ <div
50
+ className={cn(
51
+ "group rounded bg-card shadow-sm hover:shadow-md hover:bg-accent/50 hover:-translate-y-px cursor-pointer transition-all duration-150",
52
+ "px-2.5 py-2 space-y-1",
53
+ isUnread && "ring-1 ring-primary/30",
54
+ )}
55
+ onClick={() => onClick(r)}
56
+ >
57
+ {/* Line 1: issue key + summary + time */}
58
+ <div className="flex items-center gap-1.5 min-w-0">
59
+ <span className="text-xs font-mono font-semibold text-foreground shrink-0">{r.issueKey}</span>
60
+ {isUnread && <span className="size-1.5 rounded-full bg-primary shrink-0" />}
61
+ <span className="text-xs text-muted-foreground truncate flex-1 min-w-0">
62
+ {r.issueSummary || "No summary"}
63
+ </span>
64
+ <span className="text-[10px] text-muted-foreground/60 tabular-nums shrink-0">
65
+ {timeAgo(r.createdAt)}
66
+ </span>
67
+ </div>
68
+
69
+ {/* Line 2: status dot + label + action buttons */}
70
+ <div className="flex items-center justify-between min-w-0">
71
+ <span className="inline-flex items-center gap-1.5 text-[11px] text-muted-foreground">
72
+ <span className={cn("size-1.5 rounded-full shrink-0", DOT_COLORS[status])} />
73
+ {status}
74
+ </span>
75
+
76
+ <div
77
+ className="flex items-center gap-0.5 shrink-0"
78
+ onClick={(e) => e.stopPropagation()}
79
+ >
80
+ {canResume && (
81
+ <button
82
+ type="button"
83
+ className="flex items-center gap-1 h-6 px-1.5 rounded text-[10px] font-medium text-primary hover:bg-primary/10 active:scale-95 transition-colors"
84
+ onClick={() => onResume(r)}
85
+ title="Resume debug session"
86
+ >
87
+ <RotateCcw className="size-3" />
88
+ <span>Resume</span>
89
+ </button>
90
+ )}
91
+ {canDebug && (
92
+ <button
93
+ type="button"
94
+ className="flex items-center justify-center size-6 rounded text-muted-foreground hover:text-primary active:scale-95 transition-colors"
95
+ onClick={() => onDebug(r)}
96
+ title="Debug"
97
+ >
98
+ <Play className="size-3" />
99
+ </button>
100
+ )}
101
+ {canCancel && (
102
+ <button
103
+ type="button"
104
+ className="flex items-center justify-center size-6 rounded text-muted-foreground hover:text-destructive active:scale-95 transition-colors"
105
+ onClick={() => onCancel(r)}
106
+ title="Stop debug"
107
+ >
108
+ <Square className="size-3" />
109
+ </button>
110
+ )}
111
+ {r.status === "running" && (
112
+ <Loader2 className="size-3 animate-spin text-primary" />
113
+ )}
114
+ {hasSession && (
115
+ <button
116
+ type="button"
117
+ className="flex items-center gap-1 h-6 px-1.5 rounded text-[10px] font-medium text-primary hover:bg-primary/10 active:scale-95 transition-colors"
118
+ onClick={() => onOpenSession(r)}
119
+ title="Open session"
120
+ >
121
+ <ExternalLink className="size-3" />
122
+ <span>Open</span>
123
+ </button>
124
+ )}
125
+ <DropdownMenu>
126
+ <DropdownMenuTrigger asChild>
127
+ <button
128
+ type="button"
129
+ className="flex items-center justify-center size-6 rounded text-muted-foreground hover:text-foreground active:scale-95 transition-colors"
130
+ >
131
+ <MoreHorizontal className="size-3" />
132
+ </button>
133
+ </DropdownMenuTrigger>
134
+ <DropdownMenuContent align="end">
135
+ <DropdownMenuItem className="text-destructive" onClick={() => onDelete(r.id)}>
136
+ <Trash2 className="size-3.5 mr-2" /> Delete
137
+ </DropdownMenuItem>
138
+ </DropdownMenuContent>
139
+ </DropdownMenu>
140
+ </div>
141
+ </div>
142
+ </div>
143
+ );
144
+ }