bosun 0.28.2 → 0.28.4

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.
@@ -1028,6 +1028,9 @@ function ServerConfigMode() {
1028
1028
  ${activeCat?.description &&
1029
1029
  html`<div class="settings-cat-desc">${activeCat.description}</div>`}
1030
1030
 
1031
+ <!-- GitHub Device Flow login card -->
1032
+ ${activeCategory === "github" && html`<${GitHubDeviceFlowCard} config=${serverData} />`}
1033
+
1031
1034
  <!-- Settings list for active category -->
1032
1035
  ${catDefs.length === 0
1033
1036
  ? html`
@@ -1567,6 +1570,210 @@ function AppPreferencesMode() {
1567
1570
  `;
1568
1571
  }
1569
1572
 
1573
+ /* ═══════════════════════════════════════════════════════════════
1574
+ * GitHubDeviceFlowCard — "Sign in with GitHub" (like VS Code / Roo Code)
1575
+ * Uses OAuth Device Flow: no public URL, no callback needed.
1576
+ * ═══════════════════════════════════════════════════════════════ */
1577
+ function GitHubDeviceFlowCard({ config }) {
1578
+ const [phase, setPhase] = useState("idle"); // idle | loading | code | polling | done | error
1579
+ const [userCode, setUserCode] = useState("");
1580
+ const [verificationUri, setVerificationUri] = useState("");
1581
+ const [deviceCode, setDeviceCode] = useState("");
1582
+ const [pollInterval, setPollInterval] = useState(5);
1583
+ const [ghUser, setGhUser] = useState("");
1584
+ const [error, setError] = useState("");
1585
+ const pollRef = useRef(null);
1586
+
1587
+ // Check if already authenticated
1588
+ const hasToken = Boolean(
1589
+ config?.GH_TOKEN || config?.GITHUB_TOKEN
1590
+ );
1591
+
1592
+ // Cleanup polling on unmount
1593
+ useEffect(() => {
1594
+ return () => { if (pollRef.current) clearTimeout(pollRef.current); };
1595
+ }, []);
1596
+
1597
+ async function startFlow() {
1598
+ setPhase("loading");
1599
+ setError("");
1600
+ try {
1601
+ const res = await apiFetch("/api/github/device/start", { method: "POST" });
1602
+ if (!res.ok) throw new Error(res.error || "Failed to start device flow");
1603
+ const d = res.data;
1604
+ setUserCode(d.userCode);
1605
+ setVerificationUri(d.verificationUri);
1606
+ setDeviceCode(d.deviceCode);
1607
+ setPollInterval(d.interval || 5);
1608
+ setPhase("code");
1609
+
1610
+ // Open GitHub in a new tab automatically
1611
+ try {
1612
+ window.open(d.verificationUri, "_blank");
1613
+ } catch {
1614
+ // may be blocked by popup blocker
1615
+ }
1616
+
1617
+ // Start polling
1618
+ startPolling(d.deviceCode, (d.interval || 5) * 1000);
1619
+ } catch (err) {
1620
+ setError(err.message);
1621
+ setPhase("error");
1622
+ }
1623
+ }
1624
+
1625
+ function startPolling(dc, intervalMs) {
1626
+ if (pollRef.current) clearTimeout(pollRef.current);
1627
+ setPhase("polling");
1628
+
1629
+ async function tick() {
1630
+ try {
1631
+ const res = await apiFetch("/api/github/device/poll", {
1632
+ method: "POST",
1633
+ body: { deviceCode: dc },
1634
+ });
1635
+ if (!res.ok) {
1636
+ pollRef.current = setTimeout(tick, intervalMs);
1637
+ return;
1638
+ }
1639
+ const d = res.data;
1640
+ if (d.status === "complete") {
1641
+ pollRef.current = null;
1642
+ setGhUser(d.login);
1643
+ setPhase("done");
1644
+ haptic("success");
1645
+ showToast("success", `Signed in as ${d.login}`);
1646
+ return;
1647
+ } else if (d.status === "slow_down") {
1648
+ // Increase interval as requested by GitHub
1649
+ const newInterval = (d.interval || 10) * 1000;
1650
+ setPollInterval(d.interval || 10);
1651
+ intervalMs = newInterval;
1652
+ } else if (d.status === "expired") {
1653
+ pollRef.current = null;
1654
+ setError("Code expired. Please try again.");
1655
+ setPhase("error");
1656
+ return;
1657
+ } else if (d.status === "error") {
1658
+ pollRef.current = null;
1659
+ setError(d.description || d.error || "Authorization failed");
1660
+ setPhase("error");
1661
+ return;
1662
+ }
1663
+ // "pending" or "slow_down" → schedule next tick
1664
+ pollRef.current = setTimeout(tick, intervalMs);
1665
+ } catch {
1666
+ // network error — keep polling, it may recover
1667
+ pollRef.current = setTimeout(tick, intervalMs);
1668
+ }
1669
+ }
1670
+
1671
+ pollRef.current = setTimeout(tick, intervalMs);
1672
+ }
1673
+
1674
+ function copyCode() {
1675
+ try {
1676
+ navigator.clipboard.writeText(userCode);
1677
+ haptic("light");
1678
+ showToast("info", "Code copied!");
1679
+ } catch {
1680
+ // clipboard not available
1681
+ }
1682
+ }
1683
+
1684
+ // Already authenticated — compact info
1685
+ if (hasToken && phase !== "done") {
1686
+ return html`
1687
+ <${Card}>
1688
+ <div style="display:flex;align-items:center;gap:10px;padding:4px 0">
1689
+ <span style="font-size:20px">🐙</span>
1690
+ <div style="flex:1;min-width:0">
1691
+ <div style="font-size:13px;font-weight:600;color:var(--text-primary)">GitHub Connected</div>
1692
+ <div style="font-size:12px;color:var(--text-secondary)">Token is configured. Re-authenticate below if needed.</div>
1693
+ </div>
1694
+ <button class="btn btn-sm btn-secondary" onClick=${startFlow}>
1695
+ Re-auth
1696
+ </button>
1697
+ </div>
1698
+ <//>
1699
+ `;
1700
+ }
1701
+
1702
+ // Done — just authorized
1703
+ if (phase === "done") {
1704
+ return html`
1705
+ <${Card}>
1706
+ <div style="text-align:center;padding:12px 0">
1707
+ <div style="font-size:32px;margin-bottom:8px">✅</div>
1708
+ <div style="font-size:15px;font-weight:600;color:var(--text-primary)">Signed in as ${ghUser}</div>
1709
+ <div style="font-size:12px;color:var(--text-secondary);margin-top:4px">GitHub token saved to .env</div>
1710
+ </div>
1711
+ <//>
1712
+ `;
1713
+ }
1714
+
1715
+ // Show device code to enter
1716
+ if (phase === "code" || phase === "polling") {
1717
+ return html`
1718
+ <${Card}>
1719
+ <div style="text-align:center;padding:8px 0">
1720
+ <div style="font-size:13px;color:var(--text-secondary);margin-bottom:12px">
1721
+ Go to <a href=${verificationUri} target="_blank" rel="noopener"
1722
+ style="color:var(--accent);font-weight:600;text-decoration:underline">${verificationUri}</a>
1723
+ and enter this code:
1724
+ </div>
1725
+ <button onClick=${copyCode}
1726
+ style="font-size:28px;font-weight:700;letter-spacing:0.15em;font-family:var(--font-mono,'SF Mono',monospace);
1727
+ padding:12px 24px;border-radius:var(--radius-md);background:var(--surface-1);
1728
+ border:2px dashed var(--accent);color:var(--text-primary);cursor:pointer;
1729
+ transition:background 0.15s ease"
1730
+ title="Click to copy">
1731
+ ${userCode}
1732
+ </button>
1733
+ <div style="font-size:12px;color:var(--text-hint);margin-top:10px;display:flex;align-items:center;justify-content:center;gap:6px">
1734
+ <span class="spinner" style="width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%"></span>
1735
+ Waiting for authorization…
1736
+ </div>
1737
+ </div>
1738
+ <//>
1739
+ `;
1740
+ }
1741
+
1742
+ // Error state
1743
+ if (phase === "error") {
1744
+ return html`
1745
+ <${Card}>
1746
+ <div style="text-align:center;padding:12px 0">
1747
+ <div style="font-size:24px;margin-bottom:8px">⚠️</div>
1748
+ <div style="font-size:13px;color:var(--color-error);margin-bottom:12px">${error}</div>
1749
+ <button class="btn btn-sm btn-primary" onClick=${startFlow}>Try Again</button>
1750
+ </div>
1751
+ <//>
1752
+ `;
1753
+ }
1754
+
1755
+ // Idle — show sign-in button
1756
+ return html`
1757
+ <${Card}>
1758
+ <div style="text-align:center;padding:16px 0">
1759
+ <div style="font-size:32px;margin-bottom:8px">🐙</div>
1760
+ <div style="font-size:15px;font-weight:600;margin-bottom:4px;color:var(--text-primary)">
1761
+ Sign in with GitHub
1762
+ </div>
1763
+ <div style="font-size:12px;color:var(--text-secondary);margin-bottom:16px;max-width:280px;margin-inline:auto;line-height:1.5">
1764
+ Authorize Bosun to manage repos and issues on your behalf.
1765
+ No public URL needed — works entirely from your local machine.
1766
+ </div>
1767
+ <button class="btn btn-primary" onClick=${startFlow}
1768
+ disabled=${phase === "loading"}
1769
+ style="min-width:200px">
1770
+ ${phase === "loading" ? html`<${Spinner} size=${14} /> Connecting…` : "Sign in with GitHub"}
1771
+ </button>
1772
+ </div>
1773
+ <//>
1774
+ `;
1775
+ }
1776
+
1570
1777
  /* ═══════════════════════════════════════════════════════════════
1571
1778
  * SettingsTab — Top-level with two-mode segmented control
1572
1779
  * ═══════════════════════════════════════════════════════════════ */
package/ui/tabs/tasks.js CHANGED
@@ -89,6 +89,8 @@ const SORT_OPTIONS = [
89
89
  { value: "title", label: "Title" },
90
90
  ];
91
91
 
92
+ const PRIORITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3, "": 4 };
93
+
92
94
  const SYSTEM_TAGS = new Set([
93
95
  "draft",
94
96
  "todo",
@@ -760,6 +762,8 @@ export function TasksTab() {
760
762
  const [actionsOpen, setActionsOpen] = useState(false);
761
763
  const [exporting, setExporting] = useState(false);
762
764
  const [filtersOpen, setFiltersOpen] = useState(false);
765
+ const [listSortCol, setListSortCol] = useState(""); // active column sort in list mode
766
+ const [listSortDir, setListSortDir] = useState("desc"); // "asc" | "desc"
763
767
  const [isCompact, setIsCompact] = useState(() => {
764
768
  try { return globalThis.matchMedia?.("(max-width: 768px)")?.matches ?? false; }
765
769
  catch { return false; }
@@ -978,6 +982,31 @@ export function TasksTab() {
978
982
  ];
979
983
  }, [tasks]);
980
984
 
985
+ /* ── Client-side table sort (list mode) ── */
986
+ const sortedForTable = useMemo(() => {
987
+ if (!listSortCol) return visible;
988
+ return [...visible].sort((a, b) => {
989
+ let av, bv;
990
+ const dir = listSortDir === "asc" ? 1 : -1;
991
+ if (listSortCol === "priority") {
992
+ av = PRIORITY_ORDER[a.priority || ""] ?? 4;
993
+ bv = PRIORITY_ORDER[b.priority || ""] ?? 4;
994
+ return dir * (av - bv);
995
+ }
996
+ if (listSortCol === "updated") {
997
+ av = a.updated_at ? new Date(a.updated_at).getTime() : 0;
998
+ bv = b.updated_at ? new Date(b.updated_at).getTime() : 0;
999
+ return dir * (av - bv);
1000
+ }
1001
+ if (listSortCol === "status") { av = a.status || ""; bv = b.status || ""; }
1002
+ else if (listSortCol === "title") { av = (a.title || "").toLowerCase(); bv = (b.title || "").toLowerCase(); }
1003
+ else if (listSortCol === "repo") { av = a.repository || a.workspace || ""; bv = b.repository || b.workspace || ""; }
1004
+ else if (listSortCol === "branch") { av = getTaskBaseBranch(a); bv = getTaskBaseBranch(b); }
1005
+ else { return 0; }
1006
+ return dir * String(av).localeCompare(String(bv));
1007
+ });
1008
+ }, [visible, listSortCol, listSortDir]);
1009
+
981
1010
  /* ── Handlers ── */
982
1011
  const handleFilter = async (s) => {
983
1012
  haptic();
@@ -1578,23 +1607,16 @@ export function TasksTab() {
1578
1607
  `}
1579
1608
  </div>
1580
1609
 
1581
- <${Card}
1582
- title="Work Snapshot"
1583
- subtitle=${isKanban ? "Board view · showing all statuses" : "List view · filtered results"}
1584
- className="work-summary-card"
1585
- >
1586
- <div class="stat-strip">
1587
- ${summaryMetrics.map(
1588
- (metric) => html`
1589
- <${StatCard}
1590
- value=${metric.value}
1591
- label=${metric.label}
1592
- color=${metric.color}
1593
- />
1594
- `,
1595
- )}
1596
- </div>
1597
- <//>
1610
+ <div class="snapshot-bar">
1611
+ ${summaryMetrics.map((m) => html`
1612
+ <span key=${m.label} class="snapshot-pill">
1613
+ <span class="snapshot-dot" style="background:${m.color};" />
1614
+ <strong class="snapshot-val">${m.value}</strong>
1615
+ <span class="snapshot-lbl">${m.label}</span>
1616
+ </span>
1617
+ `)}
1618
+ <span class="snapshot-view-tag">${isKanban ? "⬛ Board" : "☰ List"}</span>
1619
+ </div>
1598
1620
 
1599
1621
  <style>
1600
1622
  .actions-btn { display:inline-flex; align-items:center; gap:4px; }
@@ -1617,119 +1639,84 @@ export function TasksTab() {
1617
1639
 
1618
1640
  ${isKanban && html`<${KanbanBoard} onOpenTask=${openDetail} />`}
1619
1641
 
1620
- ${!isKanban &&
1621
- visible.map((task) => {
1622
- const isManual = isTaskManual(task);
1623
- const descriptionLimit = isCompact ? 90 : 120;
1624
- const tags = getTaskTags(task);
1625
- const visibleTags = isCompact ? tags.slice(0, 3) : tags;
1626
- const extraTagCount = tags.length - visibleTags.length;
1627
- return html`
1628
- <div
1629
- key=${task.id}
1630
- class="task-card ${batchMode && selectedIds.has(task.id)
1631
- ? "task-card-selected"
1632
- : ""} task-card-enter"
1633
- data-status=${task.status || ""}
1634
- data-manual=${isManual ? "true" : "false"}
1635
- onClick=${() =>
1636
- batchMode ? toggleSelect(task.id) : openDetail(task.id)}
1637
- >
1638
- ${batchMode &&
1639
- html`
1640
- <input
1641
- type="checkbox"
1642
- checked=${selectedIds.has(task.id)}
1643
- class="task-checkbox"
1644
- onClick=${(e) => {
1645
- e.stopPropagation();
1646
- toggleSelect(task.id);
1647
- }}
1648
- style="accent-color:var(--accent)"
1649
- />
1650
- `}
1651
- <div class="task-card-header">
1652
- <div>
1653
- <div class="task-card-title">${task.title || "(untitled)"}</div>
1654
- <div class="task-card-meta">
1655
- ${task.id}${task.priority
1656
- ? html` ·
1657
- <${Badge}
1658
- status=${task.priority}
1659
- text=${task.priority}
1660
- />`
1661
- : ""}
1662
- ${task.updated_at
1663
- ? html` · ${formatRelative(task.updated_at)}`
1664
- : ""}
1665
- </div>
1666
- </div>
1667
- <div class="task-card-badges">
1668
- ${isManual && html`<${Badge} status="warning" text="manual" />`}
1669
- <${Badge} status=${task.status} text=${task.status} />
1670
- </div>
1671
- </div>
1672
- <div class="meta-text">
1673
- ${task.description
1674
- ? truncate(task.description, descriptionLimit)
1675
- : "No description."}
1676
- </div>
1677
- ${getTaskBaseBranch(task) &&
1678
- html`
1679
- <div class="meta-text">
1680
- Base: <code>${getTaskBaseBranch(task)}</code>
1681
- </div>
1682
- `}
1683
- ${(task.workspace || task.repository) &&
1684
- html`
1685
- <div class="meta-text" style="display:flex;gap:4px;align-items:center;flex-wrap:wrap">
1686
- ${task.workspace && html`<span class="pill" style="font-size:11px">📂 ${task.workspace}</span>`}
1687
- ${task.repository && html`<span class="pill" style="font-size:11px">📁 ${task.repository}</span>`}
1688
- </div>
1689
- `}
1690
- ${tags.length > 0 &&
1691
- html`
1692
- <div class="tag-row">
1693
- ${visibleTags.map(
1694
- (tag) => html`<span class="tag-chip">#${tag}</span>`,
1695
- )}
1696
- ${extraTagCount > 0 &&
1697
- html`
1698
- <span class="tag-chip tag-chip-muted">
1699
- +${extraTagCount}
1700
- </span>
1701
- `}
1702
- </div>
1703
- `}
1704
- ${!batchMode &&
1705
- html`
1706
- <div class="btn-row mt-sm task-card-actions" onClick=${(e) => e.stopPropagation()}>
1707
- ${task.status === "todo" &&
1708
- html`
1709
- <button
1710
- class="btn btn-primary btn-sm"
1711
- onClick=${() => openStartModal(task)}
1642
+ ${!isKanban && visible.length > 0 && html`
1643
+ <div class="task-table-wrap">
1644
+ <table class="task-table">
1645
+ <thead>
1646
+ <tr>
1647
+ ${[
1648
+ { col: "status", label: "Status" },
1649
+ { col: "priority", label: "Pri" },
1650
+ { col: "title", label: "Title", grow: true },
1651
+ { col: "branch", label: "Branch" },
1652
+ { col: "repo", label: "Repo" },
1653
+ { col: "updated", label: "Updated" },
1654
+ ].map(({ col, label, grow }) => {
1655
+ const active = listSortCol === col;
1656
+ const arrow = active ? (listSortDir === "asc" ? "▲" : "") : "⇅";
1657
+ return html`
1658
+ <th
1659
+ key=${col}
1660
+ class="task-th ${active ? "task-th-active" : ""} ${grow ? "task-th-grow" : ""}"
1661
+ onClick=${() => {
1662
+ if (listSortCol === col) {
1663
+ setListSortDir(listSortDir === "asc" ? "desc" : "asc");
1664
+ } else {
1665
+ setListSortCol(col);
1666
+ setListSortDir("desc");
1667
+ }
1668
+ }}
1669
+ >${label} <span class="task-th-arrow">${arrow}</span></th>
1670
+ `;
1671
+ })}
1672
+ </tr>
1673
+ </thead>
1674
+ <tbody>
1675
+ ${sortedForTable.map((task) => {
1676
+ const isManual = isTaskManual(task);
1677
+ const branch = getTaskBaseBranch(task);
1678
+ return html`
1679
+ <tr
1680
+ key=${task.id}
1681
+ class="task-tr ${batchMode && selectedIds.has(task.id) ? "task-tr-selected" : ""}"
1682
+ data-status=${task.status || ""}
1683
+ onClick=${() => batchMode ? toggleSelect(task.id) : openDetail(task.id)}
1712
1684
  >
1713
- Start
1714
- </button>
1715
- `}
1716
- <button
1717
- class="btn btn-secondary btn-sm"
1718
- onClick=${() => handleStatusUpdate(task.id, "inreview")}
1719
- >
1720
- Review
1721
- </button>
1722
- <button
1723
- class="btn btn-ghost btn-sm"
1724
- onClick=${() => handleStatusUpdate(task.id, "done")}
1725
- >
1726
- Done
1727
- </button>
1728
- </div>
1729
- `}
1730
- </div>
1731
- `;
1732
- })}
1685
+ <td class="task-td task-td-status">
1686
+ <${Badge} status=${task.status} text=${task.status} />
1687
+ ${isManual && html`<${Badge} status="warning" text="⚑" />`}
1688
+ </td>
1689
+ <td class="task-td task-td-pri">
1690
+ ${task.priority
1691
+ ? html`<${Badge} status=${task.priority} text=${task.priority} />`
1692
+ : html`<span class="task-td-empty">—</span>`}
1693
+ </td>
1694
+ <td class="task-td task-td-title">
1695
+ <div class="task-td-title-text">${task.title || "(untitled)"}</div>
1696
+ ${task.id && html`<div class="task-td-id">${task.id}</div>`}
1697
+ </td>
1698
+ <td class="task-td task-td-branch">
1699
+ ${branch
1700
+ ? html`<code class="task-td-code">${branch}</code>`
1701
+ : html`<span class="task-td-empty">—</span>`}
1702
+ </td>
1703
+ <td class="task-td task-td-repo">
1704
+ ${(task.repository || task.workspace)
1705
+ ? html`<span>${task.repository || task.workspace}</span>`
1706
+ : html`<span class="task-td-empty">—</span>`}
1707
+ </td>
1708
+ <td class="task-td task-td-updated">
1709
+ ${task.updated_at
1710
+ ? html`<span class="task-td-date">${formatRelative(task.updated_at)}</span>`
1711
+ : html`<span class="task-td-empty">—</span>`}
1712
+ </td>
1713
+ </tr>
1714
+ `;
1715
+ })}
1716
+ </tbody>
1717
+ </table>
1718
+ </div>
1719
+ `}
1733
1720
  ${!isKanban && !visible.length &&
1734
1721
  html`
1735
1722
  <${EmptyState}