code-coordinator 0.5.46__py3-none-any.whl
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.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- coord/worker_events.py +954 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Home — the Pipeline panel.
|
|
3
|
+
*
|
|
4
|
+
* It is the *list panel's* content, not a screen (#1547): the app shell owns
|
|
5
|
+
* the frame (rail, status bar, detail column) and renders this same component
|
|
6
|
+
* into the list slot at every viewport — a 360px column on a 32" monitor, the
|
|
7
|
+
* whole screen on a phone. Nothing below is breakpoint-aware; the only
|
|
8
|
+
* concession to width is Tailwind responsive classes on presentation.
|
|
9
|
+
*
|
|
10
|
+
* A "Live sessions" section (#1067) surfaces live coord-* interactive
|
|
11
|
+
* sessions (#1066's `GET /api/sessions`) at the TOP, above everything else —
|
|
12
|
+
* these are in-progress human-attended sessions that can be taken over via
|
|
13
|
+
* the `/terminal/:sessionId` view, so they get first billing over the
|
|
14
|
+
* pipeline list below. (The rail's Sessions view lists the same cards; this
|
|
15
|
+
* section stays because "attention before detail" says the pipeline should
|
|
16
|
+
* still open with what's live.)
|
|
17
|
+
*
|
|
18
|
+
* Below that: in-flight pipeline items as tappable cards, kept live by the
|
|
19
|
+
* SSE `/events` stream (#1549) rather than a poll timer -- see
|
|
20
|
+
* `src/realtime/`. Two filter tabs:
|
|
21
|
+
* Active — everything currently in the pipeline (not yet merged)
|
|
22
|
+
* Needs me — items with at least one available gate action (need human input)
|
|
23
|
+
*
|
|
24
|
+
* Pull-to-refresh: drag down from the top (when already scrolled to top) to
|
|
25
|
+
* trigger an immediate refetch.
|
|
26
|
+
*/
|
|
27
|
+
import { useCallback, useRef, useState } from 'react'
|
|
28
|
+
import { useMatch, useNavigate } from 'react-router-dom'
|
|
29
|
+
import { useQuery } from '@tanstack/react-query'
|
|
30
|
+
import { fetchPipeline, fetchSessions, type PipelineView, type SessionInfo } from '@/api/client'
|
|
31
|
+
import { PipelineCard } from '@/components/PipelineCard'
|
|
32
|
+
import { SessionCard } from '@/components/SessionCard'
|
|
33
|
+
import { PanelHeader } from '@/components/PanelHeader'
|
|
34
|
+
import { isActive, needsMe } from '@/lib/pipeline'
|
|
35
|
+
import { paths } from '@/routes/paths'
|
|
36
|
+
|
|
37
|
+
// ── Filter logic ──────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
type FilterTab = 'active' | 'needs-me'
|
|
40
|
+
|
|
41
|
+
const FILTER_FNS: Record<FilterTab, (v: PipelineView) => boolean> = {
|
|
42
|
+
'active': isActive,
|
|
43
|
+
'needs-me': needsMe,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `current_stage` values that PipelineCard's `stageStatusInfo` renders as a
|
|
48
|
+
* "done-ish" yellow badge — the work itself has finished, but the item is
|
|
49
|
+
* waiting on a downstream gate (review/smoke/merge) rather than actively
|
|
50
|
+
* running. These are the low-signal cards #1218 asked to group + collapse
|
|
51
|
+
* out of the primary "Active" view by default (e.g. a merged item's
|
|
52
|
+
* penultimate "work done" state sitting near the top of the list).
|
|
53
|
+
*/
|
|
54
|
+
const DONE_ISH_STAGES = new Set(['done', 'review_done', 'smoke_passed', 'merge_ready'])
|
|
55
|
+
|
|
56
|
+
function isDoneIsh(view: PipelineView): boolean {
|
|
57
|
+
return DONE_ISH_STAGES.has(view.current_stage)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `current_stage` values that indicate a subprocess is actively running —
|
|
62
|
+
* mirrors PipelineCard's RUNNING_STAGES so the "in progress" bucket can
|
|
63
|
+
* prioritize live work after needs-me items.
|
|
64
|
+
*/
|
|
65
|
+
const RUNNING_STAGES = new Set(['coding', 'review_running', 'smoke_running', 'merging'])
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Split the "Active" tab's items into "in progress" (shown expanded) and
|
|
69
|
+
* "done-ish" (collapsed by default, see DONE_ISH_STAGES). Within "in
|
|
70
|
+
* progress", items needing human input (available_gates.length > 0) sort
|
|
71
|
+
* first, then actively-running items, with the incoming order preserved as
|
|
72
|
+
* the tiebreak (stable sort). "Done-ish" items are returned unsorted here —
|
|
73
|
+
* the caller sorts them by finished_at descending.
|
|
74
|
+
*/
|
|
75
|
+
function groupActiveItems(views: PipelineView[]): { inProgress: PipelineView[]; done: PipelineView[] } {
|
|
76
|
+
const inProgress: PipelineView[] = []
|
|
77
|
+
const done: PipelineView[] = []
|
|
78
|
+
for (const view of views) {
|
|
79
|
+
if (isDoneIsh(view)) {
|
|
80
|
+
done.push(view)
|
|
81
|
+
} else {
|
|
82
|
+
inProgress.push(view)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const priority = (view: PipelineView): number => {
|
|
87
|
+
if (needsMe(view)) return 0
|
|
88
|
+
if (RUNNING_STAGES.has(view.current_stage)) return 1
|
|
89
|
+
return 2
|
|
90
|
+
}
|
|
91
|
+
inProgress.sort((a, b) => priority(a) - priority(b))
|
|
92
|
+
|
|
93
|
+
// Most recently finished first; items with no finished_at yet sort last.
|
|
94
|
+
done.sort((a, b) => (b.finished_at ?? -Infinity) - (a.finished_at ?? -Infinity))
|
|
95
|
+
|
|
96
|
+
return { inProgress, done }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Pull-to-refresh ───────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
const PTR_THRESHOLD_PX = 80
|
|
102
|
+
|
|
103
|
+
interface UsePullToRefreshOptions {
|
|
104
|
+
onRefresh: () => void
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* "Already at the top?" — walked up from the touch target rather than read off
|
|
109
|
+
* `window`.
|
|
110
|
+
*
|
|
111
|
+
* This used to be `window.scrollY === 0`, which was true because the panel was
|
|
112
|
+
* the document. Under the shell (#1547) the list scrolls inside an
|
|
113
|
+
* `overflow-y-auto` column, so `window.scrollY` is *permanently* 0 and that
|
|
114
|
+
* test would have fired a refetch on every downward scroll gesture on the
|
|
115
|
+
* phone — the exact regression the story's "phone flows work unchanged"
|
|
116
|
+
* criterion is about.
|
|
117
|
+
*/
|
|
118
|
+
function isScrolledToTop(target: EventTarget | null): boolean {
|
|
119
|
+
let el: HTMLElement | null = target instanceof HTMLElement ? target : null
|
|
120
|
+
while (el && el !== document.body) {
|
|
121
|
+
if (el.scrollTop > 0) return false
|
|
122
|
+
el = el.parentElement
|
|
123
|
+
}
|
|
124
|
+
return (window.scrollY || document.documentElement.scrollTop || 0) === 0
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function usePullToRefresh({ onRefresh }: UsePullToRefreshOptions) {
|
|
128
|
+
const startYRef = useRef(0)
|
|
129
|
+
const startedAtTopRef = useRef(false)
|
|
130
|
+
|
|
131
|
+
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
|
132
|
+
startYRef.current = e.touches[0].clientY
|
|
133
|
+
// Sampled at touch *start*: by the time the gesture ends the container has
|
|
134
|
+
// already been scrolled by it, so asking then would answer the wrong
|
|
135
|
+
// question.
|
|
136
|
+
startedAtTopRef.current = isScrolledToTop(e.target)
|
|
137
|
+
}, [])
|
|
138
|
+
|
|
139
|
+
const onTouchEnd = useCallback(
|
|
140
|
+
(e: React.TouchEvent) => {
|
|
141
|
+
const dy = e.changedTouches[0].clientY - startYRef.current
|
|
142
|
+
if (dy > PTR_THRESHOLD_PX && startedAtTopRef.current) {
|
|
143
|
+
onRefresh()
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
[onRefresh],
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return { onTouchStart, onTouchEnd }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Filter tab component ──────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
interface FilterTabsProps {
|
|
155
|
+
active: FilterTab
|
|
156
|
+
counts: Record<FilterTab, number>
|
|
157
|
+
onChange: (tab: FilterTab) => void
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function FilterTabs({ active: activeTab, counts, onChange }: FilterTabsProps) {
|
|
161
|
+
const tabs: Array<{ id: FilterTab; label: string }> = [
|
|
162
|
+
{ id: 'active', label: 'Active' },
|
|
163
|
+
{ id: 'needs-me', label: 'Needs me' },
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<div className="flex gap-2" role="tablist" aria-label="Pipeline filters">
|
|
168
|
+
{tabs.map(({ id, label }) => {
|
|
169
|
+
const isSelected = id === activeTab
|
|
170
|
+
const count = counts[id]
|
|
171
|
+
return (
|
|
172
|
+
<button
|
|
173
|
+
key={id}
|
|
174
|
+
type="button"
|
|
175
|
+
role="tab"
|
|
176
|
+
aria-selected={isSelected}
|
|
177
|
+
onClick={() => onChange(id)}
|
|
178
|
+
className={
|
|
179
|
+
isSelected
|
|
180
|
+
? 'flex items-center gap-1.5 rounded-full bg-primary px-3 py-1 text-xs font-semibold text-primary-foreground'
|
|
181
|
+
: 'flex items-center gap-1.5 rounded-full border border-border px-3 py-1 text-xs font-medium text-muted-foreground'
|
|
182
|
+
}
|
|
183
|
+
>
|
|
184
|
+
{label}
|
|
185
|
+
{count > 0 && (
|
|
186
|
+
<span
|
|
187
|
+
className={
|
|
188
|
+
isSelected
|
|
189
|
+
? 'rounded-full bg-primary-foreground/20 px-1.5 text-xs font-mono'
|
|
190
|
+
: 'rounded-full bg-secondary px-1.5 text-xs font-mono'
|
|
191
|
+
}
|
|
192
|
+
>
|
|
193
|
+
{count}
|
|
194
|
+
</span>
|
|
195
|
+
)}
|
|
196
|
+
</button>
|
|
197
|
+
)
|
|
198
|
+
})}
|
|
199
|
+
</div>
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Live sessions section ───────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
interface LiveSessionsProps {
|
|
206
|
+
sessions: SessionInfo[]
|
|
207
|
+
onSelect: (sessionId: string) => void
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function LiveSessions({ sessions, onSelect }: LiveSessionsProps) {
|
|
211
|
+
if (sessions.length === 0) return null
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<section aria-label="Live sessions" className="mb-5 space-y-3">
|
|
215
|
+
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
216
|
+
Live sessions
|
|
217
|
+
</h2>
|
|
218
|
+
{sessions.map((session) => (
|
|
219
|
+
<SessionCard
|
|
220
|
+
key={session.session_id}
|
|
221
|
+
session={session}
|
|
222
|
+
onClick={() => onSelect(session.session_id)}
|
|
223
|
+
/>
|
|
224
|
+
))}
|
|
225
|
+
</section>
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── Done section (collapsed by default) ─────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
interface DoneSectionProps {
|
|
232
|
+
items: PipelineView[]
|
|
233
|
+
onSelect: (view: PipelineView) => void
|
|
234
|
+
isSelected: (view: PipelineView) => boolean
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Collapsed-by-default "Work done" section for the Active tab's done-ish
|
|
239
|
+
* items (#1218) — keeps merged/finished noise out of the primary view while
|
|
240
|
+
* staying one tap away. Sorted by finished_at descending by the caller
|
|
241
|
+
* (groupActiveItems), so expanding always shows most-recently-finished first.
|
|
242
|
+
*/
|
|
243
|
+
function DoneSection({ items, onSelect, isSelected }: DoneSectionProps) {
|
|
244
|
+
const [expanded, setExpanded] = useState(false)
|
|
245
|
+
|
|
246
|
+
if (items.length === 0) return null
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<section aria-label="Work done" className="mt-3">
|
|
250
|
+
<button
|
|
251
|
+
type="button"
|
|
252
|
+
onClick={() => setExpanded((v) => !v)}
|
|
253
|
+
aria-expanded={expanded}
|
|
254
|
+
className="flex w-full items-center justify-between rounded-lg border border-border bg-card px-4 py-2 text-left text-xs font-medium text-muted-foreground"
|
|
255
|
+
>
|
|
256
|
+
<span>Work done ({items.length})</span>
|
|
257
|
+
<span aria-hidden="true">{expanded ? '▾' : '▸'}</span>
|
|
258
|
+
</button>
|
|
259
|
+
{expanded && (
|
|
260
|
+
<div className="mt-3 space-y-3">
|
|
261
|
+
{items.map((view) => (
|
|
262
|
+
<PipelineCard
|
|
263
|
+
key={view.assignment_id}
|
|
264
|
+
view={view}
|
|
265
|
+
selected={isSelected(view)}
|
|
266
|
+
onClick={() => onSelect(view)}
|
|
267
|
+
/>
|
|
268
|
+
))}
|
|
269
|
+
</div>
|
|
270
|
+
)}
|
|
271
|
+
</section>
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── Home screen ───────────────────────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
export default function Home() {
|
|
278
|
+
const navigate = useNavigate()
|
|
279
|
+
const [filterTab, setFilterTab] = useState<FilterTab>('active')
|
|
280
|
+
|
|
281
|
+
// Which row is open in the detail panel. Read from the route rather than
|
|
282
|
+
// passed in, because on narrow this component and the detail are never on
|
|
283
|
+
// screen together and there is nobody to pass it — the URL is the one thing
|
|
284
|
+
// both compositions agree on. Renders as a marker only; on narrow the
|
|
285
|
+
// detail has replaced the list anyway, so nothing is marked.
|
|
286
|
+
//
|
|
287
|
+
// Keyed on repo + issue (#1548), not `assignment_id`: the URL is
|
|
288
|
+
// `/pipeline/:repo/:issue[/:tab]`, so both the two- and three-segment forms
|
|
289
|
+
// must mark the same row.
|
|
290
|
+
const itemMatch = useMatch('/pipeline/:repo/:issue')
|
|
291
|
+
const itemTabMatch = useMatch('/pipeline/:repo/:issue/:tab')
|
|
292
|
+
const detailMatch = itemMatch ?? itemTabMatch
|
|
293
|
+
const selectedRepo = detailMatch?.params.repo
|
|
294
|
+
const selectedIssue = detailMatch?.params.issue
|
|
295
|
+
const isSelected = (view: PipelineView): boolean =>
|
|
296
|
+
selectedRepo === view.repo_name && selectedIssue === String(view.issue_number)
|
|
297
|
+
|
|
298
|
+
// #1549: no refetchInterval -- kept fresh by SSE-driven invalidation
|
|
299
|
+
// (RealtimeProvider invalidates ['pipeline'] on assignment_*/board_updated
|
|
300
|
+
// events). isFetching still pulses briefly on every SSE-triggered refetch.
|
|
301
|
+
const { data, isLoading, isError, isFetching, dataUpdatedAt, refetch } = useQuery({
|
|
302
|
+
queryKey: ['pipeline'],
|
|
303
|
+
queryFn: fetchPipeline,
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
// #1549: /api/sessions fans out one blocking ssh per machine (see
|
|
307
|
+
// fetchSessions doc comment) -- a 4s poll against a ~5s server sweep is
|
|
308
|
+
// exactly the hazard the offline-cooldown cache exists to paper over. SSE
|
|
309
|
+
// invalidation (machine_connected/disconnected, assignment_*) now drives
|
|
310
|
+
// this instead, so the request only fires when something plausibly changed.
|
|
311
|
+
const { data: sessions } = useQuery({
|
|
312
|
+
queryKey: ['sessions'],
|
|
313
|
+
queryFn: fetchSessions,
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
const handleRefresh = useCallback(() => {
|
|
317
|
+
void refetch()
|
|
318
|
+
}, [refetch])
|
|
319
|
+
|
|
320
|
+
const { onTouchStart, onTouchEnd } = usePullToRefresh({ onRefresh: handleRefresh })
|
|
321
|
+
|
|
322
|
+
const filtered = data ? data.filter(FILTER_FNS[filterTab]) : []
|
|
323
|
+
|
|
324
|
+
// Active tab only: group into "in progress" (expanded, needs-me-first) and
|
|
325
|
+
// "done-ish" (collapsed "Work done" section, sorted by recency) — #1218.
|
|
326
|
+
// The "Needs me" tab stays a flat list; its semantics are unchanged.
|
|
327
|
+
const { inProgress, done } =
|
|
328
|
+
filterTab === 'active' ? groupActiveItems(filtered) : { inProgress: filtered, done: [] }
|
|
329
|
+
|
|
330
|
+
const counts: Record<FilterTab, number> = {
|
|
331
|
+
'active': data ? data.filter(isActive).length : 0,
|
|
332
|
+
'needs-me': data ? data.filter(needsMe).length : 0,
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const updatedLabel = dataUpdatedAt
|
|
336
|
+
? new Date(dataUpdatedAt).toLocaleTimeString()
|
|
337
|
+
: null
|
|
338
|
+
|
|
339
|
+
return (
|
|
340
|
+
<div
|
|
341
|
+
className="mx-auto w-full max-w-lg px-4 py-4"
|
|
342
|
+
onTouchStart={onTouchStart}
|
|
343
|
+
onTouchEnd={onTouchEnd}
|
|
344
|
+
>
|
|
345
|
+
{/* Header. Connection state moved to the shell's status bar (#1547) —
|
|
346
|
+
it's app-wide truth, and with wide showing list + detail at once,
|
|
347
|
+
two live-region badges saying the same thing is noise. */}
|
|
348
|
+
<PanelHeader title="Pipeline" count={data ? counts.active : undefined}>
|
|
349
|
+
{isFetching && !isLoading && (
|
|
350
|
+
<span className="h-2 w-2 animate-pulse rounded-full bg-primary" aria-label="Refreshing" />
|
|
351
|
+
)}
|
|
352
|
+
{updatedLabel && <span className="font-mono text-[.7rem] text-faint">{updatedLabel}</span>}
|
|
353
|
+
</PanelHeader>
|
|
354
|
+
|
|
355
|
+
{/* Live sessions — surfaced above everything else (#1067) */}
|
|
356
|
+
<LiveSessions
|
|
357
|
+
sessions={sessions ?? []}
|
|
358
|
+
onSelect={(sessionId) => navigate(paths.terminal(sessionId))}
|
|
359
|
+
/>
|
|
360
|
+
|
|
361
|
+
{/* Filter tabs */}
|
|
362
|
+
{data && (
|
|
363
|
+
<div className="mb-4">
|
|
364
|
+
<FilterTabs active={filterTab} counts={counts} onChange={setFilterTab} />
|
|
365
|
+
</div>
|
|
366
|
+
)}
|
|
367
|
+
|
|
368
|
+
{/* Loading state */}
|
|
369
|
+
{isLoading && (
|
|
370
|
+
<p className="py-12 text-center text-sm text-muted-foreground">
|
|
371
|
+
Loading pipeline…
|
|
372
|
+
</p>
|
|
373
|
+
)}
|
|
374
|
+
|
|
375
|
+
{/* Error state */}
|
|
376
|
+
{isError && (
|
|
377
|
+
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-6 text-center">
|
|
378
|
+
<p className="text-sm text-destructive">Failed to load pipeline</p>
|
|
379
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
380
|
+
Is the dashboard server running?
|
|
381
|
+
</p>
|
|
382
|
+
<button
|
|
383
|
+
type="button"
|
|
384
|
+
onClick={handleRefresh}
|
|
385
|
+
className="mt-3 rounded-full border border-border px-3 py-1 text-xs text-muted-foreground"
|
|
386
|
+
>
|
|
387
|
+
Retry
|
|
388
|
+
</button>
|
|
389
|
+
</div>
|
|
390
|
+
)}
|
|
391
|
+
|
|
392
|
+
{/* Card list */}
|
|
393
|
+
{data && filtered.length > 0 && (
|
|
394
|
+
<>
|
|
395
|
+
<section className="space-y-3" aria-label={filterTab === 'active' ? 'Active items' : 'Items needing attention'}>
|
|
396
|
+
{inProgress.map((view) => (
|
|
397
|
+
<PipelineCard
|
|
398
|
+
key={view.assignment_id}
|
|
399
|
+
view={view}
|
|
400
|
+
selected={isSelected(view)}
|
|
401
|
+
onClick={() => navigate(paths.pipelineItem(view.repo_name, view.issue_number))}
|
|
402
|
+
/>
|
|
403
|
+
))}
|
|
404
|
+
</section>
|
|
405
|
+
<DoneSection
|
|
406
|
+
items={done}
|
|
407
|
+
isSelected={isSelected}
|
|
408
|
+
onSelect={(view) => navigate(paths.pipelineItem(view.repo_name, view.issue_number))}
|
|
409
|
+
/>
|
|
410
|
+
</>
|
|
411
|
+
)}
|
|
412
|
+
|
|
413
|
+
{/* Empty state */}
|
|
414
|
+
{data && filtered.length === 0 && (
|
|
415
|
+
<div className="py-12 text-center">
|
|
416
|
+
{filterTab === 'needs-me' ? (
|
|
417
|
+
<>
|
|
418
|
+
<p className="text-sm font-medium text-foreground">All clear</p>
|
|
419
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
420
|
+
No items are waiting for your input right now.
|
|
421
|
+
</p>
|
|
422
|
+
</>
|
|
423
|
+
) : (
|
|
424
|
+
<>
|
|
425
|
+
<p className="text-sm font-medium text-foreground">No active pipeline items</p>
|
|
426
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
427
|
+
Dispatch a worker to get started.
|
|
428
|
+
</p>
|
|
429
|
+
</>
|
|
430
|
+
)}
|
|
431
|
+
</div>
|
|
432
|
+
)}
|
|
433
|
+
</div>
|
|
434
|
+
)
|
|
435
|
+
}
|