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,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionDetail — the detail slot for one live session, at `/sessions/:id`
|
|
3
|
+
* (#1548).
|
|
4
|
+
*
|
|
5
|
+
* Mirrors `Detail`'s shape (panel content, not a screen; a back control; a
|
|
6
|
+
* not-found state for a stale id) so a session row in `SessionsList` behaves
|
|
7
|
+
* like a pipeline row in `Home` — tap the row, the shell fills the detail
|
|
8
|
+
* column (wide) or drills in (narrow), and the address bar carries exactly
|
|
9
|
+
* what's selected. The one action here is "Take over", which hands off to
|
|
10
|
+
* the full-screen `/terminal/:sessionId` PTY view (#1065) — that route stays
|
|
11
|
+
* deliberately outside the shell (see `App.tsx`), so this is a small
|
|
12
|
+
* summary card plus a door to it, not a second terminal implementation.
|
|
13
|
+
*/
|
|
14
|
+
import { useNavigate, useParams } from 'react-router-dom'
|
|
15
|
+
import { useQuery } from '@tanstack/react-query'
|
|
16
|
+
|
|
17
|
+
import { fetchSessions, type SessionInfo } from '@/api/client'
|
|
18
|
+
import { paths } from '@/routes/paths'
|
|
19
|
+
import { cn } from '@/lib/utils'
|
|
20
|
+
|
|
21
|
+
const detailShellClass = 'mx-auto w-full max-w-3xl px-4 py-5 md:px-6'
|
|
22
|
+
|
|
23
|
+
const ASSIGNMENT_TYPE_LABEL: Record<string, string> = {
|
|
24
|
+
smoke: 'test',
|
|
25
|
+
review: 'review',
|
|
26
|
+
'conflict-fix': 'merge',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function stageLabel(stage: string): string {
|
|
30
|
+
return ASSIGNMENT_TYPE_LABEL[stage] ?? stage
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function statusInfo(session: SessionInfo): { label: string; className: string } {
|
|
34
|
+
if (session.pane_dead) {
|
|
35
|
+
return { label: 'ended', className: 'bg-secondary text-secondary-foreground' }
|
|
36
|
+
}
|
|
37
|
+
if (session.attached) {
|
|
38
|
+
return { label: 'attached', className: 'bg-yellow-600 text-black' }
|
|
39
|
+
}
|
|
40
|
+
return { label: 'live', className: 'bg-primary text-primary-foreground' }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default function SessionDetail() {
|
|
44
|
+
const { id } = useParams<{ id: string }>()
|
|
45
|
+
const navigate = useNavigate()
|
|
46
|
+
|
|
47
|
+
const { data: sessions, isLoading, isError } = useQuery({
|
|
48
|
+
queryKey: ['sessions'],
|
|
49
|
+
queryFn: fetchSessions,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const session = sessions?.find((s) => s.session_id === id) ?? null
|
|
53
|
+
|
|
54
|
+
if (isLoading) {
|
|
55
|
+
return (
|
|
56
|
+
<div className={detailShellClass}>
|
|
57
|
+
<p className="py-12 text-center text-sm text-muted-foreground">Loading…</p>
|
|
58
|
+
</div>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (isError) {
|
|
63
|
+
return (
|
|
64
|
+
<div className={detailShellClass}>
|
|
65
|
+
<p className="py-12 text-center text-sm text-destructive">Failed to load sessions</p>
|
|
66
|
+
</div>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!session) {
|
|
71
|
+
return (
|
|
72
|
+
<div className={detailShellClass}>
|
|
73
|
+
<header className="mb-6 flex items-center gap-3">
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
onClick={() => navigate(-1)}
|
|
77
|
+
className="rounded p-1 text-muted-foreground hover:text-foreground"
|
|
78
|
+
aria-label="Back"
|
|
79
|
+
>
|
|
80
|
+
←
|
|
81
|
+
</button>
|
|
82
|
+
<h1 className="text-step-1 font-semibold text-foreground">Not found</h1>
|
|
83
|
+
</header>
|
|
84
|
+
<p className="text-sm text-muted-foreground">
|
|
85
|
+
Session <span className="font-mono">{id}</span> is no longer live.
|
|
86
|
+
</p>
|
|
87
|
+
</div>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const { label: statusLabel, className: statusClass } = statusInfo(session)
|
|
92
|
+
const title = session.issue_title ?? session.session_name
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div className={detailShellClass}>
|
|
96
|
+
<header className="mb-5">
|
|
97
|
+
<div className="flex items-start gap-2">
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
onClick={() => navigate(-1)}
|
|
101
|
+
aria-label="Back"
|
|
102
|
+
className="-ml-1 shrink-0 rounded p-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
103
|
+
>
|
|
104
|
+
←
|
|
105
|
+
</button>
|
|
106
|
+
<div className="min-w-0 flex-1">
|
|
107
|
+
<p className="text-xs text-muted-foreground">
|
|
108
|
+
{session.repo && <span className="font-mono">{session.repo}</span>}
|
|
109
|
+
{session.issue !== null && <span className="font-mono"> #{session.issue}</span>}
|
|
110
|
+
{(session.repo || session.issue !== null) && session.machine && ' · '}
|
|
111
|
+
{session.machine && <span className="font-mono">{session.machine}</span>}
|
|
112
|
+
</p>
|
|
113
|
+
<h2 className="mt-0.5 text-base font-semibold text-foreground">{title}</h2>
|
|
114
|
+
</div>
|
|
115
|
+
<span className={cn('shrink-0 rounded-full px-2.5 py-0.5 text-xs font-semibold', statusClass)}>
|
|
116
|
+
{statusLabel}
|
|
117
|
+
</span>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
{session.stage && (
|
|
121
|
+
<div className="mt-3 flex flex-wrap gap-1.5">
|
|
122
|
+
<span className="rounded border border-border px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
|
|
123
|
+
{stageLabel(session.stage)}
|
|
124
|
+
</span>
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
</header>
|
|
128
|
+
|
|
129
|
+
{!session.pane_dead && (
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
onClick={() => navigate(paths.terminal(session.session_id))}
|
|
133
|
+
className="w-full rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
134
|
+
>
|
|
135
|
+
Take over
|
|
136
|
+
</button>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sessions — the live interactive sessions as a first-class list panel
|
|
3
|
+
* (#1547).
|
|
4
|
+
*
|
|
5
|
+
* No new content: this is the exact `SessionCard` list Home already surfaces
|
|
6
|
+
* above the pipeline (#1067), given its own rail entry so the shell has a
|
|
7
|
+
* second *real* view. That matters for more than symmetry — with one ready
|
|
8
|
+
* view the narrow bottom row degenerates into a single tab, which can't
|
|
9
|
+
* demonstrate (or regression-test) that the rail works as a phone nav at all.
|
|
10
|
+
*
|
|
11
|
+
* Home keeps its own "Live sessions" section unchanged: "attention before
|
|
12
|
+
* detail" says the pipeline view should still open with what's live.
|
|
13
|
+
*
|
|
14
|
+
* Rows navigate to `/sessions/:id` (#1548) — the same list -> detail
|
|
15
|
+
* convention as `Home` -> `Detail` — rather than jumping straight to the
|
|
16
|
+
* full-screen terminal, so the selection is addressable and the shell fills
|
|
17
|
+
* its detail column on wide instead of leaving the shell entirely. Home's
|
|
18
|
+
* own "Live sessions" quick-glance section keeps its direct-to-terminal
|
|
19
|
+
* shortcut unchanged.
|
|
20
|
+
*/
|
|
21
|
+
import { useNavigate } from 'react-router-dom'
|
|
22
|
+
import { useQuery } from '@tanstack/react-query'
|
|
23
|
+
|
|
24
|
+
import { fetchSessions } from '@/api/client'
|
|
25
|
+
import { SessionCard } from '@/components/SessionCard'
|
|
26
|
+
import { PanelHeader } from '@/components/PanelHeader'
|
|
27
|
+
import { paths } from '@/routes/paths'
|
|
28
|
+
|
|
29
|
+
export default function SessionsList() {
|
|
30
|
+
const navigate = useNavigate()
|
|
31
|
+
const { data, isLoading, isError, refetch } = useQuery({
|
|
32
|
+
queryKey: ['sessions'],
|
|
33
|
+
queryFn: fetchSessions,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const sessions = data ?? []
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="mx-auto w-full max-w-lg px-4 py-4">
|
|
40
|
+
<PanelHeader title="Sessions" count={sessions.length} countLabel="live" />
|
|
41
|
+
|
|
42
|
+
{isLoading && (
|
|
43
|
+
<p className="py-12 text-center text-sm text-muted-foreground">Loading sessions…</p>
|
|
44
|
+
)}
|
|
45
|
+
|
|
46
|
+
{isError && (
|
|
47
|
+
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-6 text-center">
|
|
48
|
+
<p className="text-sm text-destructive">Failed to load sessions</p>
|
|
49
|
+
<button
|
|
50
|
+
type="button"
|
|
51
|
+
onClick={() => void refetch()}
|
|
52
|
+
className="mt-3 rounded-full border border-border px-3 py-1 text-xs text-muted-foreground"
|
|
53
|
+
>
|
|
54
|
+
Retry
|
|
55
|
+
</button>
|
|
56
|
+
</div>
|
|
57
|
+
)}
|
|
58
|
+
|
|
59
|
+
{data && sessions.length > 0 && (
|
|
60
|
+
<section className="space-y-3" aria-label="Live sessions">
|
|
61
|
+
{sessions.map((session) => (
|
|
62
|
+
<SessionCard
|
|
63
|
+
key={session.session_id}
|
|
64
|
+
session={session}
|
|
65
|
+
onClick={() => navigate(paths.session(session.session_id))}
|
|
66
|
+
/>
|
|
67
|
+
))}
|
|
68
|
+
</section>
|
|
69
|
+
)}
|
|
70
|
+
|
|
71
|
+
{data && sessions.length === 0 && (
|
|
72
|
+
<div className="py-12 text-center">
|
|
73
|
+
<p className="text-sm font-medium text-foreground">No live sessions</p>
|
|
74
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
75
|
+
Interactive coord sessions show up here while they're running.
|
|
76
|
+
</p>
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal — the live take-over pane for a `coord-*` interactive session
|
|
3
|
+
* (#1068). Reached via `SessionCard` (#1067) at `/terminal/:sessionId`,
|
|
4
|
+
* replacing the `TerminalComingSoon` stub now that the pane itself exists.
|
|
5
|
+
*
|
|
6
|
+
* Opens a WebSocket to `/ws/terminal/{session_id}` (#1065's PTY<->WebSocket
|
|
7
|
+
* bridge) and renders the byte stream with xterm.js:
|
|
8
|
+
* - browser keystrokes -> binary WS frames (the bridge's `message["bytes"]`
|
|
9
|
+
* path writes them straight to the PTY's stdin)
|
|
10
|
+
* - PTY output (binary WS frames) -> `term.write(Uint8Array)`
|
|
11
|
+
* - terminal size -> a JSON text control frame
|
|
12
|
+
* `{"type": "resize", "cols": .., "rows": ..}` on connect, on xterm
|
|
13
|
+
* resize, and on window resize (FitAddon keeps cols/rows matched to the
|
|
14
|
+
* pane's actual size)
|
|
15
|
+
*
|
|
16
|
+
* Closing the pane (the ✕ button, back navigation, or unmount) closes the
|
|
17
|
+
* WebSocket. That alone is enough to hit the bridge's detach path -- the
|
|
18
|
+
* server's `finally` block calls `attached.detach()` on every disconnect,
|
|
19
|
+
* local tmux session included, and detach never kills the session (#1065).
|
|
20
|
+
*
|
|
21
|
+
* Reconnect / detach resilience (#1071): mobile networks drop and resume
|
|
22
|
+
* WebSockets constantly (backgrounding the tab, wifi<->cellular handoff).
|
|
23
|
+
* An *unexpected* close (any code other than the bridge's session-gone
|
|
24
|
+
* signal) re-opens a fresh WebSocket to the same `session_id` with
|
|
25
|
+
* exponential backoff -- the xterm.js `Terminal` instance itself is never
|
|
26
|
+
* recreated, so `tmux attach`'s redraw on reattach just paints into the
|
|
27
|
+
* existing pane. The bridge closes with code 4404 when `session_id` no
|
|
28
|
+
* longer resolves to an active assignment (`resolve_session_target` in
|
|
29
|
+
* `coord/dashboard/terminal.py`) -- that's a *terminal* state (the worker
|
|
30
|
+
* session itself is gone, not just this connection), so it is surfaced
|
|
31
|
+
* distinctly and never retried.
|
|
32
|
+
*
|
|
33
|
+
* Backoff-reset guard (live-smoke fix): the WS handshake completing
|
|
34
|
+
* (`onopen`) does NOT by itself mean the session is usable -- the bridge
|
|
35
|
+
* `accept()`s before it spawns the tmux attach, so a server-side attach
|
|
36
|
+
* failure (or anything else that closes the socket a moment after accept)
|
|
37
|
+
* fires `onopen` immediately followed by `onclose`. Resetting the backoff
|
|
38
|
+
* counter on every `onopen` -- as an earlier version of this file did --
|
|
39
|
+
* meant that flapping case never actually backed off: each cycle reopened,
|
|
40
|
+
* reset the counter to zero, then closed again, so the client hammered the
|
|
41
|
+
* bridge at the *base* interval indefinitely instead of climbing the
|
|
42
|
+
* exponential ladder (reproduced live against #1071 iteration 1: 44 reconnect
|
|
43
|
+
* attempts in ~5-10 minutes). The counter now only resets when a connection
|
|
44
|
+
* stayed open at least `STABLE_CONNECTION_MS` before dropping -- long enough
|
|
45
|
+
* to have been a genuine, working attach rather than an accept-then-fail
|
|
46
|
+
* flap -- so a truly bad/looping backend still gets the full exponential
|
|
47
|
+
* backoff treatment.
|
|
48
|
+
*
|
|
49
|
+
* Layout: the pane occupies the top half of the screen (`h-[50vh]`); the
|
|
50
|
+
* bottom half hosts `MobileKeyBar` (#1070) -- the soft-key row + line-input
|
|
51
|
+
* for driving the interactive `claude` TUI from a phone. iOS/Android
|
|
52
|
+
* safe-area insets pad the header so the close control clears the
|
|
53
|
+
* notch/status bar (`index.html` sets `viewport-fit=cover` so
|
|
54
|
+
* `env(safe-area-inset-*)` resolves to something other than 0).
|
|
55
|
+
*/
|
|
56
|
+
import { useEffect, useRef, useState } from 'react'
|
|
57
|
+
import { useNavigate, useParams } from 'react-router-dom'
|
|
58
|
+
import { Terminal as XTerm } from '@xterm/xterm'
|
|
59
|
+
import { FitAddon } from '@xterm/addon-fit'
|
|
60
|
+
import '@xterm/xterm/css/xterm.css'
|
|
61
|
+
import { terminalWebSocketUrl } from '@/api/client'
|
|
62
|
+
import MobileKeyBar from '@/components/MobileKeyBar'
|
|
63
|
+
|
|
64
|
+
type ConnectionState = 'connecting' | 'open' | 'reconnecting' | 'ended'
|
|
65
|
+
|
|
66
|
+
const STATUS_LABEL: Record<ConnectionState, string> = {
|
|
67
|
+
connecting: 'Connecting…',
|
|
68
|
+
open: 'Live',
|
|
69
|
+
reconnecting: 'Reconnecting…',
|
|
70
|
+
ended: 'Session ended',
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Bridge close code for "session_id no longer resolves to an active
|
|
74
|
+
// assignment" -- see `resolve_session_target` / `terminal_ws` in
|
|
75
|
+
// `coord/dashboard/terminal.py`. Distinct from every other close (which is
|
|
76
|
+
// treated as a transient drop worth retrying).
|
|
77
|
+
const SESSION_GONE_CODE = 4404
|
|
78
|
+
const RECONNECT_BASE_MS = 1000
|
|
79
|
+
const RECONNECT_MAX_MS = 10000
|
|
80
|
+
// A connection has to survive at least this long after opening before a
|
|
81
|
+
// subsequent drop counts as "was actually working" and resets the backoff
|
|
82
|
+
// counter -- see the backoff-reset guard note above.
|
|
83
|
+
const STABLE_CONNECTION_MS = 2000
|
|
84
|
+
|
|
85
|
+
export default function Terminal() {
|
|
86
|
+
const { sessionId } = useParams<{ sessionId: string }>()
|
|
87
|
+
const navigate = useNavigate()
|
|
88
|
+
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
89
|
+
const wsRef = useRef<WebSocket | null>(null)
|
|
90
|
+
// Set right before any *deliberate* close (the ✕ button, unmount) so the
|
|
91
|
+
// reconnect logic below can tell "we closed this on purpose" apart from
|
|
92
|
+
// "the network/tab dropped it out from under us."
|
|
93
|
+
const intentionalCloseRef = useRef(false)
|
|
94
|
+
const [state, setState] = useState<ConnectionState>('connecting')
|
|
95
|
+
// Incremented on every WebSocket open (initial connect and reconnects).
|
|
96
|
+
// Passed to MobileKeyBar as `connectionKey` so it can reset its scroll-mode
|
|
97
|
+
// badge when a reconnect resets server-side copy-mode state (#1299).
|
|
98
|
+
const [connectionSeq, setConnectionSeq] = useState(0)
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (!sessionId || !containerRef.current) return
|
|
102
|
+
|
|
103
|
+
const term = new XTerm({
|
|
104
|
+
// convertEol: false — tmux sends proper CR+LF sequences; setting this
|
|
105
|
+
// true causes xterm.js to convert the CR to LF as well, producing
|
|
106
|
+
// double newlines (\r\n → \n\n).
|
|
107
|
+
convertEol: false,
|
|
108
|
+
cursorBlink: true,
|
|
109
|
+
fontSize: 13,
|
|
110
|
+
theme: { background: '#0d1117', foreground: '#e6edf3' },
|
|
111
|
+
})
|
|
112
|
+
const fit = new FitAddon()
|
|
113
|
+
term.loadAddon(fit)
|
|
114
|
+
term.open(containerRef.current)
|
|
115
|
+
try {
|
|
116
|
+
fit.fit()
|
|
117
|
+
} catch {
|
|
118
|
+
// container may not have a laid-out size yet (e.g. under test) -- xterm
|
|
119
|
+
// still opens fine, it just keeps its default 80x24 until the next fit.
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let reconnectAttempt = 0
|
|
123
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
124
|
+
// Timestamp of the most recent `onopen`, or null while not connected.
|
|
125
|
+
// Read (and cleared) in `onclose` to tell a stable connection's drop
|
|
126
|
+
// apart from an accept-then-immediately-fail flap -- see the
|
|
127
|
+
// backoff-reset guard note above the component docstring.
|
|
128
|
+
let openedAt: number | null = null
|
|
129
|
+
|
|
130
|
+
// ── Resize deduplication ────────────────────────────────────────────────
|
|
131
|
+
// Only emit a resize frame when cols or rows actually change. Without
|
|
132
|
+
// this guard, rapid layout events (ResizeObserver + visualViewport +
|
|
133
|
+
// window.resize all firing close together) would send many identical
|
|
134
|
+
// frames and thrash the PTY's TIOCSWINSZ.
|
|
135
|
+
let lastCols = 0
|
|
136
|
+
let lastRows = 0
|
|
137
|
+
|
|
138
|
+
// cols/rows default to term's current values so onopen / scheduleFit
|
|
139
|
+
// paths read from the terminal; the onResize path passes xterm's event
|
|
140
|
+
// args directly (avoids re-reading a stale value after an async hop).
|
|
141
|
+
const sendResize = (ws: WebSocket, cols = term.cols, rows = term.rows) => {
|
|
142
|
+
if (ws.readyState !== WebSocket.OPEN) return
|
|
143
|
+
if (cols === lastCols && rows === lastRows) return
|
|
144
|
+
lastCols = cols
|
|
145
|
+
lastRows = rows
|
|
146
|
+
ws.send(JSON.stringify({ type: 'resize', cols, rows }))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Debounced fit scheduler ──────────────────────────────────────────────
|
|
150
|
+
// Coalesces rapid layout events (window resize, ResizeObserver,
|
|
151
|
+
// visualViewport) into a single fit.fit() call 50 ms after the last
|
|
152
|
+
// event. fit.fit() calls term.resize() which fires onResize, which then
|
|
153
|
+
// calls sendResize — so the scheduler doesn't need to call sendResize
|
|
154
|
+
// directly.
|
|
155
|
+
let fitDebounce: ReturnType<typeof setTimeout> | null = null
|
|
156
|
+
const scheduleFit = () => {
|
|
157
|
+
if (fitDebounce) clearTimeout(fitDebounce)
|
|
158
|
+
fitDebounce = setTimeout(() => {
|
|
159
|
+
try {
|
|
160
|
+
fit.fit()
|
|
161
|
+
} catch {
|
|
162
|
+
// layout not ready yet (same situation as the mount-time fit)
|
|
163
|
+
}
|
|
164
|
+
fitDebounce = null
|
|
165
|
+
}, 50)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const connect = () => {
|
|
169
|
+
// Opening a connection is always deliberate, so any close that follows
|
|
170
|
+
// is "unintentional" until the ✕ button / unmount sets the flag again.
|
|
171
|
+
// Without this reset, React StrictMode's dev mount→cleanup→mount cycle
|
|
172
|
+
// (cleanup sets the flag true, then a *new* connection is opened) would
|
|
173
|
+
// leave the flag stuck true, so the live connection's onclose below
|
|
174
|
+
// would short-circuit and never surface "Reconnecting…" / "Session
|
|
175
|
+
// ended". Production single-mounts, but the Vite-dev E2E hits this.
|
|
176
|
+
intentionalCloseRef.current = false
|
|
177
|
+
const ws = new WebSocket(terminalWebSocketUrl(sessionId))
|
|
178
|
+
ws.binaryType = 'arraybuffer'
|
|
179
|
+
wsRef.current = ws
|
|
180
|
+
|
|
181
|
+
ws.onopen = () => {
|
|
182
|
+
openedAt = Date.now()
|
|
183
|
+
setState('open')
|
|
184
|
+
// Signal MobileKeyBar to reset its scroll-mode badge: a fresh
|
|
185
|
+
// `tmux attach-session` starts outside copy-mode, so a stale badge
|
|
186
|
+
// from the previous connection would misrepresent the server's state.
|
|
187
|
+
setConnectionSeq((n) => n + 1)
|
|
188
|
+
// Re-fit now that the WS is open: the layout may have settled since
|
|
189
|
+
// the mount-time fit (safe-area insets, MobileKeyBar DOM insertion).
|
|
190
|
+
// This gives us the most accurate terminal size to send as the first
|
|
191
|
+
// resize frame, which the tmux bridge needs to size the session
|
|
192
|
+
// correctly (it can't know the phone's viewport until we tell it).
|
|
193
|
+
try {
|
|
194
|
+
fit.fit()
|
|
195
|
+
} catch {
|
|
196
|
+
// layout not ready (e.g. under test) — term keeps its current size
|
|
197
|
+
}
|
|
198
|
+
// Always send a resize on every open (including reconnects): the
|
|
199
|
+
// server has just accepted a fresh connection and has no size state.
|
|
200
|
+
// Reset dedup so the frame is guaranteed to go out even when the
|
|
201
|
+
// terminal size hasn't changed since the last connection.
|
|
202
|
+
lastCols = 0
|
|
203
|
+
lastRows = 0
|
|
204
|
+
sendResize(ws)
|
|
205
|
+
}
|
|
206
|
+
ws.onclose = (event) => {
|
|
207
|
+
// Ignore closes from a superseded socket: React StrictMode's
|
|
208
|
+
// double-invoke opens ws#1, then cleanup closes it *after* ws#2 has
|
|
209
|
+
// already become the live connection. Only the current connection
|
|
210
|
+
// drives state transitions and reconnect scheduling — otherwise
|
|
211
|
+
// ws#1's late close schedules a spurious reconnect (the #1071
|
|
212
|
+
// reconnect-storm this guard family exists to prevent).
|
|
213
|
+
if (wsRef.current !== ws) return
|
|
214
|
+
if (intentionalCloseRef.current) return
|
|
215
|
+
if (event.code === SESSION_GONE_CODE) {
|
|
216
|
+
// The worker session itself is gone (not just this connection) --
|
|
217
|
+
// a terminal state. Retrying would spin forever against a
|
|
218
|
+
// session_id that will never resolve again.
|
|
219
|
+
setState('ended')
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
// Only treat this as "the connection was actually working" -- and
|
|
223
|
+
// so reset the backoff ladder -- if it stayed open long enough to
|
|
224
|
+
// matter. A connection that opened and closed again almost
|
|
225
|
+
// instantly (accept() succeeded, then the attach failed, or
|
|
226
|
+
// anything else killed it right away) keeps climbing the backoff
|
|
227
|
+
// instead of resetting to the base delay every cycle.
|
|
228
|
+
const wasStable =
|
|
229
|
+
openedAt !== null && Date.now() - openedAt >= STABLE_CONNECTION_MS
|
|
230
|
+
openedAt = null
|
|
231
|
+
if (wasStable) reconnectAttempt = 0
|
|
232
|
+
setState('reconnecting')
|
|
233
|
+
const delay = Math.min(RECONNECT_BASE_MS * 2 ** reconnectAttempt, RECONNECT_MAX_MS)
|
|
234
|
+
reconnectAttempt += 1
|
|
235
|
+
reconnectTimer = setTimeout(connect, delay)
|
|
236
|
+
}
|
|
237
|
+
// The WebSocket spec always fires "close" right after "error", so
|
|
238
|
+
// state transitions + reconnect scheduling live solely in onclose --
|
|
239
|
+
// duplicating them here would double-schedule reconnects.
|
|
240
|
+
ws.onerror = () => {}
|
|
241
|
+
ws.onmessage = (event) => {
|
|
242
|
+
if (typeof event.data === 'string') {
|
|
243
|
+
term.write(event.data)
|
|
244
|
+
} else {
|
|
245
|
+
term.write(new Uint8Array(event.data as ArrayBuffer))
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
connect()
|
|
251
|
+
|
|
252
|
+
const dataDisposable = term.onData((data) => {
|
|
253
|
+
const ws = wsRef.current
|
|
254
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
255
|
+
ws.send(new TextEncoder().encode(data))
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
// Use xterm's event args ({cols, rows}) directly rather than re-reading
|
|
259
|
+
// term.cols/term.rows — avoids any stale-value risk between the resize
|
|
260
|
+
// and our callback.
|
|
261
|
+
const resizeDisposable = term.onResize(({ cols, rows }) => {
|
|
262
|
+
const ws = wsRef.current
|
|
263
|
+
if (ws) sendResize(ws, cols, rows)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
// ── Layout change listeners ──────────────────────────────────────────────
|
|
267
|
+
// window.resize catches broad viewport changes (desktop browser resize,
|
|
268
|
+
// device orientation change).
|
|
269
|
+
window.addEventListener('resize', scheduleFit)
|
|
270
|
+
|
|
271
|
+
// ResizeObserver on the pane container catches layout shifts that
|
|
272
|
+
// window.resize misses, e.g. the MobileKeyBar growing/shrinking when
|
|
273
|
+
// the content changes height, or safe-area-inset-* adjustments.
|
|
274
|
+
const ro = new ResizeObserver(scheduleFit)
|
|
275
|
+
ro.observe(containerRef.current)
|
|
276
|
+
|
|
277
|
+
// visualViewport fires when the mobile soft keyboard appears or
|
|
278
|
+
// disappears, which shrinks/grows the visual viewport (the area the user
|
|
279
|
+
// actually sees) without necessarily changing window.innerWidth/Height.
|
|
280
|
+
// This is the most reliable hook for phone keyboard events.
|
|
281
|
+
window.visualViewport?.addEventListener('resize', scheduleFit)
|
|
282
|
+
|
|
283
|
+
return () => {
|
|
284
|
+
intentionalCloseRef.current = true
|
|
285
|
+
if (reconnectTimer) clearTimeout(reconnectTimer)
|
|
286
|
+
if (fitDebounce) clearTimeout(fitDebounce)
|
|
287
|
+
window.removeEventListener('resize', scheduleFit)
|
|
288
|
+
window.visualViewport?.removeEventListener('resize', scheduleFit)
|
|
289
|
+
ro.disconnect()
|
|
290
|
+
dataDisposable.dispose()
|
|
291
|
+
resizeDisposable.dispose()
|
|
292
|
+
wsRef.current?.close()
|
|
293
|
+
term.dispose()
|
|
294
|
+
wsRef.current = null
|
|
295
|
+
}
|
|
296
|
+
}, [sessionId])
|
|
297
|
+
|
|
298
|
+
const handleClose = () => {
|
|
299
|
+
intentionalCloseRef.current = true
|
|
300
|
+
wsRef.current?.close()
|
|
301
|
+
navigate(-1)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Write raw bytes (a JS string, one char per terminal byte) down the same
|
|
306
|
+
* WebSocket path `term.onData` uses for real keystrokes -- the wire format
|
|
307
|
+
* PTY bridge (#1065) expects for input. Used by `MobileKeyBar` (#1070) for
|
|
308
|
+
* its soft keys and line-input submit.
|
|
309
|
+
*/
|
|
310
|
+
const sendRaw = (data: string) => {
|
|
311
|
+
const ws = wsRef.current
|
|
312
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
313
|
+
ws.send(new TextEncoder().encode(data))
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Send a copy-mode control action as a JSON text frame to the bridge
|
|
319
|
+
* (#1299). The bridge routes it to ``tmux send-keys -X`` / ``tmux
|
|
320
|
+
* copy-mode`` server-side, which is prefix- and mode-key-agnostic.
|
|
321
|
+
*
|
|
322
|
+
* Used by `MobileKeyBar`'s Scroll controls via the `onControl` prop.
|
|
323
|
+
* Only sends when the WebSocket is open -- called during user interaction,
|
|
324
|
+
* so an open socket is the normal case; a closed socket just silently
|
|
325
|
+
* drops the frame (same defensive posture as `sendRaw`).
|
|
326
|
+
*/
|
|
327
|
+
const sendControl = (action: string) => {
|
|
328
|
+
const ws = wsRef.current
|
|
329
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
330
|
+
ws.send(JSON.stringify({ type: 'copy-mode', action }))
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return (
|
|
335
|
+
<div className="flex h-screen flex-col bg-background">
|
|
336
|
+
<header
|
|
337
|
+
className="flex items-center justify-between gap-3 border-b border-border px-4 pb-3"
|
|
338
|
+
style={{ paddingTop: 'calc(env(safe-area-inset-top) + 0.75rem)' }}
|
|
339
|
+
>
|
|
340
|
+
<div className="min-w-0">
|
|
341
|
+
<h1 className="truncate text-sm font-semibold text-foreground">Terminal</h1>
|
|
342
|
+
<p className="truncate font-mono text-xs text-muted-foreground">
|
|
343
|
+
{sessionId} · {STATUS_LABEL[state]}
|
|
344
|
+
</p>
|
|
345
|
+
</div>
|
|
346
|
+
<button
|
|
347
|
+
type="button"
|
|
348
|
+
onClick={handleClose}
|
|
349
|
+
aria-label="Close terminal"
|
|
350
|
+
className="shrink-0 rounded p-2 text-muted-foreground hover:text-foreground"
|
|
351
|
+
>
|
|
352
|
+
✕
|
|
353
|
+
</button>
|
|
354
|
+
</header>
|
|
355
|
+
|
|
356
|
+
{/* Top half: the live pane. Bottom half: the mobile key bar (#1070). */}
|
|
357
|
+
<div className="relative h-[50vh] min-h-0 shrink-0 border-b border-border bg-[#0d1117] px-2 py-2">
|
|
358
|
+
<div ref={containerRef} className="h-full w-full" data-testid="xterm-container" />
|
|
359
|
+
{state === 'ended' && (
|
|
360
|
+
<div
|
|
361
|
+
role="status"
|
|
362
|
+
className="absolute inset-0 flex items-center justify-center bg-[#0d1117]/90 px-6 text-center text-sm text-muted-foreground"
|
|
363
|
+
>
|
|
364
|
+
Session ended — the worker session is no longer available.
|
|
365
|
+
</div>
|
|
366
|
+
)}
|
|
367
|
+
</div>
|
|
368
|
+
<div
|
|
369
|
+
className="min-h-0 flex-1"
|
|
370
|
+
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
|
371
|
+
>
|
|
372
|
+
<MobileKeyBar onSend={sendRaw} onControl={sendControl} connectionKey={connectionSeq} />
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
)
|
|
376
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component tests for ConnectionBadge (#1549) — the honest
|
|
3
|
+
* live/reconnecting/stale-since-<time> indicator. Renders directly against
|
|
4
|
+
* the real ConnectionStatusContext by wrapping with RealtimeProvider's
|
|
5
|
+
* context provider indirectly via a minimal test double: since the context
|
|
6
|
+
* itself isn't exported, these tests drive it through the provider's public
|
|
7
|
+
* surface (RealtimeProvider + a fake EventSource) rather than reaching into
|
|
8
|
+
* internals.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect } from 'vitest'
|
|
11
|
+
import { act, render, screen, waitFor } from '@testing-library/react'
|
|
12
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
13
|
+
import { RealtimeProvider } from '@/realtime/RealtimeProvider'
|
|
14
|
+
import type { SseHandle } from '@/realtime/connection'
|
|
15
|
+
import { ConnectionBadge } from '../ConnectionBadge'
|
|
16
|
+
|
|
17
|
+
class FakeEventSource implements SseHandle {
|
|
18
|
+
static instances: FakeEventSource[] = []
|
|
19
|
+
url: string
|
|
20
|
+
onopen: ((ev: unknown) => void) | null = null
|
|
21
|
+
onerror: ((ev: unknown) => void) | null = null
|
|
22
|
+
constructor(url: string) {
|
|
23
|
+
this.url = url
|
|
24
|
+
FakeEventSource.instances.push(this)
|
|
25
|
+
}
|
|
26
|
+
addEventListener(): void {}
|
|
27
|
+
close(): void {}
|
|
28
|
+
emitOpen(): void {
|
|
29
|
+
this.onopen?.({})
|
|
30
|
+
}
|
|
31
|
+
emitError(): void {
|
|
32
|
+
this.onerror?.({})
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function lastInstance(): FakeEventSource {
|
|
37
|
+
const inst = FakeEventSource.instances[FakeEventSource.instances.length - 1]
|
|
38
|
+
if (!inst) throw new Error('no instance')
|
|
39
|
+
return inst
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function renderBadge() {
|
|
43
|
+
FakeEventSource.instances = []
|
|
44
|
+
return render(
|
|
45
|
+
<QueryClientProvider client={new QueryClient()}>
|
|
46
|
+
<RealtimeProvider
|
|
47
|
+
createEventSource={(url) => new FakeEventSource(url) as unknown as SseHandle}
|
|
48
|
+
backoffScheduleMs={[5]}
|
|
49
|
+
>
|
|
50
|
+
<ConnectionBadge />
|
|
51
|
+
</RealtimeProvider>
|
|
52
|
+
</QueryClientProvider>,
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('ConnectionBadge', () => {
|
|
57
|
+
it('shows "Connecting…" before the first connect', async () => {
|
|
58
|
+
renderBadge()
|
|
59
|
+
await waitFor(() => expect(FakeEventSource.instances.length).toBeGreaterThan(0))
|
|
60
|
+
expect(screen.getByRole('status')).toHaveTextContent('Connecting…')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('shows "Live" once the stream opens', async () => {
|
|
64
|
+
renderBadge()
|
|
65
|
+
await waitFor(() => expect(FakeEventSource.instances.length).toBeGreaterThan(0))
|
|
66
|
+
act(() => lastInstance().emitOpen())
|
|
67
|
+
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Live'))
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('shows a stale-since time then Reconnecting… after a drop', async () => {
|
|
71
|
+
renderBadge()
|
|
72
|
+
await waitFor(() => expect(FakeEventSource.instances.length).toBeGreaterThan(0))
|
|
73
|
+
act(() => lastInstance().emitOpen())
|
|
74
|
+
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Live'))
|
|
75
|
+
|
|
76
|
+
act(() => lastInstance().emitError())
|
|
77
|
+
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent(/Stale since/))
|
|
78
|
+
|
|
79
|
+
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Reconnecting…'))
|
|
80
|
+
})
|
|
81
|
+
})
|