@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,153 @@
1
+ import { useState, useEffect } from "react";
2
+ import { Button } from "@/components/ui/button";
3
+ import { Input } from "@/components/ui/input";
4
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
5
+ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
6
+ import { api } from "@/lib/api-client";
7
+ import { ExternalLink, Loader2 } from "lucide-react";
8
+ import type { JiraIssue, JiraTransition } from "../../../../src/types/jira";
9
+
10
+ interface Props {
11
+ open: boolean;
12
+ onOpenChange: (open: boolean) => void;
13
+ configId: number;
14
+ issueKey: string;
15
+ baseUrl?: string;
16
+ }
17
+
18
+ export function JiraTicketDetail({ open, onOpenChange, configId, issueKey, baseUrl }: Props) {
19
+ const [issue, setIssue] = useState<JiraIssue | null>(null);
20
+ const [transitions, setTransitions] = useState<JiraTransition[]>([]);
21
+ const [loading, setLoading] = useState(false);
22
+ const [saving, setSaving] = useState(false);
23
+ const [editSummary, setEditSummary] = useState("");
24
+ const [editing, setEditing] = useState(false);
25
+
26
+ useEffect(() => {
27
+ if (!open || !issueKey) return;
28
+ setLoading(true);
29
+ Promise.all([
30
+ api.get<JiraIssue>(`/api/jira/ticket/${configId}/${issueKey}`),
31
+ api.get<JiraTransition[]>(`/api/jira/ticket/${configId}/${issueKey}/transitions`),
32
+ ]).then(([iss, trans]) => {
33
+ setIssue(iss);
34
+ setEditSummary(iss.fields.summary);
35
+ setTransitions(trans);
36
+ }).catch(() => {}).finally(() => setLoading(false));
37
+ }, [open, issueKey, configId]);
38
+
39
+ const handleSaveSummary = async () => {
40
+ if (!issue || editSummary === issue.fields.summary) { setEditing(false); return; }
41
+ setSaving(true);
42
+ try {
43
+ await api.put(`/api/jira/ticket/${configId}/${issueKey}`, { fields: { summary: editSummary } });
44
+ setIssue({ ...issue, fields: { ...issue.fields, summary: editSummary } });
45
+ setEditing(false);
46
+ } catch {}
47
+ setSaving(false);
48
+ };
49
+
50
+ const handleTransition = async (transitionId: string) => {
51
+ setSaving(true);
52
+ try {
53
+ await api.post(`/api/jira/ticket/${configId}/${issueKey}/transition`, { transitionId });
54
+ // Refresh issue
55
+ const iss = await api.get<JiraIssue>(`/api/jira/ticket/${configId}/${issueKey}`);
56
+ setIssue(iss);
57
+ } catch {}
58
+ setSaving(false);
59
+ };
60
+
61
+ return (
62
+ <Dialog open={open} onOpenChange={onOpenChange}>
63
+ <DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto">
64
+ <DialogHeader>
65
+ <DialogTitle className="flex items-center gap-2">
66
+ {issueKey}
67
+ {baseUrl && (
68
+ <a href={`${baseUrl}/browse/${issueKey}`} target="_blank" rel="noopener noreferrer"
69
+ className="text-muted-foreground hover:text-foreground">
70
+ <ExternalLink className="size-4" />
71
+ </a>
72
+ )}
73
+ </DialogTitle>
74
+ </DialogHeader>
75
+
76
+ {loading ? (
77
+ <div className="flex justify-center py-8"><Loader2 className="size-6 animate-spin" /></div>
78
+ ) : issue ? (
79
+ <div className="space-y-3 text-sm">
80
+ {/* Summary */}
81
+ <div>
82
+ <label className="text-xs text-muted-foreground">Summary</label>
83
+ {editing ? (
84
+ <div className="flex gap-1">
85
+ <Input value={editSummary} onChange={(e) => setEditSummary(e.target.value)} className="h-8 text-sm" />
86
+ <Button size="sm" onClick={handleSaveSummary} disabled={saving} className="h-8">
87
+ {saving ? <Loader2 className="size-3 animate-spin" /> : "Save"}
88
+ </Button>
89
+ </div>
90
+ ) : (
91
+ <p className="cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5" onClick={() => setEditing(true)}>
92
+ {issue.fields.summary}
93
+ </p>
94
+ )}
95
+ </div>
96
+
97
+ {/* Status + transition */}
98
+ <div className="flex gap-4">
99
+ <div className="flex-1">
100
+ <label className="text-xs text-muted-foreground">Status</label>
101
+ <p className="font-medium">{issue.fields.status.name}</p>
102
+ </div>
103
+ {transitions.length > 0 && (
104
+ <div className="flex-1">
105
+ <label className="text-xs text-muted-foreground">Transition to</label>
106
+ <Select onValueChange={handleTransition} disabled={saving}>
107
+ <SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Change status..." /></SelectTrigger>
108
+ <SelectContent>
109
+ {transitions.map((t) => (
110
+ <SelectItem key={t.id} value={t.id}>{t.name} → {t.to.name}</SelectItem>
111
+ ))}
112
+ </SelectContent>
113
+ </Select>
114
+ </div>
115
+ )}
116
+ </div>
117
+
118
+ {/* Priority & Assignee */}
119
+ <div className="flex gap-4">
120
+ <div className="flex-1">
121
+ <label className="text-xs text-muted-foreground">Priority</label>
122
+ <p>{issue.fields.priority?.name ?? "None"}</p>
123
+ </div>
124
+ <div className="flex-1">
125
+ <label className="text-xs text-muted-foreground">Assignee</label>
126
+ <p>{issue.fields.assignee?.displayName ?? "Unassigned"}</p>
127
+ </div>
128
+ </div>
129
+
130
+ {/* Description */}
131
+ {issue.fields.description && (
132
+ <div>
133
+ <label className="text-xs text-muted-foreground">Description</label>
134
+ <p className="text-xs text-muted-foreground whitespace-pre-wrap bg-muted/30 rounded p-2 max-h-40 overflow-y-auto">
135
+ {typeof issue.fields.description === "string"
136
+ ? issue.fields.description
137
+ : JSON.stringify(issue.fields.description, null, 2)}
138
+ </p>
139
+ </div>
140
+ )}
141
+
142
+ {/* Timestamps */}
143
+ <div className="text-xs text-muted-foreground">
144
+ Updated: {new Date(issue.fields.updated).toLocaleString()}
145
+ </div>
146
+ </div>
147
+ ) : (
148
+ <p className="text-sm text-muted-foreground text-center py-4">Failed to load issue.</p>
149
+ )}
150
+ </DialogContent>
151
+ </Dialog>
152
+ );
153
+ }
@@ -0,0 +1,154 @@
1
+ import { useState } from "react";
2
+ import { Button } from "@/components/ui/button";
3
+ import { Input } from "@/components/ui/input";
4
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
5
+ import { useJiraStore } from "@/stores/jira-store";
6
+ import { JiraFilterBuilder } from "./jira-filter-builder";
7
+ import { Loader2, Search } from "lucide-react";
8
+ import type { JiraWatcher, JiraWatcherMode, JiraIssue } from "../../../../src/types/jira";
9
+
10
+ const INTERVALS = [
11
+ { label: "30s", value: 30000 }, { label: "1m", value: 60000 },
12
+ { label: "2m", value: 120000 }, { label: "5m", value: 300000 },
13
+ { label: "10m", value: 600000 }, { label: "30m", value: 1800000 },
14
+ { label: "1h", value: 3600000 },
15
+ ];
16
+
17
+ interface Props {
18
+ configId: number;
19
+ existing?: JiraWatcher;
20
+ onDone: () => void;
21
+ }
22
+
23
+ export function JiraWatcherForm({ configId, existing, onDone }: Props) {
24
+ const { createWatcher, updateWatcher, testJql } = useJiraStore();
25
+ const [name, setName] = useState(existing?.name ?? "");
26
+ const [jql, setJql] = useState(existing?.jql ?? "");
27
+ const [intervalMs, setIntervalMs] = useState(existing?.intervalMs ?? 120000);
28
+ const [mode, setMode] = useState<JiraWatcherMode>(existing?.mode ?? "debug");
29
+ const [prompt, setPrompt] = useState(existing?.promptTemplate ?? "");
30
+ const [saving, setSaving] = useState(false);
31
+
32
+ // Test JQL state
33
+ const [testing, setTesting] = useState(false);
34
+ const [testResults, setTestResults] = useState<JiraIssue[] | null>(null);
35
+ const [testTotal, setTestTotal] = useState(0);
36
+ const [testError, setTestError] = useState<string | null>(null);
37
+
38
+ const isEdit = !!existing;
39
+
40
+ const handleSubmit = async (e: React.FormEvent) => {
41
+ e.preventDefault();
42
+ if (!name || !jql) return;
43
+ setSaving(true);
44
+ try {
45
+ if (isEdit) {
46
+ await updateWatcher(existing.id, {
47
+ name, jql, intervalMs, mode,
48
+ promptTemplate: prompt || null,
49
+ });
50
+ } else {
51
+ await createWatcher({ configId, name, jql, intervalMs, mode, promptTemplate: prompt || undefined });
52
+ }
53
+ onDone();
54
+ } catch {}
55
+ setSaving(false);
56
+ };
57
+
58
+ const handleTestJql = async () => {
59
+ if (!jql) return;
60
+ setTesting(true);
61
+ setTestError(null);
62
+ setTestResults(null);
63
+ try {
64
+ const res = await testJql(configId, jql);
65
+ setTestResults(res.issues);
66
+ setTestTotal(res.total);
67
+ } catch (e: any) {
68
+ setTestError(e.message ?? "Test failed");
69
+ }
70
+ setTesting(false);
71
+ };
72
+
73
+ return (
74
+ <form onSubmit={handleSubmit} className="space-y-3 min-w-0">
75
+ <div>
76
+ <label className="text-xs text-muted-foreground">Name</label>
77
+ <Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Bug watcher" className="h-9" />
78
+ </div>
79
+ <JiraFilterBuilder value={jql} onChange={setJql} configId={configId} />
80
+
81
+ {/* Test JQL button */}
82
+ <Button
83
+ type="button" size="sm" variant="outline"
84
+ className="w-full min-h-[44px]"
85
+ disabled={!jql || testing}
86
+ onClick={handleTestJql}
87
+ >
88
+ {testing ? <Loader2 className="size-4 animate-spin mr-1.5" /> : <Search className="size-4 mr-1.5" />}
89
+ Test Filter
90
+ </Button>
91
+
92
+ {/* Test results preview */}
93
+ {testError && (
94
+ <p className="text-xs text-destructive bg-destructive/10 rounded-md px-3 py-2">{testError}</p>
95
+ )}
96
+ {testResults && (
97
+ <div className="border rounded-md max-h-48 overflow-hidden">
98
+ <div className="px-3 py-1.5 border-b bg-muted/50 text-xs text-muted-foreground font-medium">
99
+ {testTotal} ticket{testTotal !== 1 ? "s" : ""} found
100
+ {testTotal > testResults.length && ` (showing ${testResults.length})`}
101
+ </div>
102
+ <div className="overflow-y-auto max-h-[calc(12rem-30px)]">
103
+ {testResults.length === 0 ? (
104
+ <p className="text-xs text-muted-foreground text-center py-4">No tickets match this filter.</p>
105
+ ) : (
106
+ testResults.map((issue) => (
107
+ <div key={issue.key} className="flex items-center gap-2 px-3 py-1.5 border-b last:border-b-0 text-xs min-w-0">
108
+ <span className="font-mono font-medium shrink-0">{issue.key}</span>
109
+ <span className="truncate text-muted-foreground flex-1 min-w-0">{issue.fields.summary}</span>
110
+ <span className="shrink-0 text-[10px] text-muted-foreground px-1.5 py-0.5 bg-muted rounded whitespace-nowrap">
111
+ {issue.fields.status.name}
112
+ </span>
113
+ </div>
114
+ ))
115
+ )}
116
+ </div>
117
+ </div>
118
+ )}
119
+
120
+ <div className="flex gap-2">
121
+ <div className="flex-1">
122
+ <label className="text-xs text-muted-foreground">Interval</label>
123
+ <Select value={String(intervalMs)} onValueChange={(v) => setIntervalMs(Number(v))}>
124
+ <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
125
+ <SelectContent>
126
+ {INTERVALS.map((i) => <SelectItem key={i.value} value={String(i.value)}>{i.label}</SelectItem>)}
127
+ </SelectContent>
128
+ </Select>
129
+ </div>
130
+ <div className="flex-1">
131
+ <label className="text-xs text-muted-foreground">Mode</label>
132
+ <Select value={mode} onValueChange={(v) => setMode(v as JiraWatcherMode)}>
133
+ <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
134
+ <SelectContent>
135
+ <SelectItem value="debug">Debug + Notify</SelectItem>
136
+ <SelectItem value="notify">Notify only</SelectItem>
137
+ </SelectContent>
138
+ </Select>
139
+ </div>
140
+ </div>
141
+ <div>
142
+ <label className="text-xs text-muted-foreground">Prompt template (optional)</label>
143
+ <textarea
144
+ value={prompt} onChange={(e) => setPrompt(e.target.value)}
145
+ className="w-full h-16 rounded-md border border-input bg-background px-3 py-2 text-xs font-mono resize-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
146
+ placeholder="Debug Jira issue {issue_key}: {summary}"
147
+ />
148
+ </div>
149
+ <Button type="submit" size="sm" disabled={saving || !name || !jql} className="min-h-[44px] w-full">
150
+ {saving ? <Loader2 className="size-4 animate-spin" /> : isEdit ? "Save Changes" : "Create Watcher"}
151
+ </Button>
152
+ </form>
153
+ );
154
+ }
@@ -0,0 +1,98 @@
1
+ import { useState } from "react";
2
+ import { Button } from "@/components/ui/button";
3
+ import { Switch } from "@/components/ui/switch";
4
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
5
+ import { useJiraStore } from "@/stores/jira-store";
6
+ import { JiraWatcherForm } from "./jira-watcher-form";
7
+ import { Plus, Trash2, Play, Loader2, Pencil } from "lucide-react";
8
+ import { toast } from "sonner";
9
+ import type { JiraWatcher } from "../../../../src/types/jira";
10
+
11
+ interface Props { configId: number }
12
+
13
+ export function JiraWatcherList({ configId }: Props) {
14
+ const { watchers, deleteWatcher, toggleWatcher, pullWatcher } = useJiraStore();
15
+ const [addOpen, setAddOpen] = useState(false);
16
+ const [editingWatcher, setEditingWatcher] = useState<JiraWatcher | null>(null);
17
+ const [pulling, setPulling] = useState<number | null>(null);
18
+
19
+ const handlePull = async (id: number) => {
20
+ setPulling(id);
21
+ try {
22
+ const res = await pullWatcher(id);
23
+ toast.success(`Pulled ${res.newIssues} new issue${res.newIssues !== 1 ? "s" : ""}`);
24
+ } catch (e: any) {
25
+ toast.error(e.message ?? "Pull failed");
26
+ }
27
+ setPulling(null);
28
+ };
29
+
30
+ return (
31
+ <div className="space-y-2">
32
+ <div className="flex items-center justify-between">
33
+ <h4 className="text-sm font-medium">Watchers</h4>
34
+ <Dialog open={addOpen} onOpenChange={setAddOpen}>
35
+ <DialogTrigger asChild>
36
+ <Button size="sm" variant="outline" className="min-h-[44px]">
37
+ <Plus className="size-4 mr-1" /> Add
38
+ </Button>
39
+ </DialogTrigger>
40
+ <DialogContent className="max-w-md overflow-hidden">
41
+ <DialogHeader><DialogTitle>New Watcher</DialogTitle></DialogHeader>
42
+ <JiraWatcherForm configId={configId} onDone={() => setAddOpen(false)} />
43
+ </DialogContent>
44
+ </Dialog>
45
+ </div>
46
+
47
+ {watchers.length === 0 && (
48
+ <p className="text-sm text-muted-foreground py-4 text-center">No watchers yet.</p>
49
+ )}
50
+
51
+ {watchers.map((w) => (
52
+ <div key={w.id} className="flex items-center gap-2 p-2 rounded-md border text-sm">
53
+ <Switch
54
+ checked={w.enabled}
55
+ onCheckedChange={(val) => toggleWatcher(w.id, val)}
56
+ className="shrink-0"
57
+ />
58
+ <div className="flex-1 min-w-0">
59
+ <div className="font-medium truncate">{w.name}</div>
60
+ <div className="text-xs text-muted-foreground truncate font-mono">{w.jql}</div>
61
+ </div>
62
+ <span className="text-xs text-muted-foreground shrink-0">
63
+ {w.mode === "notify" ? "notify" : "debug"} · {formatInterval(w.intervalMs)}
64
+ </span>
65
+ <Button size="icon" variant="ghost" className="size-8" onClick={() => setEditingWatcher(w)}>
66
+ <Pencil className="size-3.5" />
67
+ </Button>
68
+ <Button size="icon" variant="ghost" className="size-8" onClick={() => handlePull(w.id)} disabled={pulling === w.id}>
69
+ {pulling === w.id ? <Loader2 className="size-3.5 animate-spin" /> : <Play className="size-3.5" />}
70
+ </Button>
71
+ <Button size="icon" variant="ghost" className="size-8 text-destructive" onClick={() => deleteWatcher(w.id)}>
72
+ <Trash2 className="size-3.5" />
73
+ </Button>
74
+ </div>
75
+ ))}
76
+
77
+ {/* Edit dialog */}
78
+ <Dialog open={!!editingWatcher} onOpenChange={(open) => { if (!open) setEditingWatcher(null); }}>
79
+ <DialogContent className="max-w-md overflow-hidden">
80
+ <DialogHeader><DialogTitle>Edit Watcher</DialogTitle></DialogHeader>
81
+ {editingWatcher && (
82
+ <JiraWatcherForm
83
+ configId={configId}
84
+ existing={editingWatcher}
85
+ onDone={() => setEditingWatcher(null)}
86
+ />
87
+ )}
88
+ </DialogContent>
89
+ </Dialog>
90
+ </div>
91
+ );
92
+ }
93
+
94
+ function formatInterval(ms: number): string {
95
+ if (ms >= 3600000) return `${ms / 3600000}h`;
96
+ if (ms >= 60000) return `${ms / 60000}m`;
97
+ return `${ms / 1000}s`;
98
+ }
@@ -1,6 +1,6 @@
1
- import { useState, useCallback, useEffect } from "react";
1
+ import { useState, useCallback, useEffect, useMemo } from "react";
2
2
  import {
3
- X, Bug, FolderOpen, GitBranch, Settings, Database,
3
+ X, Bug as BugIcon, FolderOpen, GitBranch, Settings, Database,
4
4
  } from "lucide-react";
5
5
  import { useShallow } from "zustand/react/shallow";
6
6
  import { useProjectStore } from "@/stores/project-store";
@@ -9,12 +9,13 @@ import { FileTree } from "@/components/explorer/file-tree";
9
9
  import { GitStatusPanel } from "@/components/git/git-status-panel";
10
10
  import { SettingsTab } from "@/components/settings/settings-tab";
11
11
  import { DatabaseSidebar } from "@/components/database/database-sidebar";
12
+ import { JiraPanel } from "@/components/jira/jira-panel";
12
13
  import { openBugReportPopup } from "@/lib/report-bug";
13
14
  import { cn } from "@/lib/utils";
14
15
 
15
- type DrawerTab = "explorer" | "git" | "settings" | "database";
16
+ type DrawerTab = "explorer" | "git" | "settings" | "database" | "jira";
16
17
 
17
- const TABS: { id: DrawerTab; label: string; icon: React.ElementType }[] = [
18
+ const BASE_TABS: { id: DrawerTab; label: string; icon: React.ElementType }[] = [
18
19
  { id: "explorer", label: "Explorer", icon: FolderOpen },
19
20
  { id: "git", label: "Git", icon: GitBranch },
20
21
  { id: "database", label: "Database", icon: Database },
@@ -31,8 +32,17 @@ interface MobileDrawerProps {
31
32
  export function MobileDrawer({ isOpen, onClose, initialTab }: MobileDrawerProps) {
32
33
  const { activeProject } = useProjectStore(useShallow((s) => ({ activeProject: s.activeProject })));
33
34
  const version = useSettingsStore((s) => s.version);
35
+ const jiraEnabled = useSettingsStore((s) => s.jiraEnabled);
34
36
  const [activeTab, setActiveTab] = useState<DrawerTab>(initialTab ?? "explorer");
35
37
 
38
+ const TABS = useMemo(() => {
39
+ if (!jiraEnabled) return BASE_TABS;
40
+ const tabs = [...BASE_TABS];
41
+ const settingsIdx = tabs.findIndex((t) => t.id === "settings");
42
+ tabs.splice(settingsIdx, 0, { id: "jira", label: "Jira", icon: BugIcon });
43
+ return tabs;
44
+ }, [jiraEnabled]);
45
+
36
46
  // Sync when initialTab changes (e.g. settings button opens drawer)
37
47
  useEffect(() => {
38
48
  if (initialTab) setActiveTab(initialTab);
@@ -92,6 +102,9 @@ export function MobileDrawer({ isOpen, onClose, initialTab }: MobileDrawerProps)
92
102
  {activeTab === "database" && (
93
103
  <DatabaseSidebar />
94
104
  )}
105
+ {activeTab === "jira" && (
106
+ <JiraPanel />
107
+ )}
95
108
  {activeTab === "settings" && (
96
109
  <SettingsTab />
97
110
  )}
@@ -126,7 +139,7 @@ export function MobileDrawer({ isOpen, onClose, initialTab }: MobileDrawerProps)
126
139
  onClick={handleReportBug}
127
140
  className="flex items-center gap-1 text-[10px] text-text-subtle hover:text-text-secondary transition-colors"
128
141
  >
129
- <Bug className="size-3" />
142
+ <BugIcon className="size-3" />
130
143
  <span>Report Bug</span>
131
144
  </button>
132
145
  </div>
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useRef, useMemo, memo } from "react";
2
- import { PanelLeftClose, PanelLeftOpen, FolderOpen, GitBranch, Settings, Database, Search, Puzzle } from "lucide-react";
2
+ import { PanelLeftClose, PanelLeftOpen, FolderOpen, GitBranch, Settings, Database, Search, Puzzle, Bug } from "lucide-react";
3
3
  import { useShallow } from "zustand/react/shallow";
4
4
  import { useProjectStore } from "@/stores/project-store";
5
5
  import { useSettingsStore, type SidebarActiveTab } from "@/stores/settings-store";
@@ -10,7 +10,9 @@ import { SettingsTab } from "@/components/settings/settings-tab";
10
10
  import { DatabaseSidebar } from "@/components/database/database-sidebar";
11
11
  import { SearchPanel } from "@/components/explorer/search-panel";
12
12
  import { ExtensionTreeView } from "@/components/extensions/extension-tree-view";
13
+ import { JiraPanel } from "@/components/jira/jira-panel";
13
14
  import { useGitStatusStore, useGitChangesPoller } from "@/stores/git-status-store";
15
+ import { useJiraStore } from "@/stores/jira-store";
14
16
  import { cn } from "@/lib/utils";
15
17
 
16
18
  const BUILTIN_TABS: { id: SidebarActiveTab; label: string; icon: React.ElementType }[] = [
@@ -67,15 +69,22 @@ export const Sidebar = memo(function Sidebar() {
67
69
  const setSidebarWidth = useSettingsStore((s) => s.setSidebarWidth);
68
70
  const sidebarActiveTab = useSettingsStore((s) => s.sidebarActiveTab);
69
71
  const setSidebarActiveTab = useSettingsStore((s) => s.setSidebarActiveTab);
72
+ const jiraEnabled = useSettingsStore((s) => s.jiraEnabled);
70
73
  const contributions = useExtensionStore((s) => s.contributions);
71
74
  const gitChangesCount = useGitStatusStore((s) =>
72
75
  activeProject?.name ? (s.counts.get(activeProject.name) ?? 0) : 0,
73
76
  );
77
+ const jiraUnreadCount = useJiraStore((s) => s.unreadCount);
74
78
  useGitChangesPoller(activeProject?.name, sidebarActiveTab === "git");
75
79
 
76
- // Build tabs list: built-in + extension-contributed sidebar views
80
+ // Build tabs list: built-in + jira (conditional) + extension-contributed sidebar views
77
81
  const TABS = useMemo(() => {
78
82
  const tabs: { id: SidebarActiveTab; label: string; icon: React.ElementType }[] = [...BUILTIN_TABS];
83
+ if (jiraEnabled) {
84
+ // Insert Jira before Settings
85
+ const settingsIdx = tabs.findIndex((t) => t.id === "settings");
86
+ tabs.splice(settingsIdx, 0, { id: "jira", label: "Jira", icon: Bug });
87
+ }
79
88
  if (contributions?.views) {
80
89
  const sidebarViews = contributions.views["sidebar"] ?? contributions.views["explorer"] ?? [];
81
90
  for (const view of sidebarViews) {
@@ -83,7 +92,7 @@ export const Sidebar = memo(function Sidebar() {
83
92
  }
84
93
  }
85
94
  return tabs;
86
- }, [contributions]);
95
+ }, [contributions, jiraEnabled]);
87
96
 
88
97
  if (sidebarCollapsed) {
89
98
  return (
@@ -126,6 +135,11 @@ export const Sidebar = memo(function Sidebar() {
126
135
  {gitChangesCount > 99 ? "99+" : gitChangesCount}
127
136
  </span>
128
137
  )}
138
+ {tab.id === "jira" && jiraUnreadCount > 0 && (
139
+ <span className="absolute top-1 right-1 min-w-[16px] h-4 px-1 flex items-center justify-center rounded-full bg-primary text-primary-foreground text-[10px] font-medium leading-none">
140
+ {jiraUnreadCount > 99 ? "99+" : jiraUnreadCount}
141
+ </span>
142
+ )}
129
143
  </button>
130
144
  );
131
145
  })}
@@ -158,6 +172,9 @@ export const Sidebar = memo(function Sidebar() {
158
172
  {sidebarActiveTab === "database" && (
159
173
  <DatabaseSidebar />
160
174
  )}
175
+ {sidebarActiveTab === "jira" && (
176
+ <JiraPanel />
177
+ )}
161
178
  {sidebarActiveTab === "settings" && (
162
179
  <SettingsTab />
163
180
  )}
@@ -1,10 +1,11 @@
1
1
  import { useState, useCallback, useRef } from "react";
2
2
  import {
3
3
  Moon, Sun, Monitor, Bell, BellOff, Check, ChevronRight, ArrowLeft,
4
- Bot, BellRing, Keyboard, Globe, Plug, Puzzle,
4
+ Bot, BellRing, Keyboard, Globe, Plug, Puzzle, Bug,
5
5
  } from "lucide-react";
6
6
  import { Button } from "@/components/ui/button";
7
7
  import { Input } from "@/components/ui/input";
8
+ import { Switch } from "@/components/ui/switch";
8
9
  import { ScrollArea } from "@/components/ui/scroll-area";
9
10
  import { Separator } from "@/components/ui/separator";
10
11
  import { useShallow } from "zustand/react/shallow";
@@ -29,12 +30,13 @@ const pushSupported = "PushManager" in window && "serviceWorker" in navigator;
29
30
  const isIosNonPwa = /iPhone|iPad/.test(navigator.userAgent) &&
30
31
  !window.matchMedia("(display-mode: standalone)").matches;
31
32
 
32
- type SettingsCategory = "ai" | "notifications" | "clawbot" | "proxy" | "shortcuts" | "mcp" | "extensions";
33
+ type SettingsCategory = "ai" | "notifications" | "clawbot" | "jira" | "proxy" | "shortcuts" | "mcp" | "extensions";
33
34
 
34
35
  const CATEGORIES: { value: SettingsCategory; label: string; subtitle: string; icon: React.ElementType }[] = [
35
36
  { value: "ai", label: "AI Provider", subtitle: "Model, execution mode, limits", icon: Bot },
36
37
  { value: "notifications", label: "Notifications", subtitle: "Push & Telegram alerts", icon: BellRing },
37
38
  { value: "clawbot", label: "PPMBot", subtitle: "Telegram AI bot", icon: Bot },
39
+ // Jira is now a toggle, not a full settings category
38
40
  { value: "proxy", label: "API Proxy", subtitle: "Expose accounts as Anthropic API", icon: Globe },
39
41
  { value: "shortcuts", label: "Keyboard Shortcuts", subtitle: "Customize key bindings", icon: Keyboard },
40
42
  { value: "mcp", label: "MCP Servers", subtitle: "Model Context Protocol tools", icon: Plug },
@@ -42,7 +44,7 @@ const CATEGORIES: { value: SettingsCategory; label: string; subtitle: string; ic
42
44
  ];
43
45
 
44
46
  export function SettingsTab() {
45
- const { theme, setTheme, deviceName, setDeviceName, version } = useSettingsStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, deviceName: s.deviceName, setDeviceName: s.setDeviceName, version: s.version })));
47
+ const { theme, setTheme, deviceName, setDeviceName, version, jiraEnabled, setJiraEnabled } = useSettingsStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, deviceName: s.deviceName, setDeviceName: s.setDeviceName, version: s.version, jiraEnabled: s.jiraEnabled, setJiraEnabled: s.setJiraEnabled })));
46
48
  const { permission, isSubscribed, loading, error: pushError, subscribe, unsubscribe } = usePushNotification();
47
49
  const [activeCategory, setActiveCategory] = useState<SettingsCategory | null>(null);
48
50
  const [nameInput, setNameInput] = useState(deviceName ?? "");
@@ -90,6 +92,7 @@ export function SettingsTab() {
90
92
  {activeCategory === "ai" && <AISettingsSection compact />}
91
93
  {activeCategory === "notifications" && <NotificationsContent isSubscribed={isSubscribed} loading={loading} permission={permission} pushError={pushError} subscribe={subscribe} unsubscribe={unsubscribe} />}
92
94
  {activeCategory === "clawbot" && <PPMBotSettingsSection />}
95
+ {/* Jira is now a sidebar tab with a toggle below */}
93
96
  {activeCategory === "proxy" && <ProxySettingsSection />}
94
97
  {activeCategory === "shortcuts" && <KeyboardShortcutsSection />}
95
98
  {activeCategory === "mcp" && <McpSettingsSection />}
@@ -163,6 +166,20 @@ export function SettingsTab() {
163
166
  </div>
164
167
  </section>
165
168
 
169
+ {/* Jira toggle */}
170
+ <section className="space-y-1">
171
+ <div className="flex items-center justify-between">
172
+ <div className="flex items-center gap-2">
173
+ <Bug className="size-4 text-muted-foreground" />
174
+ <div>
175
+ <p className="text-xs font-medium">Jira Watcher</p>
176
+ <p className="text-[11px] text-muted-foreground">Auto-debug Jira tickets</p>
177
+ </div>
178
+ </div>
179
+ <Switch checked={jiraEnabled} onCheckedChange={setJiraEnabled} />
180
+ </div>
181
+ </section>
182
+
166
183
  <Separator />
167
184
 
168
185
  {/* Category navigation list */}
@@ -41,8 +41,10 @@ export function MdPre({ children, node, ...rest }: any) {
41
41
  const isBash = /^(bash|sh|shell|zsh)$/.test(lang || "") || (!lang && text.startsWith("$"));
42
42
 
43
43
  return (
44
- <pre {...rest} className={`relative group ${rest.className || ""}`}>
45
- {children}
44
+ <div className="relative group">
45
+ <pre {...rest}>
46
+ {children}
47
+ </pre>
46
48
  {codeActions && (
47
49
  <div className="code-actions absolute top-1 right-1 flex gap-1">
48
50
  <ActionBtn title="Copy" icon={<CopyIcon />} activeIcon={<CheckIcon />} onClick={() => navigator.clipboard.writeText(text)} />
@@ -58,7 +60,7 @@ export function MdPre({ children, node, ...rest }: any) {
58
60
  )}
59
61
  </div>
60
62
  )}
61
- </pre>
63
+ </div>
62
64
  );
63
65
  }
64
66