@omercnet/paseo-gas-city 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,96 @@
1
+ import type { PluginClientContext } from "@getpaseo/plugin/client";
2
+ import { queueSlingIntent } from "./dispatch-intent";
3
+ import { FactoryPanel } from "./factory-panel";
4
+ import { GasCitySurface } from "./gas-city-surface";
5
+ import { GasCitySettingsScreen } from "./settings-screen";
6
+
7
+ export function registerGasCityClient(client: PluginClientContext) {
8
+ const cleanups = [
9
+ client.addSettingsScreen({
10
+ id: "gas-city",
11
+ title: "Gas City settings",
12
+ icon: "Settings",
13
+ Component: GasCitySettingsScreen,
14
+ }),
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
+ }),
22
+ client.addWorkspacePanel({
23
+ id: "gas-city-factory",
24
+ title: "Factory",
25
+ icon: "Factory",
26
+ context: "workspace",
27
+ locations: ["workspace", "explorer"],
28
+ Component: FactoryPanel,
29
+ }),
30
+ client.addCommandCenterItem({
31
+ id: "open-gas-city",
32
+ title: "Open Gas City",
33
+ icon: "Factory",
34
+ keywords: ["gas city", "supervisor", "sessions", "convoys", "operator"],
35
+ context: "global",
36
+ onSelect({ openSurface }) {
37
+ openSurface("gas-city");
38
+ },
39
+ }),
40
+ client.addCommandCenterItem({
41
+ id: "configure-gas-city",
42
+ title: "Configure Gas City",
43
+ icon: "Settings",
44
+ keywords: ["gas city", "endpoint", "observe only", "mutations"],
45
+ context: "global",
46
+ onSelect({ openSettings }) {
47
+ openSettings("gas-city");
48
+ },
49
+ }),
50
+ client.addCommandCenterItem({
51
+ id: "open-gas-city-factory",
52
+ title: "Open Gas City Factory",
53
+ icon: "Factory",
54
+ keywords: ["gas city", "workspace", "rig", "sessions", "convoys"],
55
+ context: "workspace",
56
+ onSelect({ openPanel }) {
57
+ openPanel("gas-city-factory");
58
+ },
59
+ }),
60
+ client.addCommandCenterItem({
61
+ id: "sling-gas-city-work",
62
+ title: "Sling work in Gas City",
63
+ icon: "Send",
64
+ keywords: ["gas city", "dispatch", "bead", "agent role"],
65
+ context: "workspace",
66
+ onSelect({ workspace, openPanel }) {
67
+ queueSlingIntent(workspace.id, "");
68
+ openPanel("gas-city-factory");
69
+ },
70
+ }),
71
+ client.addCommandCenterItem({
72
+ id: "open-agent-gas-city-factory",
73
+ title: "Open Gas City Factory",
74
+ icon: "Factory",
75
+ keywords: ["gas city", "workspace", "agent", "session"],
76
+ context: "agent",
77
+ onSelect({ openPanel }) {
78
+ openPanel("gas-city-factory");
79
+ },
80
+ }),
81
+ client.addSlashCommand({
82
+ name: "sling",
83
+ description: "Confirm and dispatch a Gas City bead to an agent role",
84
+ argumentHint: "<bead-id> [agent-role]",
85
+ context: "workspace",
86
+ onSubmit({ workspace, args, openPanel }) {
87
+ queueSlingIntent(workspace.id, args);
88
+ openPanel("gas-city-factory");
89
+ },
90
+ }),
91
+ ];
92
+
93
+ return () => {
94
+ for (const cleanup of cleanups.reverse()) cleanup();
95
+ };
96
+ }
@@ -0,0 +1,53 @@
1
+ import { useSyncExternalStore } from "react";
2
+ import { parseSlingArguments, type SlingArguments } from "./view-model";
3
+
4
+ export interface SlingIntent extends SlingArguments {
5
+ id: number;
6
+ parseError: string | null;
7
+ }
8
+
9
+ const intents = new Map<string, SlingIntent>();
10
+ const listeners = new Set<() => void>();
11
+ let nextId = 1;
12
+
13
+ function emit() {
14
+ for (const listener of listeners) listener();
15
+ }
16
+
17
+ export function queueSlingIntent(workspaceId: string, args: string): SlingIntent {
18
+ const parsed = parseSlingArguments(args);
19
+ const intent: SlingIntent = {
20
+ id: nextId++,
21
+ beadId: parsed?.beadId ?? "",
22
+ agent: parsed?.agent ?? "",
23
+ parseError: parsed
24
+ ? null
25
+ : "Use /sling <bead-id> [agent-role]. Quote roles that contain spaces.",
26
+ };
27
+ intents.set(workspaceId, intent);
28
+ emit();
29
+ return intent;
30
+ }
31
+
32
+ export function getSlingIntent(workspaceId: string): SlingIntent | null {
33
+ return intents.get(workspaceId) ?? null;
34
+ }
35
+
36
+ export function dismissSlingIntent(workspaceId: string, id: number): void {
37
+ if (intents.get(workspaceId)?.id !== id) return;
38
+ intents.delete(workspaceId);
39
+ emit();
40
+ }
41
+
42
+ export function subscribeSlingIntent(listener: () => void): () => void {
43
+ listeners.add(listener);
44
+ return () => listeners.delete(listener);
45
+ }
46
+
47
+ export function useSlingIntent(workspaceId: string): SlingIntent | null {
48
+ return useSyncExternalStore(
49
+ subscribeSlingIntent,
50
+ () => getSlingIntent(workspaceId),
51
+ () => null,
52
+ );
53
+ }
@@ -0,0 +1,373 @@
1
+ import {
2
+ type PluginWorkspacePanelProps,
3
+ useRpc,
4
+ useSettings,
5
+ useWorkspace,
6
+ } from "@getpaseo/plugin/client";
7
+ import { Icon } from "@getpaseo/plugin/client/react-native";
8
+ import { useQuery } from "@tanstack/react-query";
9
+ import { useCallback, useMemo } from "react";
10
+ import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
11
+ import {
12
+ type GasCitySettings,
13
+ gasCitySettings,
14
+ resolveWorkspaceRig,
15
+ toGasCityRpcSettings,
16
+ type WorkspaceRigMapping,
17
+ } from "../shared";
18
+ import { CityOperations } from "./city-operations";
19
+ import { dismissSlingIntent, useSlingIntent } from "./dispatch-intent";
20
+
21
+ function errorMessage(error: unknown): string {
22
+ return error instanceof Error ? error.message : "An unexpected error occurred.";
23
+ }
24
+
25
+ export function FactoryPanel(props: PluginWorkspacePanelProps) {
26
+ const settings = useSettings(gasCitySettings);
27
+ if (settings.status === "loading") {
28
+ return (
29
+ <FactoryState
30
+ {...props}
31
+ title="Loading Factory"
32
+ body="Reading persisted Gas City settings."
33
+ loading
34
+ />
35
+ );
36
+ }
37
+ if (settings.status !== "ready") {
38
+ return (
39
+ <FactoryState
40
+ {...props}
41
+ title="Factory settings unavailable"
42
+ body={settings.error}
43
+ onRetry={() => void settings.reload()}
44
+ />
45
+ );
46
+ }
47
+ return (
48
+ <ReadyFactoryPanel
49
+ key={`${props.workspaceId}:${settings.revision}`}
50
+ {...props}
51
+ settings={settings.values}
52
+ />
53
+ );
54
+ }
55
+
56
+ function ReadyFactoryPanel({
57
+ theme,
58
+ layout,
59
+ host,
60
+ navigation,
61
+ workspaceId,
62
+ settings,
63
+ }: PluginWorkspacePanelProps & { settings: GasCitySettings }) {
64
+ const styles = useMemo(() => createStyles(theme, layout.compact), [layout.compact, theme]);
65
+ const rpcSettings = useMemo(() => toGasCityRpcSettings(settings), [settings]);
66
+ const workspace = useWorkspace(workspaceId, ({ directory, name, title }) => ({
67
+ directory,
68
+ name,
69
+ title,
70
+ }));
71
+ const loadMapping = useRpc(resolveWorkspaceRig);
72
+ const mapping = useQuery({
73
+ queryKey: ["gas-city", host.id, settings.endpointUrl, "workspace-mapping", workspaceId],
74
+ queryFn: () => loadMapping({ settings: rpcSettings, workspaceId }),
75
+ refetchInterval: settings.refreshIntervalMs,
76
+ });
77
+ const retryMapping = () => void mapping.refetch();
78
+ const slingIntent = useSlingIntent(workspaceId);
79
+ const dismissIntent = useCallback(
80
+ (id: number) => dismissSlingIntent(workspaceId, id),
81
+ [workspaceId],
82
+ );
83
+
84
+ if (mapping.isPending && !mapping.data) {
85
+ return (
86
+ <FactoryState
87
+ theme={theme}
88
+ layout={layout}
89
+ host={host}
90
+ navigation={navigation}
91
+ context="workspace"
92
+ workspaceId={workspaceId}
93
+ title="Mapping workspace"
94
+ body="Resolving this workspace to a Gas City rig."
95
+ loading
96
+ />
97
+ );
98
+ }
99
+ if (mapping.error && !mapping.data) {
100
+ return (
101
+ <FactoryState
102
+ theme={theme}
103
+ layout={layout}
104
+ host={host}
105
+ navigation={navigation}
106
+ context="workspace"
107
+ workspaceId={workspaceId}
108
+ title="Could not map workspace"
109
+ body={errorMessage(mapping.error)}
110
+ onRetry={retryMapping}
111
+ />
112
+ );
113
+ }
114
+ if (!mapping.data) {
115
+ return (
116
+ <FactoryState
117
+ theme={theme}
118
+ layout={layout}
119
+ host={host}
120
+ navigation={navigation}
121
+ context="workspace"
122
+ workspaceId={workspaceId}
123
+ title="No workspace mapping"
124
+ body="The supervisor returned no usable mapping result."
125
+ onRetry={retryMapping}
126
+ />
127
+ );
128
+ }
129
+
130
+ const resolved = mapping.data;
131
+ if (resolved.state !== "mapped" || !resolved.cityName || !resolved.rigName) {
132
+ return (
133
+ <MappingState
134
+ theme={theme}
135
+ layout={layout}
136
+ host={host}
137
+ navigation={navigation}
138
+ context="workspace"
139
+ workspaceId={workspaceId}
140
+ mapping={resolved}
141
+ onRetry={retryMapping}
142
+ />
143
+ );
144
+ }
145
+
146
+ return (
147
+ <View style={styles.screen}>
148
+ <View style={styles.mappingBar}>
149
+ <View style={styles.mappingIcon}>
150
+ <Icon name="Factory" size={15} color={theme.colors.accent} />
151
+ </View>
152
+ <View style={styles.mappingText}>
153
+ <Text style={styles.mappingTitle} numberOfLines={1}>
154
+ {workspace?.title?.trim() || workspace?.name || "Workspace Factory"}
155
+ </Text>
156
+ <Text style={styles.mappingMeta} numberOfLines={1}>
157
+ {resolved.cityName} / {resolved.rigName} ·{" "}
158
+ {resolved.source === "explicit" ? "explicit mapping" : "path mapping"}
159
+ </Text>
160
+ </View>
161
+ {mapping.isFetching ? <ActivityIndicator size="small" color={theme.colors.accent} /> : null}
162
+ </View>
163
+ {resolved.diagnostics.length > 0 ? (
164
+ <View accessibilityRole="alert" style={styles.diagnostics}>
165
+ <Icon name="TriangleAlert" size={14} color={theme.colors.statusWarning} />
166
+ <Text style={styles.diagnosticText} numberOfLines={2}>
167
+ {resolved.diagnostics.map((diagnostic) => diagnostic.message).join(" · ")}
168
+ </Text>
169
+ </View>
170
+ ) : null}
171
+ <View style={styles.body}>
172
+ <CityOperations
173
+ key={`${settings.endpointUrl}:${resolved.cityName}:${resolved.rigName}`}
174
+ theme={theme}
175
+ layout={layout}
176
+ host={host}
177
+ cityName={resolved.cityName}
178
+ rigName={resolved.rigName}
179
+ settings={settings}
180
+ slingIntent={slingIntent}
181
+ onDismissSlingIntent={dismissIntent}
182
+ />
183
+ </View>
184
+ </View>
185
+ );
186
+ }
187
+
188
+ function MappingState({
189
+ theme,
190
+ layout,
191
+ host,
192
+ navigation,
193
+ workspaceId,
194
+ mapping,
195
+ onRetry,
196
+ }: PluginWorkspacePanelProps & { mapping: WorkspaceRigMapping; onRetry: () => void }) {
197
+ const styles = useMemo(() => createStyles(theme, layout.compact), [layout.compact, theme]);
198
+ const title =
199
+ mapping.state === "ambiguous"
200
+ ? "Choose an explicit mapping"
201
+ : mapping.state === "unavailable"
202
+ ? "Supervisor mapping unavailable"
203
+ : "Workspace is not mapped";
204
+ const body =
205
+ mapping.diagnostics.map((diagnostic) => diagnostic.message).join(" ") ||
206
+ (mapping.state === "ambiguous"
207
+ ? "Multiple Gas City rigs contain this workspace. Add an explicit override in Gas City settings."
208
+ : "No Gas City rig contains this workspace path. Add an explicit override in Gas City settings.");
209
+ return (
210
+ <View style={styles.mappingStateScreen}>
211
+ <FactoryState
212
+ theme={theme}
213
+ layout={layout}
214
+ host={host}
215
+ navigation={navigation}
216
+ context="workspace"
217
+ workspaceId={workspaceId}
218
+ title={title}
219
+ body={body}
220
+ onRetry={onRetry}
221
+ />
222
+ {mapping.candidates.length > 0 ? (
223
+ <ScrollView style={styles.candidates} contentContainerStyle={styles.candidatesContent}>
224
+ <Text accessibilityRole="header" style={styles.candidatesTitle}>
225
+ Mapping candidates
226
+ </Text>
227
+ {mapping.candidates.map((candidate) => (
228
+ <View key={`${candidate.cityName}:${candidate.rigPath}`} style={styles.candidateRow}>
229
+ <Icon name="GitBranch" size={14} color={theme.colors.foregroundMuted} />
230
+ <View style={styles.mappingText}>
231
+ <Text style={styles.mappingTitle}>
232
+ {candidate.cityName} / {candidate.rigName}
233
+ </Text>
234
+ <Text style={styles.mappingMeta} numberOfLines={1}>
235
+ {candidate.rigPath}
236
+ </Text>
237
+ </View>
238
+ </View>
239
+ ))}
240
+ </ScrollView>
241
+ ) : null}
242
+ </View>
243
+ );
244
+ }
245
+
246
+ function FactoryState({
247
+ theme,
248
+ title,
249
+ body,
250
+ loading,
251
+ onRetry,
252
+ }: PluginWorkspacePanelProps & {
253
+ title: string;
254
+ body: string;
255
+ loading?: boolean;
256
+ onRetry?: () => void;
257
+ }) {
258
+ const styles = useMemo(() => createStyles(theme, true), [theme]);
259
+ return (
260
+ <View style={styles.stateCard}>
261
+ {loading ? (
262
+ <ActivityIndicator color={theme.colors.accent} />
263
+ ) : (
264
+ <Icon name="Factory" size={28} color={theme.colors.foregroundMuted} />
265
+ )}
266
+ <Text accessibilityRole="header" style={styles.stateTitle}>
267
+ {title}
268
+ </Text>
269
+ <Text style={styles.stateBody}>{body}</Text>
270
+ {onRetry ? (
271
+ <Pressable
272
+ accessibilityRole="button"
273
+ accessibilityLabel="Retry Gas City workspace mapping"
274
+ onPress={onRetry}
275
+ style={({ pressed }) => [styles.retryButton, pressed && styles.pressed]}
276
+ >
277
+ <Text style={styles.retryText}>Retry</Text>
278
+ </Pressable>
279
+ ) : null}
280
+ </View>
281
+ );
282
+ }
283
+
284
+ function createStyles(theme: PluginWorkspacePanelProps["theme"], compact: boolean) {
285
+ return StyleSheet.create({
286
+ screen: { flex: 1, backgroundColor: theme.colors.surface0 },
287
+ body: { flex: 1 },
288
+ mappingBar: {
289
+ minHeight: 54,
290
+ flexDirection: "row",
291
+ alignItems: "center",
292
+ gap: 9,
293
+ paddingHorizontal: compact ? 12 : 18,
294
+ borderBottomWidth: StyleSheet.hairlineWidth,
295
+ borderBottomColor: theme.colors.border,
296
+ backgroundColor: theme.colors.surface0,
297
+ },
298
+ mappingIcon: {
299
+ width: 30,
300
+ height: 30,
301
+ alignItems: "center",
302
+ justifyContent: "center",
303
+ borderRadius: 6,
304
+ backgroundColor: theme.colors.surface0,
305
+ },
306
+ mappingText: { flex: 1, minWidth: 0, gap: 2 },
307
+ mappingTitle: { color: theme.colors.foreground, fontSize: 12, fontWeight: "700" },
308
+ mappingMeta: { color: theme.colors.foregroundMuted, fontSize: 10 },
309
+ diagnostics: {
310
+ flexDirection: "row",
311
+ alignItems: "center",
312
+ gap: 7,
313
+ paddingHorizontal: compact ? 12 : 18,
314
+ paddingVertical: 8,
315
+ borderBottomWidth: StyleSheet.hairlineWidth,
316
+ borderBottomColor: theme.colors.border,
317
+ },
318
+ diagnosticText: { flex: 1, color: theme.colors.statusWarning, fontSize: 10 },
319
+ mappingStateScreen: { flex: 1, backgroundColor: theme.colors.surface0 },
320
+ stateCard: {
321
+ alignItems: "center",
322
+ justifyContent: "center",
323
+ gap: 9,
324
+ padding: 24,
325
+ backgroundColor: theme.colors.surface0,
326
+ },
327
+ stateTitle: {
328
+ color: theme.colors.foreground,
329
+ fontSize: 17,
330
+ fontWeight: "800",
331
+ textAlign: "center",
332
+ },
333
+ stateBody: {
334
+ maxWidth: 480,
335
+ color: theme.colors.foregroundMuted,
336
+ fontSize: 12,
337
+ lineHeight: 18,
338
+ textAlign: "center",
339
+ },
340
+ retryButton: {
341
+ minHeight: 36,
342
+ justifyContent: "center",
343
+ paddingHorizontal: 13,
344
+ borderRadius: 6,
345
+ backgroundColor: theme.colors.accent,
346
+ },
347
+ retryText: { color: theme.colors.accentForeground, fontSize: 12, fontWeight: "700" },
348
+ candidates: {
349
+ flex: 1,
350
+ borderTopWidth: StyleSheet.hairlineWidth,
351
+ borderTopColor: theme.colors.border,
352
+ },
353
+ candidatesContent: { gap: 7, padding: compact ? 12 : 18 },
354
+ candidatesTitle: {
355
+ color: theme.colors.foreground,
356
+ fontSize: 10,
357
+ fontWeight: "700",
358
+ letterSpacing: 0.9,
359
+ textTransform: "uppercase",
360
+ marginBottom: 4,
361
+ },
362
+ candidateRow: {
363
+ flexDirection: "row",
364
+ alignItems: "center",
365
+ gap: 8,
366
+ paddingVertical: 12,
367
+ paddingHorizontal: 10,
368
+ borderBottomWidth: StyleSheet.hairlineWidth,
369
+ borderColor: theme.colors.border,
370
+ },
371
+ pressed: { opacity: 0.72 },
372
+ });
373
+ }