@stevecastaneda/paseo-progress 0.1.7 → 0.2.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.
- package/README.md +18 -5
- package/client/attachments.tsx +4 -3
- package/client/history.tsx +157 -0
- package/client/panel.tsx +13 -10
- package/client/pill-popover.tsx +4 -2
- package/dist/server/cli.js +11 -21
- package/dist/server/paths.js +17 -1
- package/dist/server/progress-file.js +30 -45
- package/dist/server/store.js +205 -0
- package/dist/shared/events.js +7 -5
- package/index.server.ts +4 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/cli.ts +11 -20
- package/server/dashboard.ts +32 -7
- package/server/open.ts +5 -3
- package/server/paths.ts +16 -1
- package/server/progress-file.ts +30 -46
- package/server/store.ts +205 -0
- package/shared/events.ts +7 -5
- package/shared/rpc.ts +32 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Progress
|
|
2
2
|
|
|
3
|
-
A plugin for Paseo 0.9 and later that gives each worktree a live progress dashboard. Agents record progress with the `paseo-progress` command, which adds one line per change to
|
|
3
|
+
A plugin for Paseo 0.9 and later that gives each worktree a live progress dashboard. Agents record progress with the `paseo-progress` command, which adds one line per change to the worktree's progress file. That file lives outside the repo, so git never sees it. The **Progress** panel draws the dashboard from that file and updates by itself.
|
|
4
4
|
|
|
5
5
|
<img src="images/1-panel.png" alt="The Progress panel in Explorer" width="400">
|
|
6
6
|
|
|
@@ -18,7 +18,7 @@ The panel lives in Explorer, beside the agent chat, so you can watch both. Open
|
|
|
18
18
|
|
|
19
19
|
On phones, where Paseo has no Explorer pane, Progress opens as a tab of its own, and the pill stays above the message box while a run is open so there's always a way in. SVG files preview on desktop; on phones they open on the daemon host.
|
|
20
20
|
|
|
21
|
-
The first time a run starts in a worktree whose Progress panel has never been opened, the pill shows a spinner and reads **Progress** instead; pressing it opens the panel. Once the panel has been opened there, the plugin writes an empty
|
|
21
|
+
The first time a run starts in a worktree whose Progress panel has never been opened, the pill shows a spinner and reads **Progress** instead; pressing it opens the panel. Once the panel has been opened there, the plugin writes an empty `progress-panel-opened` file next to the progress file and the nudge never comes back, even for later runs. It doesn't open the panel by itself because Paseo switches to a workspace to open its panel, which would pull you away from whatever you're looking at.
|
|
22
22
|
|
|
23
23
|
## Install
|
|
24
24
|
|
|
@@ -41,7 +41,19 @@ node "$([ -f "$dir/dist/server/cli.js" ] && echo "$dir/dist/server/cli.js" || ec
|
|
|
41
41
|
|
|
42
42
|
If `paseo` isn't on your `PATH`, use `/Applications/Paseo.app/Contents/Resources/bin/paseo`. The command doesn't name a plugin folder: each run asks Paseo (`paseo plugin ls`) which copy of the plugin it is running and runs that copy, so it keeps working after updates and `npm run dev` switches. The npm package carries the command compiled to JavaScript in `dist/`, because Node won't run TypeScript from `node_modules`.
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
## Where progress is kept
|
|
45
|
+
|
|
46
|
+
Each worktree's progress lives outside the repo, in its own folder under `~/.local/state/paseo-progress/` on the daemon host. There's nothing to set up and nothing to hide: git never sees these files, and the plugin never edits a `.gitignore` or git's exclude file. The panel's empty state shows the exact path.
|
|
47
|
+
|
|
48
|
+
### Saving run history to the repo
|
|
49
|
+
|
|
50
|
+
If you want to keep runs, at the bottom of the panel press **Save to repo…** and pick a folder (or name a new one). From then on, each run is saved as its own file there, like `history/20261001-064512-export-feature.jsonl`, and updated as the run goes. Commit them like any other file. One file per run means two branches never write the same file, so merges don't conflict. The lock and the panel marker never go there.
|
|
51
|
+
|
|
52
|
+
The choice covers every worktree of the repo and is kept in the repo's local git config (`paseo-progress.saveRuns`), which nothing commits. **Change…** picks another folder for future runs; **Stop saving** turns it off. Files already saved are never moved or deleted.
|
|
53
|
+
|
|
54
|
+
### Upgrading from 0.1.x
|
|
55
|
+
|
|
56
|
+
Earlier versions kept progress in `.scratch/` in each worktree, hidden by a `.scratch/.gitignore` they wrote. The first time the command or the panel touches a worktree after the upgrade, the plugin moves its files out of `.scratch/` and removes that `.gitignore` if it's exactly the one the plugin wrote. If `.scratch/` is then empty, it goes too; anything else you keep there stays. If git tracks the old progress file (for example, an agent committed it), the plugin leaves it where it is and keeps using it, so nothing changes in that repo.
|
|
45
57
|
|
|
46
58
|
## Agent skill
|
|
47
59
|
|
|
@@ -86,13 +98,14 @@ paseo-progress finish "Snapshots ship for all four tables"
|
|
|
86
98
|
|
|
87
99
|
## The progress file
|
|
88
100
|
|
|
89
|
-
|
|
101
|
+
The progress file, `progress.jsonl`, holds one JSON event per line, each with a version (`v`), a UTC timestamp (`ts`) from the real clock, and a `type`. It is only ever appended to, so you can read or diff the history. A half-written last line is ignored. Other bad lines are skipped and listed in a small notice in the panel while the rest still renders. A lock keeps two commands from writing at once or handing out the same id.
|
|
90
102
|
|
|
91
103
|
## Limitations
|
|
92
104
|
|
|
93
105
|
- Nothing updates unless the agent runs `paseo-progress`. Agents need the skill, and an agent that skips the command leaves the dashboard behind.
|
|
94
106
|
- The command runs on the machine that hosts the Paseo daemon, and it needs Node.js 22.18 or later there.
|
|
95
|
-
- Each worktree keeps its own dashboard
|
|
107
|
+
- Each worktree keeps its own dashboard. There's no view across worktrees.
|
|
108
|
+
- Progress folders outside the repo stay behind when a worktree is deleted. They're small, and you can delete them from `~/.local/state/paseo-progress/`.
|
|
96
109
|
- Paseo can't open a panel without switching you to its workspace. So a new run shows a "Progress" pill, and you open the panel yourself.
|
|
97
110
|
- Pills check busy worktrees every 5 seconds and quiet ones every 30. In a quiet worktree, a new question can take up to half a minute to show.
|
|
98
111
|
- Previews cover images up to 10 MB and the first 256 KB of a text file. Other files open in their default app.
|
package/client/attachments.tsx
CHANGED
|
@@ -16,10 +16,11 @@ export function isPreviewable(attachment: Attachment): boolean {
|
|
|
16
16
|
return canPreview(attachment, { browser: Platform.OS === "web" });
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
// Full path on the daemon host for a stored path.
|
|
20
|
-
|
|
19
|
+
// Full path on the daemon host for a stored path. `root` is the worktree
|
|
20
|
+
// root the dashboard was read from, not the workspace directory.
|
|
21
|
+
export function absolutePath(path: string, root: string): string {
|
|
21
22
|
if (path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path)) return path;
|
|
22
|
-
const base =
|
|
23
|
+
const base = root.replace(/[\\/]+$/, "");
|
|
23
24
|
return path === "./" ? `${base}/` : `${base}/${path}`;
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { type PluginWorkspacePanelProps, useRpc } from "@getpaseo/plugin/client";
|
|
2
|
+
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
+
import React, { useState } from "react";
|
|
5
|
+
import { ActivityIndicator, ScrollView, Text, TextInput, View } from "react-native";
|
|
6
|
+
import { PressScale } from "./motion";
|
|
7
|
+
import { PressableRow } from "./row";
|
|
8
|
+
import { listFolders, setHistoryFolder } from "../shared/rpc";
|
|
9
|
+
|
|
10
|
+
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
11
|
+
|
|
12
|
+
// Run history stays on this computer unless the repo opts in. This quiet row
|
|
13
|
+
// is the only place that changes it; nothing in the repo changes until then.
|
|
14
|
+
export function HistoryRow({ colors, workspaceId, directory, savedTo }: {
|
|
15
|
+
colors: Colors;
|
|
16
|
+
workspaceId: string;
|
|
17
|
+
directory: string;
|
|
18
|
+
savedTo: string | null;
|
|
19
|
+
}) {
|
|
20
|
+
const [picking, setPicking] = useState(false);
|
|
21
|
+
const set = useRpc(setHistoryFolder);
|
|
22
|
+
const queryClient = useQueryClient();
|
|
23
|
+
const toast = useToast();
|
|
24
|
+
const mutation = useMutation({
|
|
25
|
+
mutationFn: (folder: string | null) => set({ workspaceId, workspaceDirectory: directory, folder }),
|
|
26
|
+
onSuccess: ({ savedTo: next }) => {
|
|
27
|
+
queryClient.invalidateQueries({ queryKey: ["progress", "dashboard"] });
|
|
28
|
+
toast.show(next ? `Each run will be saved to ${next}.` : "Runs will stay on this computer. Files already saved stay in the repo.", { variant: "success" });
|
|
29
|
+
},
|
|
30
|
+
onError: (error) => toast.error(error instanceof Error ? error.message : "Could not change where runs are saved."),
|
|
31
|
+
});
|
|
32
|
+
// While saving, the button that started it says so.
|
|
33
|
+
const busy = mutation.isPending ? (mutation.variables === null ? "stop" : "pick") : null;
|
|
34
|
+
const actions: { label: string; busyLabel: string; kind: "pick" | "stop"; onPress(): void }[] = savedTo
|
|
35
|
+
? [{ label: "Change…", busyLabel: "Saving…", kind: "pick", onPress: () => setPicking(true) }, { label: "Stop saving", busyLabel: "Stopping…", kind: "stop", onPress: () => mutation.mutate(null) }]
|
|
36
|
+
: [{ label: "Save to repo…", busyLabel: "Saving…", kind: "pick", onPress: () => setPicking(true) }];
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", columnGap: 8, rowGap: 4, marginHorizontal: 12, marginTop: 16 }}>
|
|
40
|
+
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 6, flex: 1, minWidth: 180 }}>
|
|
41
|
+
<View style={{ paddingTop: 2 }}><Icon name="History" size={13} color={colors.foregroundMuted} /></View>
|
|
42
|
+
<Text style={{ flex: 1, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
43
|
+
{savedTo ? <>Each run is saved to <Text style={{ color: colors.foreground }}>{savedTo}</Text> in this repo.</> : "Run history stays on this computer."}
|
|
44
|
+
</Text>
|
|
45
|
+
</View>
|
|
46
|
+
<View style={{ flexDirection: "row", gap: 4 }}>
|
|
47
|
+
{actions.map((action) => (
|
|
48
|
+
<PressScale key={action.label} accessibilityRole="button" disabled={mutation.isPending} onPress={action.onPress}
|
|
49
|
+
style={({ pressed, hovered }) => ({ paddingHorizontal: 8, paddingVertical: 4, borderRadius: 4, opacity: mutation.isPending ? 0.7 : 1,
|
|
50
|
+
backgroundColor: pressed ? colors.border : hovered ? colors.surface2 : "transparent" })}>
|
|
51
|
+
<Text style={{ color: colors.foreground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>{busy === action.kind ? action.busyLabel : action.label}</Text>
|
|
52
|
+
</PressScale>
|
|
53
|
+
))}
|
|
54
|
+
</View>
|
|
55
|
+
<FolderPicker key={picking ? "open" : "closed"} colors={colors} workspaceId={workspaceId} directory={directory} open={picking} start={savedTo ?? ""}
|
|
56
|
+
onClose={() => setPicking(false)} onPick={(folder) => { setPicking(false); mutation.mutate(folder); }} />
|
|
57
|
+
</View>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Browses the repo's folders on the daemon host. A new folder only needs a
|
|
62
|
+
// name: it's made the first time a run is saved there.
|
|
63
|
+
function FolderPicker({ colors, workspaceId, directory, open, start, onClose, onPick }: {
|
|
64
|
+
colors: Colors;
|
|
65
|
+
workspaceId: string;
|
|
66
|
+
directory: string;
|
|
67
|
+
open: boolean;
|
|
68
|
+
// The folder chosen so far; browsing starts in the folder that holds it.
|
|
69
|
+
start: string;
|
|
70
|
+
onClose(): void;
|
|
71
|
+
onPick(folder: string): void;
|
|
72
|
+
}) {
|
|
73
|
+
const parentOf = (path: string) => path.split("/").slice(0, -1).join("/");
|
|
74
|
+
const [at, setAt] = useState(() => parentOf(start));
|
|
75
|
+
const [naming, setNaming] = useState<string | null>(null);
|
|
76
|
+
const list = useRpc(listFolders);
|
|
77
|
+
const listed = useQuery({
|
|
78
|
+
queryKey: ["progress", "folders", workspaceId, directory, at],
|
|
79
|
+
queryFn: () => list({ workspaceId, workspaceDirectory: directory, folder: at }),
|
|
80
|
+
enabled: open,
|
|
81
|
+
});
|
|
82
|
+
const join = (name: string) => (at ? `${at}/${name}` : name);
|
|
83
|
+
const crumbs = at ? at.split("/") : [];
|
|
84
|
+
const close = () => { setNaming(null); setAt(parentOf(start)); onClose(); };
|
|
85
|
+
const newName = naming?.trim().replace(/\/+$/, "") ?? "";
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<Modal title="Save run history to a folder" icon={<Icon name="FolderOpen" size={16} color={colors.foregroundMuted} />} open={open} onOpenChange={(next) => { if (!next) close(); }}>
|
|
89
|
+
{/* Paseo sizes a non-scrolling dialog to 85% of the window, so let it size to
|
|
90
|
+
its content; the list's own height cap keeps the buttons in view. */}
|
|
91
|
+
<Modal.Content>
|
|
92
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
93
|
+
Each run becomes its own file in this folder, in every worktree of this repo. Commit them like any other file.
|
|
94
|
+
</Text>
|
|
95
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 2, paddingHorizontal: 4 }}>
|
|
96
|
+
{["Top of the repo", ...crumbs].map((crumb, index) => (
|
|
97
|
+
<React.Fragment key={index}>
|
|
98
|
+
{index ? <Icon name="ChevronRight" size={12} color={colors.foregroundMuted} /> : null}
|
|
99
|
+
<PressScale accessibilityRole="button" disabled={index === crumbs.length} onPress={() => { setNaming(null); setAt(crumbs.slice(0, index).join("/")); }}
|
|
100
|
+
style={({ pressed, hovered }) => ({ paddingHorizontal: 4, paddingVertical: 2, borderRadius: 4,
|
|
101
|
+
backgroundColor: index === crumbs.length ? "transparent" : pressed ? colors.border : hovered ? colors.surface2 : "transparent" })}>
|
|
102
|
+
<Text style={{ color: index === crumbs.length ? colors.foreground : colors.foregroundMuted, fontSize: 12, lineHeight: 16, fontWeight: index === crumbs.length ? "600" : "400" }}>{crumb}</Text>
|
|
103
|
+
</PressScale>
|
|
104
|
+
</React.Fragment>
|
|
105
|
+
))}
|
|
106
|
+
</View>
|
|
107
|
+
|
|
108
|
+
{/* Only the list scrolls, so Use and Cancel stay in view in a big repo. */}
|
|
109
|
+
<ScrollView style={{ maxHeight: 320, flexGrow: 0 }} contentContainerStyle={{ gap: 2 }}>
|
|
110
|
+
{listed.isPending ? <ActivityIndicator color={colors.foregroundMuted} accessibilityLabel="Loading folders" style={{ alignSelf: "flex-start", padding: 8 }} /> : null}
|
|
111
|
+
{listed.data?.folders.map((name) => (
|
|
112
|
+
<PressableRow key={name} colors={colors} accessibilityRole="button" accessibilityLabel={`Open ${name}`} onPress={() => { setNaming(null); setAt(join(name)); }}
|
|
113
|
+
style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 8, paddingVertical: 6, borderRadius: 6 }}>
|
|
114
|
+
<Icon name="Folder" size={14} color={colors.foregroundMuted} />
|
|
115
|
+
<Text numberOfLines={1} ellipsizeMode="middle" style={{ flex: 1, color: colors.foreground, fontSize: 12, lineHeight: 16 }}>{name}</Text>
|
|
116
|
+
<Icon name="ChevronRight" size={14} color={colors.foregroundMuted} />
|
|
117
|
+
</PressableRow>
|
|
118
|
+
))}
|
|
119
|
+
{listed.data && !listed.data.folders.length ? (
|
|
120
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 16, paddingHorizontal: 8, paddingVertical: 6 }}>No folders in here.</Text>
|
|
121
|
+
) : null}
|
|
122
|
+
{listed.error ? <Text accessibilityRole="alert" style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 17, paddingHorizontal: 8 }}>{listed.error.message}</Text> : null}
|
|
123
|
+
</ScrollView>
|
|
124
|
+
|
|
125
|
+
{naming === null ? (
|
|
126
|
+
<PressScale accessibilityRole="button" onPress={() => setNaming("")}
|
|
127
|
+
style={({ pressed, hovered }) => ({ alignSelf: "flex-start", flexDirection: "row", alignItems: "center", gap: 6, paddingHorizontal: 8, paddingVertical: 5, borderRadius: 6, backgroundColor: pressed ? colors.border : hovered ? colors.surface2 : "transparent" })}>
|
|
128
|
+
<Icon name="FolderPlus" size={14} color={colors.foreground} />
|
|
129
|
+
<Text style={{ color: colors.foreground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>New folder</Text>
|
|
130
|
+
</PressScale>
|
|
131
|
+
) : (
|
|
132
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
133
|
+
<TextInput autoFocus value={naming} onChangeText={setNaming} onSubmitEditing={() => { if (newName) onPick(join(newName)); }}
|
|
134
|
+
autoCapitalize="none" autoCorrect={false} spellCheck={false} accessibilityLabel="New folder name" placeholder="progress-history" placeholderTextColor={colors.foregroundMuted}
|
|
135
|
+
style={{ flex: 1, color: colors.foreground, fontSize: 12, lineHeight: 16, paddingHorizontal: 8, paddingVertical: 6, borderRadius: 6, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface1 }} />
|
|
136
|
+
<PressScale accessibilityRole="button" disabled={!newName} onPress={() => onPick(join(newName))}
|
|
137
|
+
style={({ pressed, hovered }) => ({ paddingHorizontal: 10, paddingVertical: 6, borderRadius: 6, backgroundColor: colors.accent, opacity: !newName ? 0.5 : pressed ? 0.7 : hovered ? 0.85 : 1 })}>
|
|
138
|
+
<Text style={{ color: colors.accentForeground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>Use new folder</Text>
|
|
139
|
+
</PressScale>
|
|
140
|
+
</View>
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", justifyContent: "flex-end", gap: 8 }}>
|
|
144
|
+
{at ? null : <Text style={{ flex: 1, minWidth: 140, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>Open a folder to use it, or make a new one.</Text>}
|
|
145
|
+
<PressScale accessibilityRole="button" onPress={close}
|
|
146
|
+
style={({ pressed, hovered }) => ({ paddingHorizontal: 12, paddingVertical: 6, borderRadius: 6, backgroundColor: pressed ? colors.border : hovered ? colors.surface2 : "transparent" })}>
|
|
147
|
+
<Text style={{ color: colors.foreground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>Cancel</Text>
|
|
148
|
+
</PressScale>
|
|
149
|
+
<PressScale accessibilityRole="button" disabled={!at} onPress={() => onPick(at)} outerStyle={{ flexShrink: 1 }}
|
|
150
|
+
style={({ pressed, hovered }) => ({ paddingHorizontal: 12, paddingVertical: 6, borderRadius: 6, backgroundColor: colors.accent, opacity: !at ? 0.5 : pressed ? 0.7 : hovered ? 0.85 : 1 })}>
|
|
151
|
+
<Text numberOfLines={1} ellipsizeMode="middle" style={{ color: colors.accentForeground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>{at ? `Use ${crumbs[crumbs.length - 1]}` : "Use folder"}</Text>
|
|
152
|
+
</PressScale>
|
|
153
|
+
</View>
|
|
154
|
+
</Modal.Content>
|
|
155
|
+
</Modal>
|
|
156
|
+
);
|
|
157
|
+
}
|
package/client/panel.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { type PluginWorkspacePanelProps, useRpc, useWorkspace } from "@getpaseo/
|
|
|
2
2
|
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
3
3
|
import { DeliverablesList } from "./deliverables";
|
|
4
4
|
import { LauncherBanner, SkillBanner } from "./launcher";
|
|
5
|
+
import { HistoryRow } from "./history";
|
|
5
6
|
import { AnsweredQuestionsList, QuestionsSection } from "./questions";
|
|
6
7
|
import { Spinner, StalledPulse, WaitingDot } from "./spinner";
|
|
7
8
|
import { IconSwap, Presence, StaggerRoot, nativeDriver } from "./motion";
|
|
@@ -11,7 +12,7 @@ import { When } from "./when";
|
|
|
11
12
|
import React, { useEffect, useRef, useState } from "react";
|
|
12
13
|
import { ActivityIndicator, Animated, Platform, Pressable, ScrollView, Text, View } from "react-native";
|
|
13
14
|
import { finishedToFold, isFinished, shortTitle, type Activity, type Dashboard, type FileIssue, type ProgressSegment, type StuckItem, type Ticket } from "../shared/dashboard";
|
|
14
|
-
import {
|
|
15
|
+
import type { TicketStatus } from "../shared/events";
|
|
15
16
|
import { formatEstimate, formatMinutes, formatWorkDone, minutesSince } from "../shared/format";
|
|
16
17
|
import { markPanelOpened } from "../shared/rpc";
|
|
17
18
|
import { useDashboard } from "./dashboard-query";
|
|
@@ -51,10 +52,11 @@ function WorkspaceProgress({ theme, workspaceId, host, layout, navigation }: Plu
|
|
|
51
52
|
<LauncherBanner colors={colors} host={host} />
|
|
52
53
|
<SkillBanner colors={colors} host={host} />
|
|
53
54
|
{/* Fades in like the dashboard's sections instead of popping in after the spinner. */}
|
|
54
|
-
<Presence show={Boolean(result && !result.configured)}>{result && !result.configured ? <EmptyState colors={colors} /> : null}</Presence>
|
|
55
|
+
<Presence show={Boolean(result && !result.configured)}>{result && !result.configured ? <EmptyState colors={colors} file={result.file} /> : null}</Presence>
|
|
55
56
|
{/* Keyed by run: a new run starts with fresh tabs, pages and dialogs. */}
|
|
56
57
|
{result?.configured ? <DashboardView key={result.dashboard.run?.startedAt ?? "none"} colors={colors} dashboard={result.dashboard} agentRunning={agentRunning} compact={layout.compact}
|
|
57
|
-
workspaceId={workspaceId} workspaceDirectory={
|
|
58
|
+
workspaceId={workspaceId} workspaceDirectory={result.root} file={result.file} navigation={navigation} /> : null}
|
|
59
|
+
{result?.inRepo && directory ? <HistoryRow colors={colors} workspaceId={workspaceId} directory={directory} savedTo={result.savedTo} /> : null}
|
|
58
60
|
</ScrollView>
|
|
59
61
|
);
|
|
60
62
|
}
|
|
@@ -74,7 +76,7 @@ function useMarkPanelOpened(workspaceId: string, directory: string | null, neede
|
|
|
74
76
|
}, [needed, directory, workspaceId, mark]);
|
|
75
77
|
}
|
|
76
78
|
|
|
77
|
-
function EmptyState({ colors }: { colors: Colors }) {
|
|
79
|
+
function EmptyState({ colors, file }: { colors: Colors; file: string }) {
|
|
78
80
|
return (
|
|
79
81
|
<View style={{ padding: 12, paddingTop: 20, gap: 10 }}>
|
|
80
82
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
@@ -91,9 +93,9 @@ function EmptyState({ colors }: { colors: Colors }) {
|
|
|
91
93
|
{`paseo-progress start "Build the export feature"\npaseo-progress ticket add "Ticket 01: Export button" --estimate 60`}
|
|
92
94
|
</Text>
|
|
93
95
|
</View>
|
|
94
|
-
{/* So
|
|
96
|
+
{/* So people know nothing lands in the repo. */}
|
|
95
97
|
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
|
|
96
|
-
Progress is saved in <Text style={{ color: colors.foreground }}
|
|
98
|
+
Progress is saved outside the repo, in <Text selectable style={{ color: colors.foreground }}>{file}</Text>, so git never sees it.
|
|
97
99
|
</Text>
|
|
98
100
|
</View>
|
|
99
101
|
);
|
|
@@ -122,13 +124,14 @@ function useNow(intervalMs: number): number {
|
|
|
122
124
|
|
|
123
125
|
const WIDE_MIN = 760;
|
|
124
126
|
|
|
125
|
-
function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId, workspaceDirectory, navigation }: {
|
|
127
|
+
function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId, workspaceDirectory, file, navigation }: {
|
|
126
128
|
colors: Colors;
|
|
127
129
|
dashboard: Dashboard;
|
|
128
130
|
agentRunning: boolean;
|
|
129
131
|
compact: boolean;
|
|
130
132
|
workspaceId: string;
|
|
131
133
|
workspaceDirectory: string;
|
|
134
|
+
file: string;
|
|
132
135
|
navigation: PluginWorkspacePanelProps["navigation"];
|
|
133
136
|
}) {
|
|
134
137
|
const now = useNow(15_000);
|
|
@@ -198,7 +201,7 @@ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId,
|
|
|
198
201
|
</Text>
|
|
199
202
|
</View>
|
|
200
203
|
) : null}</Presence>
|
|
201
|
-
<Presence show={Boolean(dashboard.issues.length)} order={1}>{dashboard.issues.length ? <IssuesNotice colors={colors} issues={dashboard.issues} /> : null}</Presence>
|
|
204
|
+
<Presence show={Boolean(dashboard.issues.length)} order={1}>{dashboard.issues.length ? <IssuesNotice colors={colors} issues={dashboard.issues} file={file} /> : null}</Presence>
|
|
202
205
|
<Presence show={Boolean(dashboard.progress.totalMin > 0)} order={2}>{dashboard.progress.totalMin > 0 ? <ProgressBar colors={colors} progress={dashboard.progress} live={live} /> : null}</Presence>
|
|
203
206
|
<View onLayout={(event) => setWide(event.nativeEvent.layout.width >= WIDE_MIN)}
|
|
204
207
|
style={{ flexDirection: wide ? "row" : "column", alignItems: "flex-start" }}>
|
|
@@ -314,13 +317,13 @@ function ActivityList({ colors, activity, now }: { colors: Colors; activity: Act
|
|
|
314
317
|
}
|
|
315
318
|
|
|
316
319
|
// Small and non-blocking: the rest of the dashboard still renders.
|
|
317
|
-
function IssuesNotice({ colors, issues }: { colors: Colors; issues: FileIssue[] }) {
|
|
320
|
+
function IssuesNotice({ colors, issues, file }: { colors: Colors; issues: FileIssue[]; file: string }) {
|
|
318
321
|
const shown = issues.slice(0, 5);
|
|
319
322
|
return (
|
|
320
323
|
<View style={{ flexDirection: "row", gap: 8, margin: 12, marginBottom: 0, padding: 8, borderRadius: 6, backgroundColor: colors.surface1 }}>
|
|
321
324
|
<View style={{ paddingTop: 1.5 }}><Icon name="FileWarning" size={14} color={colors.statusWarning} /></View>
|
|
322
325
|
<Text selectable style={{ flex: 1, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
323
|
-
Skipped {issues.length === 1 ? "1 line" : `${issues.length} lines`} in {
|
|
326
|
+
Skipped {issues.length === 1 ? "1 line" : `${issues.length} lines`} in {file}:{" "}
|
|
324
327
|
{shown.map((issue) => `line ${issue.line} (${issue.reason})`).join(", ")}
|
|
325
328
|
{issues.length > shown.length ? `, and ${issues.length - shown.length} more` : ""}.
|
|
326
329
|
</Text>
|
package/client/pill-popover.tsx
CHANGED
|
@@ -28,12 +28,14 @@ export function AttentionPopover({ theme, host, layout, workspaceId, close, open
|
|
|
28
28
|
const directory = useWorkspace(workspaceId, (workspace) => workspace.directory);
|
|
29
29
|
const query = useDashboard(host.id, workspaceId, directory);
|
|
30
30
|
const dashboard = query.data?.configured ? query.data.dashboard : null;
|
|
31
|
+
// Stored paths are relative to the worktree root, which can sit above the workspace directory.
|
|
32
|
+
const root = query.data?.root ?? directory ?? "";
|
|
31
33
|
const copy = useCopy();
|
|
32
34
|
// Previews open in a dialog over the popover, which stays open to return to.
|
|
33
35
|
// Links and other files leave Paseo, so the popover closes for those.
|
|
34
36
|
const [previewing, setPreviewing] = useState<{ attachment: Attachment; group: Attachment[] } | null>(null);
|
|
35
37
|
const group = useRef<Attachment[]>([]);
|
|
36
|
-
const opener = useAttachmentOpener({ workspaceId, workspaceDirectory:
|
|
38
|
+
const opener = useAttachmentOpener({ workspaceId, workspaceDirectory: root, onPreview: (attachment) => setPreviewing({ attachment, group: previewable(group.current) }) });
|
|
37
39
|
return (
|
|
38
40
|
<View style={{ width: 360, maxWidth: "100%", gap: 8 }}>
|
|
39
41
|
{dashboard ? (
|
|
@@ -52,7 +54,7 @@ export function AttentionPopover({ theme, host, layout, workspaceId, close, open
|
|
|
52
54
|
{query.error ? `Could not read progress: ${query.error.message}` : "Loading…"}
|
|
53
55
|
</Text>
|
|
54
56
|
)}
|
|
55
|
-
<PreviewDialog colors={colors} attachment={previewing?.attachment ?? null} workspaceId={workspaceId} workspaceDirectory={
|
|
57
|
+
<PreviewDialog colors={colors} attachment={previewing?.attachment ?? null} workspaceId={workspaceId} workspaceDirectory={root}
|
|
56
58
|
onClose={() => setPreviewing(null)} onOpenOnHost={(attachment) => void opener.openOnHost(attachment)}
|
|
57
59
|
gallery={previewing ? { items: previewing.group, onSelect: (attachment) => setPreviewing({ ...previewing, attachment }) } : undefined} />
|
|
58
60
|
<View style={{ paddingTop: touch ? 12 : 8, borderTopWidth: 1, borderTopColor: colors.border }}>
|
package/dist/server/cli.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
// The command agents run to record progress. Each command appends one event
|
|
2
2
|
// to the worktree's progress file and prints what it recorded.
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { realpathSync, statSync } from "node:fs";
|
|
4
|
+
import { relative, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { parseArgs } from "node:util";
|
|
7
7
|
import { isSettled } from "../shared/dashboard.js";
|
|
8
|
-
import { DELIVERABLE_KINDS,
|
|
8
|
+
import { DELIVERABLE_KINDS, TICKET_STATUSES } from "../shared/events.js";
|
|
9
9
|
import { appendProgress, InvalidEvent, readProgress } from "./progress-file.js";
|
|
10
|
-
import { outside } from "./paths.js";
|
|
10
|
+
import { findRoot, outside } from "./paths.js";
|
|
11
11
|
import { installLauncher } from "./launcher.js";
|
|
12
|
+
import { findStore, saveRunCopy } from "./store.js";
|
|
12
13
|
import { dashboardText } from "../shared/show.js";
|
|
13
14
|
import { imageMimeType } from "../shared/attachments.js";
|
|
14
15
|
import { homedir } from "node:os";
|
|
@@ -426,7 +427,7 @@ function help() {
|
|
|
426
427
|
return [
|
|
427
428
|
`Usage: ${USAGE_NAME} <command>`,
|
|
428
429
|
"",
|
|
429
|
-
`Records progress
|
|
430
|
+
`Records progress for this worktree, shown by the Progress panel in Paseo. The file lives outside the repo, so git never sees it.`,
|
|
430
431
|
`Add --help after any command for its options.`,
|
|
431
432
|
"",
|
|
432
433
|
...Object.values(commands).flatMap((command) => [` ${command.usage}`, ` ${command.summary}`]),
|
|
@@ -436,19 +437,6 @@ function help() {
|
|
|
436
437
|
` ${INSTALL_SUMMARY}`,
|
|
437
438
|
].join("\n");
|
|
438
439
|
}
|
|
439
|
-
// The worktree root is the nearest directory with a `.git` entry (a folder in
|
|
440
|
-
// a checkout, a file in a linked worktree). Outside git, use the directory.
|
|
441
|
-
export function findRoot(cwd) {
|
|
442
|
-
let directory = resolve(cwd);
|
|
443
|
-
while (true) {
|
|
444
|
-
if (existsSync(join(directory, ".git")))
|
|
445
|
-
return directory;
|
|
446
|
-
const parent = dirname(directory);
|
|
447
|
-
if (parent === directory)
|
|
448
|
-
return resolve(cwd);
|
|
449
|
-
directory = parent;
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
440
|
export async function runCli(argv, options) {
|
|
453
441
|
const out = options.out ?? ((line) => console.log(line));
|
|
454
442
|
const now = options.now ?? (() => new Date());
|
|
@@ -464,7 +452,8 @@ export async function runCli(argv, options) {
|
|
|
464
452
|
}
|
|
465
453
|
if (first === "show") {
|
|
466
454
|
const root = findRoot(options.cwd);
|
|
467
|
-
|
|
455
|
+
const store = await findStore(root);
|
|
456
|
+
out(`Worktree: ${root}\n\n${dashboardText((await readProgress(store.directory, now())).dashboard, now().getTime())}`);
|
|
468
457
|
return 0;
|
|
469
458
|
}
|
|
470
459
|
const target = resolve(options.cwd, (argv[1] ?? DEFAULT_LAUNCHER).replace(/^~(?=$|\/)/, homedir()));
|
|
@@ -491,14 +480,15 @@ export async function runCli(argv, options) {
|
|
|
491
480
|
throw new UsageError(error.message);
|
|
492
481
|
}
|
|
493
482
|
const root = findRoot(options.cwd);
|
|
494
|
-
const
|
|
483
|
+
const store = await findStore(root);
|
|
484
|
+
const message = await appendProgress(store.directory, now, (state) => {
|
|
495
485
|
const finished = state.dashboard.run?.finished;
|
|
496
486
|
if (finished && command !== commands.start) {
|
|
497
487
|
throw new UsageError(`The run "${state.dashboard.run.title}" is finished. Start new work with: ${USAGE_NAME} start "<title>"`);
|
|
498
488
|
}
|
|
499
489
|
const { event, message } = command.run({ positionals: parsed.positionals, values: parsed.values, state, cwd: options.cwd, root });
|
|
500
490
|
return { event, result: message };
|
|
501
|
-
});
|
|
491
|
+
}, (text) => saveRunCopy(root, text));
|
|
502
492
|
out(message);
|
|
503
493
|
return 0;
|
|
504
494
|
}
|
package/dist/server/paths.js
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
2
3
|
// Whether a path from relative(root, target) leaves the root. "..notes" is a
|
|
3
4
|
// name inside it, not a step out.
|
|
4
5
|
export function outside(inside) {
|
|
5
6
|
return inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside);
|
|
6
7
|
}
|
|
8
|
+
// The worktree root is the nearest directory with a `.git` entry (a folder in
|
|
9
|
+
// a checkout, a file in a linked worktree). Outside git, use the directory.
|
|
10
|
+
// The command and the panel both start from here, so a Paseo project set at a
|
|
11
|
+
// subfolder of the repo still reads the file the agent writes.
|
|
12
|
+
export function findRoot(cwd) {
|
|
13
|
+
let directory = resolve(cwd);
|
|
14
|
+
while (true) {
|
|
15
|
+
if (existsSync(join(directory, ".git")))
|
|
16
|
+
return directory;
|
|
17
|
+
const parent = dirname(directory);
|
|
18
|
+
if (parent === directory)
|
|
19
|
+
return resolve(cwd);
|
|
20
|
+
directory = parent;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -1,26 +1,17 @@
|
|
|
1
1
|
// Everything that reads or writes a worktree's progress files: the event log
|
|
2
|
-
// the command appends to
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// the command appends to and the panel-opened marker. Each function takes the
|
|
3
|
+
// folder that holds them, which `findStore` in store.ts picks. The command and
|
|
4
|
+
// the panel's RPC handlers go through here, so they agree on how they're read.
|
|
5
5
|
import { access, appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
6
|
-
import {
|
|
6
|
+
import { join } from "node:path";
|
|
7
7
|
import { EMPTY_DASHBOARD, parseProgress, reduceProgress } from "../shared/dashboard.js";
|
|
8
|
-
import { EVENT_VERSION, eventSchema,
|
|
8
|
+
import { EVENT_VERSION, eventSchema, PANEL_OPENED_NAME, PROGRESS_FILE_NAME } from "../shared/events.js";
|
|
9
9
|
import { withLock } from "./lock.js";
|
|
10
|
-
const
|
|
11
|
-
const LOCK_FILE = `${PROGRESS_FILE}.lock`;
|
|
12
|
-
// Names only this plugin's files, so they stay out of git without the user
|
|
13
|
-
// editing their own .gitignore. Other files in .scratch/ are left to the repo.
|
|
14
|
-
export const SCRATCH_GITIGNORE = `# Added by the progress plugin for Paseo so its files stay out of git.
|
|
15
|
-
${basename(PROGRESS_FILE)}
|
|
16
|
-
${basename(LOCK_FILE)}/
|
|
17
|
-
${basename(PANEL_OPENED_FILE)}
|
|
18
|
-
.gitignore
|
|
19
|
-
`;
|
|
10
|
+
const LOCK_NAME = `${PROGRESS_FILE_NAME}.lock`;
|
|
20
11
|
// A draft the event schema rejects. The message names each bad field.
|
|
21
12
|
export class InvalidEvent extends Error {
|
|
22
13
|
}
|
|
23
|
-
// The parsed file per
|
|
14
|
+
// The parsed file per folder, kept until its size or change time moves. The
|
|
24
15
|
// file only grows, and the panel and pills read it every few seconds.
|
|
25
16
|
const parsedFiles = new Map();
|
|
26
17
|
// Replaced file snapshots and their derived data can be collected together.
|
|
@@ -29,9 +20,9 @@ const dashboards = new WeakMap();
|
|
|
29
20
|
// reuse the last one until the file changes or a stale or overdue time passes.
|
|
30
21
|
// Stale and overdue checks use the daemon host's clock, the same clock the
|
|
31
22
|
// command stamps events with.
|
|
32
|
-
export async function readProgress(
|
|
33
|
-
const parsed = await readParsed(
|
|
34
|
-
const panelOpened = await exists(join(
|
|
23
|
+
export async function readProgress(directory, now = new Date()) {
|
|
24
|
+
const parsed = await readParsed(directory);
|
|
25
|
+
const panelOpened = await exists(join(directory, PANEL_OPENED_NAME));
|
|
35
26
|
if (parsed === null)
|
|
36
27
|
return { configured: false, dashboard: EMPTY_DASHBOARD, panelOpened };
|
|
37
28
|
let cached = dashboards.get(parsed);
|
|
@@ -46,10 +37,12 @@ export async function readProgress(root, now = new Date()) {
|
|
|
46
37
|
// Appends the one event `write` builds from the file as it is now. Commands
|
|
47
38
|
// that run at once take turns, so each sees the others' events and no two hand
|
|
48
39
|
// out the same id. `write` may throw to append nothing.
|
|
49
|
-
export async function appendProgress(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
40
|
+
export async function appendProgress(directory, now, write,
|
|
41
|
+
// Runs before the lock is released, with the whole file as it now is.
|
|
42
|
+
after) {
|
|
43
|
+
await mkdir(directory, { recursive: true });
|
|
44
|
+
return withLock(join(directory, LOCK_NAME), async () => {
|
|
45
|
+
const text = (await readText(directory)) ?? "";
|
|
53
46
|
const { dashboard, nextIds } = reduceProgress(parseProgress(text), now());
|
|
54
47
|
const { event, result } = write({ dashboard, nextIds });
|
|
55
48
|
const checked = eventSchema.safeParse({ v: EVENT_VERSION, ts: now().toISOString(), ...withoutUndefined(event) });
|
|
@@ -58,27 +51,19 @@ export async function appendProgress(root, now, write) {
|
|
|
58
51
|
}
|
|
59
52
|
// A line cut short (a killed command, a hand edit) would swallow this event, so start a new line.
|
|
60
53
|
const lead = text && !text.endsWith("\n") ? "\n" : "";
|
|
61
|
-
|
|
54
|
+
const line = `${lead}${JSON.stringify(checked.data)}\n`;
|
|
55
|
+
await appendFile(join(directory, PROGRESS_FILE_NAME), line);
|
|
56
|
+
await after?.(text + line);
|
|
62
57
|
return result;
|
|
63
58
|
});
|
|
64
59
|
}
|
|
65
|
-
export async function markPanelOpened(
|
|
66
|
-
await prepareScratch(root);
|
|
67
|
-
await writeFile(join(root, PANEL_OPENED_FILE), "");
|
|
68
|
-
}
|
|
69
|
-
// Creates .scratch/ in the worktree and, the first time, its .gitignore. An
|
|
70
|
-
// existing .gitignore is the user's, so it is never touched.
|
|
71
|
-
async function prepareScratch(root) {
|
|
72
|
-
const directory = join(root, SCRATCH_DIR);
|
|
60
|
+
export async function markPanelOpened(directory) {
|
|
73
61
|
await mkdir(directory, { recursive: true });
|
|
74
|
-
await writeFile(join(directory,
|
|
75
|
-
if (error.code !== "EEXIST")
|
|
76
|
-
throw error;
|
|
77
|
-
});
|
|
62
|
+
await writeFile(join(directory, PANEL_OPENED_NAME), "");
|
|
78
63
|
}
|
|
79
|
-
async function readText(
|
|
64
|
+
async function readText(directory) {
|
|
80
65
|
try {
|
|
81
|
-
return await readFile(join(
|
|
66
|
+
return await readFile(join(directory, PROGRESS_FILE_NAME), "utf8");
|
|
82
67
|
}
|
|
83
68
|
catch (error) {
|
|
84
69
|
if (error.code === "ENOENT")
|
|
@@ -86,27 +71,27 @@ async function readText(root) {
|
|
|
86
71
|
throw error;
|
|
87
72
|
}
|
|
88
73
|
}
|
|
89
|
-
async function readParsed(
|
|
90
|
-
const info = await stat(join(
|
|
74
|
+
async function readParsed(directory) {
|
|
75
|
+
const info = await stat(join(directory, PROGRESS_FILE_NAME)).catch((error) => {
|
|
91
76
|
if (error.code === "ENOENT")
|
|
92
77
|
return null;
|
|
93
78
|
throw error;
|
|
94
79
|
});
|
|
95
80
|
if (!info) {
|
|
96
|
-
parsedFiles.delete(
|
|
81
|
+
parsedFiles.delete(directory);
|
|
97
82
|
return null;
|
|
98
83
|
}
|
|
99
|
-
const cached = parsedFiles.get(
|
|
84
|
+
const cached = parsedFiles.get(directory);
|
|
100
85
|
if (cached && cached.size === info.size && cached.mtimeMs === info.mtimeMs && cached.ctimeMs === info.ctimeMs)
|
|
101
86
|
return cached.parsed;
|
|
102
87
|
// Stat before reading: if the file grows in between, the next stat won't match and it's read again.
|
|
103
|
-
const text = await readText(
|
|
88
|
+
const text = await readText(directory);
|
|
104
89
|
if (text === null) {
|
|
105
|
-
parsedFiles.delete(
|
|
90
|
+
parsedFiles.delete(directory);
|
|
106
91
|
return null;
|
|
107
92
|
}
|
|
108
93
|
const parsed = parseProgress(text);
|
|
109
|
-
parsedFiles.set(
|
|
94
|
+
parsedFiles.set(directory, { size: info.size, mtimeMs: info.mtimeMs, ctimeMs: info.ctimeMs, parsed });
|
|
110
95
|
return parsed;
|
|
111
96
|
}
|
|
112
97
|
async function exists(path) {
|