@omercnet/paseo-gas-city 1.1.0 → 1.2.0-next.226.1

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.
package/README.md CHANGED
@@ -49,7 +49,7 @@ Gas City settings are host-scoped and shared by every Paseo client connected to
49
49
  - **Workspace mappings** override automatic longest-ancestor rig matching when a workspace is
50
50
  ambiguous or lives outside its rig path.
51
51
 
52
- Gas City requires Paseo `^0.9.0 || ^0.10.0`, whose server settings handle lets every daemon-side operation
52
+ Gas City requires Paseo `^0.9.0 || ^0.10.0 || ^0.11.0`, whose server settings handle lets every daemon-side operation
53
53
  use the persisted configuration.
54
54
 
55
55
  Settings are a safety and routing configuration, not a credential vault. Put authentication and
@@ -68,7 +68,7 @@ host. Enabling a remote endpoint sends requests to that host from the Paseo daem
68
68
 
69
69
  ## Requirements and limitations
70
70
 
71
- - Paseo `^0.9.0 || ^0.10.0` with plugins enabled.
71
+ - Paseo `^0.9.0 || ^0.10.0 || ^0.11.0` with plugins enabled.
72
72
  - A reachable Gas City v1.4.1 supervisor exposing its HTTP API.
73
73
  - HTTP or HTTPS endpoints only. Credentials, query strings, and fragments are rejected.
74
74
  - Automatic workspace mapping requires the workspace path to be inside exactly one discovered rig;
@@ -83,7 +83,7 @@ host. Enabling a remote endpoint sends requests to that host from the Paseo daem
83
83
 
84
84
  ## Install
85
85
 
86
- With Paseo `^0.9.0 || ^0.10.0`, install the npm package:
86
+ With Paseo `^0.9.0 || ^0.10.0 || ^0.11.0`, install the npm package:
87
87
 
88
88
  ```bash
89
89
  paseo plugin install npm:@omercnet/paseo-gas-city
@@ -0,0 +1,75 @@
1
+ import { type PluginScreenProps, useSettings } from "@getpaseo/plugin/client";
2
+ import { gasCitySettings } from "../shared";
3
+ import { CityOperations } from "./city-operations";
4
+ import { SurfaceState } from "./gas-city-surface";
5
+ import { useDiscovery } from "./sidebar-item";
6
+ import { parseCityParam } from "./view-model";
7
+
8
+ export function CityScreen(props: PluginScreenProps) {
9
+ const settings = useSettings(gasCitySettings);
10
+ const discovery = useDiscovery(props.host.id);
11
+ const city = parseCityParam(props.params);
12
+ if (!city) {
13
+ return (
14
+ <SurfaceState
15
+ {...props}
16
+ title="No city selected"
17
+ body="Open a city from the Gas City sidebar menu."
18
+ />
19
+ );
20
+ }
21
+ if (settings.status === "loading") {
22
+ return (
23
+ <SurfaceState
24
+ {...props}
25
+ title="Loading Gas City"
26
+ body="Reading persisted connection settings."
27
+ loading
28
+ />
29
+ );
30
+ }
31
+ if (settings.status !== "ready") {
32
+ return (
33
+ <SurfaceState
34
+ {...props}
35
+ title="Gas City settings unavailable"
36
+ body={settings.error}
37
+ onRetry={() => void settings.reload()}
38
+ />
39
+ );
40
+ }
41
+ const { health, data } = discovery;
42
+ if (health.tone === "unavailable" || health.tone === "unconfigured") {
43
+ return (
44
+ <SurfaceState
45
+ {...props}
46
+ title={
47
+ health.tone === "unconfigured" ? "Gas City is not configured" : "Gas City is unavailable"
48
+ }
49
+ body={health.summary}
50
+ onRetry={discovery.refetch}
51
+ />
52
+ );
53
+ }
54
+ if (data?.state === "available" && !data.cities.some((item) => item.name === city)) {
55
+ return (
56
+ <SurfaceState
57
+ {...props}
58
+ title="City not found"
59
+ body={`The supervisor does not report a city named ${city}.`}
60
+ onRetry={discovery.refetch}
61
+ />
62
+ );
63
+ }
64
+ return (
65
+ <CityOperations
66
+ key={`${settings.revision}:${city}`}
67
+ theme={props.theme}
68
+ layout={props.layout}
69
+ host={props.host}
70
+ cityName={city}
71
+ rigName={null}
72
+ settings={settings.values}
73
+ />
74
+ );
75
+ }
@@ -1,8 +1,36 @@
1
1
  import type { PluginClientContext } from "@getpaseo/plugin/client";
2
+ import { CityScreen } from "./city-screen";
2
3
  import { queueSlingIntent } from "./dispatch-intent";
3
4
  import { FactoryPanel } from "./factory-panel";
4
5
  import { GasCitySurface } from "./gas-city-surface";
5
6
  import { GasCitySettingsScreen } from "./settings-screen";
7
+ import { GasCitySidebarItem } from "./sidebar-item";
8
+ import { CITY_SCREEN_ID, cityScreenTitle, supportsScreens } from "./view-model";
9
+
10
+ // Paseo 0.11+ registers screens and a sidebar row; 0.9/0.10 keep the static surface.
11
+ // The main screen keeps the old sidebar item id so saved links keep working.
12
+ function addNavigation(client: PluginClientContext) {
13
+ if (supportsScreens(client)) {
14
+ return [
15
+ client.addScreen({ id: "gas-city", title: "Gas City", Component: GasCitySurface }),
16
+ client.addScreen({ id: CITY_SCREEN_ID, title: cityScreenTitle, Component: CityScreen }),
17
+ client.addSidebarHeaderItem({
18
+ id: "gas-city",
19
+ title: "Gas City",
20
+ Component: GasCitySidebarItem,
21
+ }),
22
+ ];
23
+ }
24
+ return [
25
+ client.addSurface("gas-city", GasCitySurface),
26
+ client.addSidebarItem({
27
+ id: "gas-city",
28
+ title: "Gas City",
29
+ icon: "Factory",
30
+ surface: "gas-city",
31
+ }),
32
+ ];
33
+ }
6
34
 
7
35
  export function registerGasCityClient(client: PluginClientContext) {
8
36
  const cleanups = [
@@ -12,13 +40,7 @@ export function registerGasCityClient(client: PluginClientContext) {
12
40
  icon: "Settings",
13
41
  Component: GasCitySettingsScreen,
14
42
  }),
15
- client.addSurface("gas-city", GasCitySurface),
16
- client.addSidebarItem({
17
- id: "gas-city",
18
- title: "Gas City",
19
- icon: "Factory",
20
- surface: "gas-city",
21
- }),
43
+ ...addNavigation(client),
22
44
  client.addWorkspacePanel({
23
45
  id: "gas-city-factory",
24
46
  title: "Factory",
@@ -33,8 +55,9 @@ export function registerGasCityClient(client: PluginClientContext) {
33
55
  icon: "Factory",
34
56
  keywords: ["gas city", "supervisor", "sessions", "convoys", "operator"],
35
57
  context: "global",
36
- onSelect({ openSurface }) {
37
- openSurface("gas-city");
58
+ onSelect({ openSurface, openScreen }) {
59
+ if (typeof openScreen === "function") openScreen({ screenId: "gas-city" });
60
+ else openSurface("gas-city");
38
61
  },
39
62
  }),
40
63
  client.addCommandCenterItem({
@@ -208,7 +208,7 @@ function ReadyGasCitySurface({
208
208
  );
209
209
  }
210
210
 
211
- function SurfaceState({
211
+ export function SurfaceState({
212
212
  theme,
213
213
  title,
214
214
  body,
@@ -0,0 +1,159 @@
1
+ import {
2
+ type PluginPopoverProps,
3
+ type PluginSidebarItemProps,
4
+ useRpc,
5
+ useSettings,
6
+ } from "@getpaseo/plugin/client";
7
+ import { SidebarRow } from "@getpaseo/plugin/client/ui";
8
+ import { useQuery } from "@tanstack/react-query";
9
+ import { useMemo } from "react";
10
+ import { Pressable, StyleSheet, Text, View } from "react-native";
11
+ import { discoverSupervisor, gasCitySettings } from "../shared";
12
+ import {
13
+ CITY_SCREEN_ID,
14
+ cityScreenParams,
15
+ type SupervisorTone,
16
+ supervisorHealth,
17
+ } from "./view-model";
18
+
19
+ type Theme = PluginSidebarItemProps["theme"];
20
+
21
+ function toneColor(theme: Theme, tone: SupervisorTone): string {
22
+ switch (tone) {
23
+ case "healthy":
24
+ return theme.colors.statusSuccess;
25
+ case "warning":
26
+ return theme.colors.statusWarning;
27
+ case "unavailable":
28
+ return theme.colors.statusDanger;
29
+ default:
30
+ return theme.colors.foregroundMuted;
31
+ }
32
+ }
33
+
34
+ export function useDiscovery(hostId: string) {
35
+ const settings = useSettings(gasCitySettings);
36
+ const loadDiscovery = useRpc(discoverSupervisor);
37
+ const ready = settings.status === "ready";
38
+ const query = useQuery({
39
+ // Same key as the main screen so both share one poll.
40
+ queryKey: ["gas-city", hostId, ready ? settings.values.endpointUrl : null, "discovery"],
41
+ queryFn: () => loadDiscovery({}),
42
+ enabled: ready,
43
+ refetchInterval: ready ? settings.values.refreshIntervalMs : false,
44
+ });
45
+ // Invalid or unreadable settings must not fall back to any cached discovery.
46
+ const data = ready ? query.data : undefined;
47
+ const health = supervisorHealth({
48
+ data,
49
+ error: settings.status === "error" ? new Error(settings.error) : ready ? query.error : null,
50
+ pending: settings.status === "loading" || (ready && query.isPending),
51
+ });
52
+ return { data, health, refetch: () => void query.refetch() };
53
+ }
54
+
55
+ export function GasCitySidebarItem({
56
+ theme,
57
+ host,
58
+ currentScreen,
59
+ openScreen,
60
+ openPopover,
61
+ }: PluginSidebarItemProps) {
62
+ const { health } = useDiscovery(host.id);
63
+ const color = toneColor(theme, health.tone);
64
+ const styles = useMemo(() => createStyles(theme), [theme]);
65
+ const active =
66
+ currentScreen?.screenId === "gas-city" || currentScreen?.screenId === CITY_SCREEN_ID;
67
+ return (
68
+ <SidebarRow
69
+ icon="Factory"
70
+ active={active}
71
+ onPress={() => openScreen({ screenId: "gas-city" })}
72
+ trailing={
73
+ <Pressable
74
+ accessibilityRole="button"
75
+ accessibilityLabel={`Gas City ${health.tone}: ${health.summary} Show cities`}
76
+ onPress={() => openPopover(CitiesPopover)}
77
+ style={styles.trailing}
78
+ >
79
+ <View style={[styles.dot, { backgroundColor: color }]} />
80
+ <Text style={[styles.badge, { color }]}>{health.badge}</Text>
81
+ </Pressable>
82
+ }
83
+ />
84
+ );
85
+ }
86
+
87
+ function CitiesPopover({ theme, host, layout, close, openScreen }: PluginPopoverProps) {
88
+ const { data, health } = useDiscovery(host.id);
89
+ const styles = useMemo(() => createStyles(theme), [theme]);
90
+ const color = toneColor(theme, health.tone);
91
+ const cities = data?.cities ?? [];
92
+ return (
93
+ <View style={[styles.popover, layout.compact && styles.popoverCompact]}>
94
+ <View style={styles.popoverHeader}>
95
+ <View style={[styles.dot, { backgroundColor: color }]} />
96
+ <Text accessibilityRole="header" style={styles.popoverTitle}>
97
+ Gas City
98
+ </Text>
99
+ </View>
100
+ <Text style={[styles.summary, health.tone !== "healthy" && { color }]}>{health.summary}</Text>
101
+ {cities.map((city) => (
102
+ <Pressable
103
+ key={`${city.path}:${city.name}`}
104
+ accessibilityRole="button"
105
+ accessibilityLabel={`Open ${city.name}, ${city.running ? "running" : (city.status ?? "stopped")}`}
106
+ onPress={() => {
107
+ close();
108
+ openScreen({ screenId: CITY_SCREEN_ID, params: cityScreenParams(city.name) });
109
+ }}
110
+ style={({ pressed }) => [styles.cityRow, pressed && styles.pressed]}
111
+ >
112
+ <View
113
+ style={[
114
+ styles.dot,
115
+ {
116
+ backgroundColor: city.running
117
+ ? theme.colors.statusSuccess
118
+ : theme.colors.statusWarning,
119
+ },
120
+ ]}
121
+ />
122
+ <Text style={styles.cityName} numberOfLines={1}>
123
+ {city.name}
124
+ </Text>
125
+ <Text style={styles.cityStatus} numberOfLines={1}>
126
+ {city.error ?? city.status ?? (city.running ? "running" : "stopped")}
127
+ </Text>
128
+ </Pressable>
129
+ ))}
130
+ {data?.state === "available" && cities.length === 0 ? (
131
+ <Text style={styles.summary}>No cities reported.</Text>
132
+ ) : null}
133
+ </View>
134
+ );
135
+ }
136
+
137
+ function createStyles(theme: Theme) {
138
+ return StyleSheet.create({
139
+ trailing: { flexDirection: "row", alignItems: "center", gap: 5, paddingHorizontal: 6 },
140
+ dot: { width: 7, height: 7, borderRadius: 4 },
141
+ badge: { fontSize: 11, fontWeight: "700" },
142
+ popover: { minWidth: 260, gap: 6, padding: 12 },
143
+ popoverCompact: { padding: 16, paddingBottom: 24 },
144
+ popoverHeader: { flexDirection: "row", alignItems: "center", gap: 7 },
145
+ popoverTitle: { color: theme.colors.foreground, fontSize: 14, fontWeight: "800" },
146
+ summary: { color: theme.colors.foregroundMuted, fontSize: 12, lineHeight: 17 },
147
+ cityRow: {
148
+ minHeight: 36,
149
+ flexDirection: "row",
150
+ alignItems: "center",
151
+ gap: 8,
152
+ paddingHorizontal: 8,
153
+ borderRadius: 6,
154
+ },
155
+ cityName: { color: theme.colors.foreground, fontSize: 13, fontWeight: "600" },
156
+ cityStatus: { flex: 1, color: theme.colors.foregroundMuted, fontSize: 11, textAlign: "right" },
157
+ pressed: { opacity: 0.72 },
158
+ });
159
+ }
@@ -4,6 +4,7 @@ import type {
4
4
  GasCityEvent,
5
5
  GasCitySession,
6
6
  GasCityWorkItem,
7
+ SupervisorDiscovery,
7
8
  } from "../shared";
8
9
 
9
10
  export type DashboardRow =
@@ -316,3 +317,86 @@ export function convoyProgress(convoy: GasCityConvoy): string {
316
317
  if (convoy.closedWork === null || convoy.totalWork === null) return "Progress unavailable";
317
318
  return `${convoy.closedWork} of ${convoy.totalWork} work items closed`;
318
319
  }
320
+
321
+ export const CITY_SCREEN_ID = "city";
322
+
323
+ export function cityScreenParams(city: string): Record<string, string> {
324
+ return { city };
325
+ }
326
+
327
+ export function parseCityParam(params: Record<string, string>): string | null {
328
+ const city = params.city?.trim();
329
+ return city ? city : null;
330
+ }
331
+
332
+ export function cityScreenTitle(params: Record<string, string>): string {
333
+ const city = parseCityParam(params);
334
+ return city ? `${city} · Gas City` : "Gas City";
335
+ }
336
+
337
+ export interface ScreenApi {
338
+ addScreen?: unknown;
339
+ addSidebarHeaderItem?: unknown;
340
+ }
341
+
342
+ /** True on Paseo 0.11+, where screens and sidebar header items exist. */
343
+ export function supportsScreens(client: ScreenApi): boolean {
344
+ return (
345
+ typeof client.addScreen === "function" && typeof client.addSidebarHeaderItem === "function"
346
+ );
347
+ }
348
+
349
+ export type SupervisorTone = "loading" | "healthy" | "warning" | "unavailable" | "unconfigured";
350
+
351
+ export interface SupervisorHealth {
352
+ tone: SupervisorTone;
353
+ /** Short text for the sidebar trailing slot. */
354
+ badge: string;
355
+ /** One-line explanation for the popover and accessibility labels. */
356
+ summary: string;
357
+ }
358
+
359
+ /** Health is "healthy" only when the supervisor answered with a usable summary and no diagnostics. */
360
+ export function supervisorHealth(input: {
361
+ data: SupervisorDiscovery | undefined;
362
+ error: unknown;
363
+ pending: boolean;
364
+ }): SupervisorHealth {
365
+ const { data, error, pending } = input;
366
+ // A failed refresh outranks cached data: never show "healthy" from a stale success.
367
+ if (error || !data) {
368
+ if (error) {
369
+ return {
370
+ tone: "unavailable",
371
+ badge: "error",
372
+ summary: error instanceof Error ? error.message : "Could not reach Gas City.",
373
+ };
374
+ }
375
+ return pending
376
+ ? { tone: "loading", badge: "...", summary: "Contacting the configured supervisor." }
377
+ : { tone: "unavailable", badge: "error", summary: "No supervisor data." };
378
+ }
379
+ const detail = data.diagnostics.map((item) => item.message).join(" ");
380
+ if (data.state === "not-configured") {
381
+ return {
382
+ tone: "unconfigured",
383
+ badge: "setup",
384
+ summary: detail || "Gas City is not configured.",
385
+ };
386
+ }
387
+ if (data.state !== "available" || !data.supervisor) {
388
+ return {
389
+ tone: "unavailable",
390
+ badge: "offline",
391
+ summary: detail || `Supervisor state: ${data.state}.`,
392
+ };
393
+ }
394
+ const { runningCityCount, cityCount } = data.supervisor;
395
+ return {
396
+ tone: data.diagnostics.length > 0 ? "warning" : "healthy",
397
+ badge: `${runningCityCount}/${cityCount}`,
398
+ summary:
399
+ detail ||
400
+ `${runningCityCount} of ${cityCount} ${cityCount === 1 ? "city" : "cities"} running.`,
401
+ };
402
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omercnet/paseo-gas-city",
3
- "version": "1.1.0",
3
+ "version": "1.2.0-next.226.1",
4
4
  "type": "module",
5
5
  "description": "A Paseo control plane for observing and operating Gas City supervisors.",
6
6
  "license": "MIT",
@@ -52,15 +52,14 @@
52
52
  },
53
53
  "devDependencies": {
54
54
  "@biomejs/biome": "^2.5.10",
55
- "@getpaseo/cli": "0.10.1",
56
- "@getpaseo/client": "0.10.1",
57
- "@getpaseo/plugin": "0.10.1",
58
- "@getpaseo/protocol": "0.10.1",
55
+ "@getpaseo/cli": "0.11.0-beta.5",
56
+ "@getpaseo/client": "0.11.0-beta.5",
57
+ "@getpaseo/plugin": "0.11.0-beta.5",
58
+ "@getpaseo/protocol": "0.11.0-beta.5",
59
59
  "@tanstack/react-query": "^5.102.3",
60
60
  "@types/node": "^24.10.1",
61
61
  "@types/react": "^19.2.18",
62
62
  "@vitest/coverage-v8": "^5.0.0",
63
- "fflate": "^0.8.3",
64
63
  "react": "19.1.0",
65
64
  "react-native": "0.81.5",
66
65
  "typescript": "^7.0.2",
package/paseo-plugin.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "id": "gas-city",
3
3
  "description": "A Paseo control plane for observing and operating Gas City supervisors.",
4
- "requirements": { "paseo": "^0.9.0 || ^0.10.0" }
4
+ "requirements": { "paseo": "^0.9.0 || ^0.10.0 || ^0.11.0" }
5
5
  }